ethwifi: throw away networking tools and keep simple things simple

22 июля, 2022

TLDR

Not mentioning systemd, which I'm going to get rid of, I suddenly realized that all those network managers, netplan.io, and even old good ifupdown with ifplugd are simply obstacles to implement same old thing: automatic switching from wifi to ethernet when network cable is plugged in and vice versa. I purged all those packages. A simple python script looks much clearer and does the job without any quirks.

Of course, I have to run this script as a systemd service for now, but that's not for long. In the world of SBCs the choice of distros is limited, but I keep looking towards Buildroot and Yocto. Not sure when I get there, though. I prefer to paint, you know.

Mediacenter

Not so long read

Although my daughter called me old believer when I rolled down windows in her car instead of turning on AC, I don't feel myself too old and have no desire to fap on all that oldschool crap. I dislike ifupdown and sysvinit no less than modish systemd.

Recently I dug out my quite old Armbian-based mediacenter and decided to completely upgrade it and setup automatic switching between ethernet and wifi. Given that systemd is not an option at all, I tried to make things working with old tools.

Actually, I don't remember how I did such an automatic switching before. It was more than a decade ago. My habits drifted towards manual network management using command line even on laptops, even for wifi. I use Debian so my choice was old (but I can't say good) ifupdown.

I started from scratch. Initially I gave ifplugd a try but I found its configuration confusing. Not to mention it did not work for me out of the box. Although it detected link status changes, its behavior was weird and soon I realized I'm wasting my time.

Well, ifupdown and ifplugd aren't good helpers but what's a good replacement then? I bet you have already guessed. Yes, this world is rolling to hell, it's worth to recall ISC which has dropped the development of their DHCP client and I guess in favor of what. But they could not foresee that the author would find a better place so there's a little hope all that crap will be eventually thrown away and the unix way gets restored. But as of now, I haven't found any good replacement.

And my second thought was "DBUS might provide some events". I was wrong. In my research I found this page https://unix.stackexchange.com/questions/503309/udev-network-cable-hotplug-event-not-catched but suggested evaluation script really did not catch anything when I plugged and unplugged ethernet cable. I can be wrong saying there are no such events on DBUS, but that's true. Yes, I'm aware that systemd-networkd can generate them, but... re-read the beginning, please.

Okay, I dug deeper into netlink. I had no experience with it before, so I started from search. Although the best hit was https://olegkutkov.me/2018/02/14/monitoring-linux-networking-state-using-netlink/, I prefer python so I searched pypi as well and even found a few packages. However I did not want any external dependencies. Also, those packages look incomplete so I had to extract all necessary constants from linux headers.

Finally, I found this code and used it as a boilerplate: https://gist.github.com/Lukasa/6209575d588f1584c374 Soon my implementation started detecting link state changes very well, but what's next? The code looked too complicated and I still needed to get initial link state, avoid race conditions, and switch interfaces somehow.

Instead, polling /sys/class/eth0/carrier and invoking bare ip command looked much simple and reliable approach. If I used ifupdown, I could not poll carrier file because ifdown deactivates network interface completely.

So, here's the script:

#!/usr/bin/env python3
'''
Manage networking for a host with ethernet and wifi interfaces.
'''

eth_ifname = 'eth0'
wifi_ifname = 'wlan0'

wpa_ssid = 'MY-WIFI'
wpa_passphrase = 'my-very-strong-password'

ipv4_address = '192.168.0.3/24'
ipv4_gateway = '192.168.0.1'

eth_carrier_poll_delay = 1

# when link gets down, that might be a temporary failure so try a few times:
linkdown_checks = 3

# delay after unanticipated exception
restart_delay = 3

def main():
    init_interfaces()
    start_wpa_supplicant()
    prev_carrier = None
    while True:
        try:
            eth_carrier = get_carrier_state(eth_ifname)
            if eth_carrier != prev_carrier:
                if eth_carrier == '1':
                    print('Ethernet link up, deinit wifi')
                    deinit_wifi()
                    init_eth()
                else:
                    print('Ethernet link down, init wifi')
                    deinit_eth()
                    init_wifi()
                prev_carrier = eth_carrier
            time.sleep(eth_carrier_poll_delay)
        except Exception:
            traceback.print_exc()
            time.sleep(restart_delay)

def init_interfaces():
    # initialize interfaces so we can check link carrier or scan wifi
    invoke(f'ip link set {eth_ifname} up')
    invoke(f'ip link set {wifi_ifname} up')
    # delete routes and assigned addresses
    deinit_wifi()
    deinit_eth()

def start_wpa_supplicant():
    # start wpa supplicant for wifi interface
    invoke('killall wpa_supplicant', check=False)
    wpa_conf = invoke(f'wpa_passphrase {wpa_ssid} {wpa_passphrase}').stdout
    wpa_conf_filename = f'/etc/wpa_supplicant/{wpa_ssid}.conf'
    with open(wpa_conf_filename, 'w') as f:
        f.write(wpa_conf)
    invoke(f'wpa_supplicant -B -i {wifi_ifname} -c {wpa_conf_filename}')

def init_wifi():
    # setup ip configuration for wifi
    invoke(f'ip address add {ipv4_address} broadcast + dev {wifi_ifname}')
    invoke(f'ip route add default via {ipv4_gateway} dev {wifi_ifname}')

def deinit_wifi():
    # delete ip configuration for wifi
    invoke(f'ip route del default via {ipv4_gateway} dev {wifi_ifname}', check=False)
    invoke(f'ip address del {ipv4_address} broadcast + dev {wifi_ifname}', check=False)

def init_eth():
    # setup ip configuration for ethernet
    invoke(f'ip address add {ipv4_address} broadcast + dev {eth_ifname}')
    invoke(f'ip route add default via {ipv4_gateway} dev {eth_ifname}')

def deinit_eth():
    # delete ip configuration for ethernet
    invoke(f'ip route del default via {ipv4_gateway} dev {eth_ifname}', check=False)
    invoke(f'ip address del {ipv4_address} broadcast + dev {eth_ifname}', check=False)

def get_carrier_state(ifname):
    for i in range(linkdown_checks):
        if read_carrier(ifname) == '1':
            # link is up, return immediately
            return '1'
        time.sleep(eth_carrier_poll_delay)
    return read_carrier(ifname)

def read_carrier(ifname):
    try:
        with open(f'/sys/class/net/{ifname}/carrier') as f:
            return f.read(1)
    except OSError:
        return '0'

def invoke(command, check=True, shell=False, **kwargs):
    if not shell:
        command = shlex.split(command)
    result = subprocess.run(command, capture_output=True, text=True, shell=shell, **kwargs)
    if check and result.returncode != 0:
        raise Exception(f'Failed {command}: {result.stderr or result.stdout}')
    return result

import shlex
import subprocess
import time
import traceback

if __name__ == '__main__':
    main()

Someday I'll rewrite that in C, and maybe end up with systemd-2, but I doubt this will ever happen so the world is in safety for now.

The script does not update resolv.conf. I have no plans to use DHCP so I purged resolvconf package too.

A unit for systemd, based on networking.service:

[Unit]
Description=Networking
Wants=network.target
After=local-fs.target network-pre.target apparmor.service systemd-sysctl.service systemd-modules-load.service
Before=network.target shutdown.target network-online.target
Conflicts=shutdown.target

[Install]
WantedBy=multi-user.target
WantedBy=network-online.target

[Service]
Type=simple
ExecStart=/root/ethwifi

Here is a summary of what was purged:

apt purge network-manager* networkd-dispatcher* avahi* netplan* ifupdown resolvconf

And, one more thing:

systemctl disable wpa_supplicant

That's all. Bye for now.