Overview

Howdy, my fellow network tinkerers! Haxinator 2000 is back with another tale of VPN wizardry. After taming the NetworkManager iodine plugin, I took the same approach to an ICMP-based tunneling solution called HANS. The result? A brand-new Python plugin that integrates seamlessly with NetworkManager via D-Bus, letting you run a stealthy, tunnel-over-ICMP VPN right on your Linux system.

In this blog post, I'll walk you through the gritty details of how and why I wrote this Python-based HANS plugin—plus highlight the new features and battle scars I picked up along the way.

The Backstory

After triumphantly patching the network-manager-iodine plugin (documented in my previous blog post), I found myself wanting more. Why stop at DNS tunneling? Let's do ICMP next!

I'd heard rumblings of HANS (some folks call it a fancy "ping tunnel"). I said, "Sure, I can create a NetworkManager plugin for that!" So, armed with Python, D-Bus, and my unstoppable curiosity, I hacked up a new script to manage a HANS session just like a normal VPN, complete with:

  • Connect & Disconnect methods via D-Bus
  • Automatic route and IP assignment
  • Connection state monitoring (pinging the remote server to confirm readiness)
  • And—most importantly—a clean teardown so NetworkManager doesn't get stuck

The Technical Bits

How it Works

The script registers itself on the D-Bus system bus under the name org.freedesktop.NetworkManager.hans. The standard NetworkManager VPN interface org.freedesktop.NetworkManager.VPN.Plugin is implemented, so NetworkManager sees it like any other VPN plugin.

When you call nmcli connection up my-hans-vpn, NetworkManager sends Connect or ConnectInteractive to this D-Bus service. The plugin then:

  1. Spawns the HANS process (subprocess.Popen(["hans", "-f", ...]))
  2. Sets up the new tunnel interface (tun0) by flipping it up with ip link set tun0 up
  3. Periodically checks if the server is reachable via an ICMP ping
  4. Once it's ready, it calls Ip4Config to inform NetworkManager of the assigned IP, prefix, DNS, route metrics—whatever else we need

When you call nmcli connection down my-hans-vpn, the plugin's Disconnect method kills the HANS process, flushes tun0 addresses, and quits its main loop so the process ends gracefully. No more zombie VPN plugins!

The Code

Below is the entire script. Notice how we communicate with NetworkManager by sending D-Bus signals for StateChanged and Ip4Config. Also notice our cunning usage of a polling loop (check_tunnel) to see if the tunnel is active, letting us gracefully handle the "connecting" → "connected" transition.

#!/usr/bin/env python3
"""
NetworkManager HANS VPN Service
Author: MoreHax ([email protected])

A Python-based NetworkManager VPN plugin for HANS, providing reliable VPN connectivity through DBus integration.
Manages HANS tunnel setup, IP configuration, and clean disconnection with detailed logging to /tmp/hans-plugin.log.
Features: host-order IP handling, dynamic tun0 configuration, route-metric support, and robust tunnel monitoring via ping.
Ensures proper process termination and supports address-data for NetworkManager compatibility.
"""

import os
import subprocess
import logging
import time
import json
import dbus
import dbus.service
import dbus.mainloop.glib
from gi.repository import GLib
import socket
import struct

# Setup logging with high-precision timestamps
logging.basicConfig(
    filename='/tmp/hans-plugin.log',
    level=logging.DEBUG,
    format='%(asctime)s.%(msecs)03d [%(levelname)s] %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

# D-Bus service details
BUS_NAME = 'org.freedesktop.NetworkManager.hans'
OBJECT_PATH = '/org/freedesktop/NetworkManager/VPN/Plugin'
IFACE_VPN_PLUGIN = 'org.freedesktop.NetworkManager.VPN.Plugin'

def ip4_to_uint32_host(ip_str):
    return struct.unpack('<I', socket.inet_aton(ip_str))[0]

class HansVPNPlugin(dbus.service.Object):
    def __init__(self, bus):
        dbus.service.Object.__init__(self, bus, OBJECT_PATH)
        self.vpn_settings = None
        self.process = None
        self.check_timer = None
        self.loop = GLib.MainLoop()
        logging.info("HANS VPN Plugin initialized")
        logging.debug(f"Environment: {os.environ}")

    @dbus.service.method(IFACE_VPN_PLUGIN, in_signature='a{sa{ss}}', out_signature='s')
    def NeedSecrets(self, settings):
        logging.info("NeedSecrets called with settings: %s", settings)
        return ""

    @dbus.service.method(IFACE_VPN_PLUGIN, in_signature='a{sa{sv}}a{sv}', out_signature='')
    def ConnectInteractive(self, settings, details):
        self.Connect(settings)

    @dbus.service.method(IFACE_VPN_PLUGIN, in_signature='a{sa{ss}}', out_signature='')
    def Connect(self, connection):
        self.vpn_settings = connection
        vpn_data = connection['vpn']['data']
        server = vpn_data.get('server', '')
        password = vpn_data.get('password', '')

        if not server or not password:
            self.state_changed(6, "Missing VPN server or password")
            return

        cmd = ['hans', '-f', '-c', server, '-p', password]
        try:
            self.process = subprocess.Popen(
                cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
            )
            GLib.io_add_watch(self.process.stdout, GLib.IO_IN, self._log_hans_output)
            GLib.io_add_watch(self.process.stderr, GLib.IO_IN, self._log_hans_output)
            self.process.start_time = time.time()
            self.state_changed(2, "Connecting")

            if self._setup_tun0():
                assigned_ip, assigned_prefix = self._get_tun0_ipv4()
                if assigned_ip:
                    self.state_changed(3, "Connecting")
                    self.send_config(assigned_ip, assigned_prefix)
                    self.state_changed(4, "Connected")
                else:
                    self.check_timer = GLib.timeout_add(1000, self.check_tunnel)
            else:
                self.check_timer = GLib.timeout_add(1000, self.check_tunnel)
        except Exception as e:
            logging.error("Error starting HANS: %s", e)
            self.state_changed(6, f"HANS startup error: {str(e)}")

    def _log_hans_output(self, fd, condition):
        if condition & GLib.IO_IN:
            line = fd.readline().strip()
            if line:
                logging.debug("HANS output: %s", line)
            return True
        return False

    def _setup_tun0(self):
        try:
            subprocess.check_output(['ip', 'link', 'set', 'tun0', 'up'])
            return True
        except Exception as e:
            logging.debug("Failed to set up tun0: %s", e)
            return False

    def _get_tun0_ipv4(self):
        try:
            out = subprocess.check_output(['ip', '-j', 'addr', 'show', 'dev', 'tun0'], text=True)
            data = json.loads(out)
            for addr_info in data[0].get('addr_info', []):
                if addr_info.get('family') == 'inet':
                    ip_str = addr_info.get('local')
                    prefix_int = addr_info.get('prefixlen')
                    return ip_str, prefix_int
            return None, None
        except Exception as e:
            logging.error("Failed to get tun0 IPv4: %s", e)
            return None, None

    def send_config(self, assigned_ip, assigned_prefix):
        vpn_data = self.vpn_settings['vpn']['data']
        dns_str = vpn_data.get('dns', '8.8.8.8')

        ip4config = {
            'address': dbus.UInt32(ip4_to_uint32_host(assigned_ip)),
            'prefix': dbus.UInt32(int(assigned_prefix)),
            'gateway': dbus.UInt32(ip4_to_uint32_host('10.1.2.254')),  # Hardcoded gateway
            'mtu': dbus.UInt32(1467),
            'tundev': 'tun0',
            'address-data': dbus.Array([
                dbus.Dictionary({
                    'address': dbus.String(assigned_ip),
                    'prefix': dbus.UInt32(int(assigned_prefix))
                }, signature='sv')
            ]),
            'dns': dbus.Array([dbus.UInt32(ip4_to_uint32_host(dns_str))], signature='u'),
            'route-metric': dbus.UInt32(5),
            'routes': dbus.Array([
                dbus.Struct([
                    dbus.UInt32(ip4_to_uint32_host("10.1.2.1")),  # Server tunnel IP
                    dbus.UInt32(32),  # /32 for specific host
                    dbus.UInt32(0)  # No gateway, use tun0
                ], signature='(uuu)')
            ], signature='(uuu)')
        }

        try:
            subprocess.call(["ip", "addr", "flush", "dev", "tun0"])
        except Exception as e:
            logging.warning("Failed to flush tun0: %s", e)

        try:
            self.Ip4Config(ip4config)
        except Exception as e:
            logging.error("Failed to emit Ip4Config: %s", e)

    @dbus.service.method(IFACE_VPN_PLUGIN, in_signature='', out_signature='')
    def Disconnect(self):
        if self.process:
            try:
                self.process.terminate()
                self.process.wait(timeout=5)
            except Exception as e:
                logging.error("Error terminating HANS process: %s", e)
        if self.check_timer:
            GLib.source_remove(self.check_timer)
            self.check_timer = None
        self.state_changed(6, "Disconnected")
        GLib.idle_add(self.loop.quit)

    def check_tunnel(self):
        if self.process.poll() is not None:
            self.state_changed(6, "HANS process exited")
            return False
        try:
            vpn_data = self.vpn_settings['vpn']['data']
            gw_str = vpn_data.get('gateway', '10.1.2.1')
            ping_result = subprocess.run(
                ['ping', '-c', '1', '-W', '1', gw_str],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
            )
            if ping_result.returncode == 0:
                assigned_ip, assigned_prefix = self._get_tun0_ipv4()
                if assigned_ip:
                    self.send_config(assigned_ip, assigned_prefix)
                    self.state_changed(4, "Connected")
                    return False
        except Exception as e:
            logging.error("Tunnel check error: %s", e)
        return True

    def state_changed(self, state, reason):
        try:
            self.StateChanged(dbus.UInt32(state), reason)
        except Exception as e:
            logging.error("Error sending StateChanged: %s", e)

    @dbus.service.signal(IFACE_VPN_PLUGIN, signature='a{sv}')
    def Ip4Config(self, config):
        pass

    @dbus.service.signal(IFACE_VPN_PLUGIN, signature='us')
    def StateChanged(self, state, reason):
        pass

def main():
    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SystemBus()
    name = dbus.service.BusName(BUS_NAME, bus)
    plugin = HansVPNPlugin(bus)
    plugin.loop.run()

if __name__ == '__main__':
    main()

Testing It Out

Here's how to get this HANS plugin up and running:

  1. Install HANS however you do (build from source or package)
  2. Save the above script as nm-hans-service.py (or your favorite name), and mark it executable
  3. Run it manually as sudo ./nm-hans-service.py
  4. Configure a new "VPN" in NetworkManager referencing org.freedesktop.NetworkManager.hans as the service type
  5. The new "VPN" can store your server, DNS, password, etc. in the data dictionary
  6. Connect with nmcli connection up <your-hans-connection>

If all goes well, you'll see logs in /tmp/hans-plugin.log, and a glorious tun0 interface with the assigned IP. You can also confirm connectivity with ping -c 1 <server IP>.

Key Features

This HANS plugin includes several improvements over basic implementations:

  • Robust D-Bus Integration: Full NetworkManager VPN plugin interface
  • Dynamic IP Configuration: Automatically detects and configures tunnel IPs
  • Connection Monitoring: Uses ping to verify tunnel connectivity
  • Clean Teardown: Properly terminates processes and cleans up interfaces
  • Detailed Logging: Comprehensive logging to /tmp/hans-plugin.log
  • Route Management: Handles routing and DNS configuration automatically

Haxinator 2000 Strikes Again!

Now, Haxinator 2000 has not one, but two cunning VPN plugins:

  1. The patched iodine plugin for DNS-based tunneling
  2. The brand-new HANS plugin for sneaky ICMP magic

Both integrate via NetworkManager and tidy up after themselves, so no more zombified processes left behind. This means you can connect and disconnect all day long—like a real, grown-up sysadmin.

If you're craving exotic tunnels, want to sneak data over DNS or ICMP, or simply want to poke around on interesting ports at your local coffee shop, this is the plugin for you. Have fun, and as always…

Stay hacky!