Wi-Fi AP to share USB tethering

With my Vodafone cable modem now turned off most of the time, I initially used to share internet connection from my phone via the built-in Wi-Fi hotspot feature. Unfortunately, it didn't live up to my expectations, and not in terms of connection quality: what bugged me is that nothing could be configured statically, not just the device IPs, but even the netmask changed from time to time.

After some deliberation, I realized what I actually want from my home server:

  • WLAN with a set of always available services resolvable via local DNS
  • Internet access appearing automatically when connecting the phone via USB

Since I ruined the (very fragile!) antenna connectors on an Intel AX200 card while transplanting my PN50's motherboard into an Akasa Turing A50 case, I bought a cheap USB dongle, completely ignoring any concerns about Linux compatibility; I got lucky this time, though configuring it wasn't a walk in the park.

Auto-enabling USB tethering

Manually going into phone settings every time I connect it to the PC ruins the fun, therefore I configured an udev rule to do it for me.

The first thing to do is to copy the systemd-udevd unit file to /etc/systemd/system and append a line "IPAddressAllow=127.0.0.1" so that ADB client can connect to the ADB daemon - this line will override the default "IPAddressDeny=any" setting.

Second, add the udev rule, which will make systemd-udevd execute the script whenever it sees the phone (grab vendor and product ids from lsusb output):

ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="04e8", ATTR{idProduct}=="6860", RUN+="/usr/local/bin/enable-usb-tethering.sh"
/etc/udev/rules.d/80-samsung-galaxy-mtp.rules

The actual script follows the ideas from this StackOverflow answer, because it's the easiest to debug and shouldn't need significant changes across Android versions:

#!/usr/bin/env bash

sleep 2  # wait until adb can connect to the device

screen_is_locked() {
  $adb shell dumpsys deviceidle | grep -q 'mScreenLocked=true'
}

screen_is_on() {
  $adb shell dumpsys deviceidle | grep -q 'mScreenOn=true'
}

set -ex

if screen_is_locked ; then
  if ! screen_is_on ; then
    $adb shell input keyevent 26 # wakeup
    sleep 0.2
  fi

  $adb shell input keyevent 82 # unlock
  sleep 0.2
  $adb shell input text <XXXX> # enter your PIN
  $adb shell input keyevent 66 # enter
fi

$adb shell am force-stop com.android.settings
$adb shell am start -a android.intent.action.MAIN -n com.android.settings/.TetherSettings
sleep 0.5

# the below sequence of key presses depends on the device
for i in {1..4}; do
  $adb shell input keyevent 20 # down
done

$adb shell input keyevent 66 # enter
/usr/local/bin/enable-usb-tethering.sh

Important: make the script accessible by root only as it contains your PIN. If you use a pattern instead of a PIN, ugh, google it, you can do it with some effort.

Also important: use the original cable! USB tethering didn't work at all with the other USB-C-to-USB-C cable I have, and it's anyone's guess how stable the connection with any other non-original cable is.

Once the script has done its job, a new network interface will appear. In OpenSUSE, the configuration files are added to /etc/sysconfig/network/.

BOOTPROTO='dhcp4'
STARTMODE='hotplug'
ZONE=public

POST_UP_SCRIPT="wicked:/usr/local/bin/configure-usb-modem.sh"
/etc/sysconfig/network/ifcfg-enp5s0f3u1

The post-up script is not necessary in every case, but I wanted the new interface to be added to the bridge that I use for all my KVM guests:

#!/usr/bin/env bash

iface=enp5s0f3u1
set -ex

/sbin/ip link set $iface master br0

# this will pick up the changes and acquire a DHCP lease on br0;
# nohup is necessary to avoid a weird dependency loop inside wicked
nohup /sbin/wicked ifup br0 &
/usr/local/bin/configure-usb-modem.sh

Access point setup

I bought a cheap USB Wi-Fi adapter based on Realtek chipset for the experiment. Although the Ebay listing pictures suggested that the driver is RTL8811AU, the correct driver turned out to be RTL8821CU. Luckily, someone packaged it for OpenSUSE (repository); I installed rtl8821cu-kmp-default, and wlan0 appeared.

I then started hostapd with default settings, measured the transmission rate with iperf3, and... it was absolutely terrible - 20 Mbps at best. It was clear that something is amiss, and eventually I found this excellent document: https://github.com/morrownr/8821cu-20210118/blob/main/docs/AP_Mode-Bridged_Wireless_Access_Point.md

With all tweaks in place (see below), I now get up to 200 Mbps - not bad at all!

Driver settings

# Enable 80MHz channel width
options 8821cu rtw_vht_enable=2

# Allow use of channels that require DFS (dynamic frequency selection).
# See https://github.com/morrownr/88x2bu/issues/97 for background info.
options 8821cu rtw_dfs_region_domain=3

# No idea why but this helps with stability and speed.
# See https://github.com/morrownr/88x2bu/issues/67
# and https://github.com/morrownr/88x2bu/issues/96
options 8821cu rtw_switch_usb_mode=1
/etc/modprobe.d/50-8821cu.conf

Initially I was excited to use DFS channels as they are less cluttered, but as I quickly discovered, at some point the driver switches to a non-DFS channel, presumably because it "detects" a radar nearby (a false positive?), and never switches back, in which case why not just set a non-DFS channel from the beginning? And even if it'd work properly, there's a host of other problems with DFS, such as slower network discovery by clients. I advise you to read this post on the topic: https://mac-wifi.com/why-i-dislike-dfs-channels-and-you-might-too/

Hostapd settings

Most important here is to set ht_capab and vht_capab according to the output of "iw list" command, which tells you everything about the device capabilities.

interface=wlan0
driver=nl80211

<... default settings ...>

ssid=<Network name of your choice>
auth_algs=1
wpa=2
wpa_passphrase=<XXXXXXXXXXX>
wpa_pairwise=CCMP

# set regulatory domain, enable radar detection, DFS and TPC
# (dynamic frequency selection and transmission power control)
country_code=DE
ieee80211d=1
ieee80211h=1
local_pwr_constraint=3

# use a 5GHz non-DFS channel
hw_mode=a
channel=36

ieee80211n=1
ht_capab=[HT40+][SHORT-GI-20][SHORT-GI-40][MAX-AMSDU-7935]
require_ht=1

ieee80211ac=1
vht_capab=[SHORT-GI-80][HTC-VHT][MAX-MPDU-11454]
require_vht=1

# 80 MHz channel width
vht_oper_chwidth=1
# channel (36) + 6
vht_oper_centr_freq_seg0_idx=42
/etc/hostapd.conf

Traffic forwarding

To get Internet access via Wi-Fi, a handful of iptables rules must be in place:

#!/usr/bin/env bash

sysctl -w net.ipv4.ip_forward=1

iptables -A FORWARD -i wlan0 -o br0 -j ACCEPT
iptables -A FORWARD -i br0 -m state --state RELATED,ESTABLISHED -j ACCEPT
iptables -t nat -A POSTROUTING -o br0 -j MASQUERADE
/usr/local/bin/fwd-wifi-traffic.sh

Local DHCP/DNS server and virtual hosts

Clients receive IP addresses from Dnsmasq, which is configured as follows:

local-service
port=53
domain-needed
bogus-priv
interface=wlan0
conf-dir=/etc/dnsmasq.d/*.conf

dhcp-range=interface:wlan0,192.168.1.50,192.168.1.150,12h

# make sure Android clients recognize this as a metered network
dhcp-option-force=43,ANDROID_METERED
/etc/dnsmasq.conf

The wlan0 interface is configured statically with IP 192.168.1.1:

STARTMODE='auto'
BOOTPROTO='static'
ZONE=public

IPADDR=192.168.1.1/24
/etc/sysconfig/network/ifcfg-wlan0

Obviously, don't forget to open the ports:

$ firewall-cmd --add-service=dhcp
$ firewall-cmd --add-service=dns
$ firewall-cmd --add-service=http

Dnsmasq reads /etc/hosts by default and creates according DNS entries. I like to simply use the same 192.168.1.1 for all hostnames and do the rest in HAProxy, i.e.

192.168.1.1     kiwix.home webdav.home
/etc/hosts entry

HAProxy then forwards traffic according to HTTP headers:

frontend http-in
  bind *:80
  mode http
  use_backend be_kiwix if { hdr(host) -i kiwix.home }
  use_backend be_webdav if { hdr(host) -i webdav.home }
 
backend be_kiwix
  mode http
  server kiwix1 127.0.0.1:8111

backend be_webdav
  mode http
  server webdav1 127.0.0.1:8000
/etc/haproxy/haproxy.cfg

Now I can visit kiwix.home web page, mount webdav.home, and easily make more services available by just editing /etc/hosts and /etc/haproxy/haproxy.cfg. That's pretty much how one usually configures a router, except that my "router" is a virtual machine, with a mediocre USB dongle instead of decent antennas.