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:
- Spawns the HANS process (
subprocess.Popen(["hans", "-f", ...])
) - Sets up the new tunnel interface (
tun0
) by flipping it up withip link set tun0 up
- Periodically checks if the server is reachable via an ICMP ping
- 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:
- Install HANS however you do (build from source or package)
- Save the above script as
nm-hans-service.py
(or your favorite name), and mark it executable - Run it manually as
sudo ./nm-hans-service.py
- Configure a new "VPN" in NetworkManager referencing
org.freedesktop.NetworkManager.hans
as the service type - The new "VPN" can store your server, DNS, password, etc. in the data dictionary
- 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:
- The patched iodine plugin for DNS-based tunneling
- 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!