Wi-Fi hotspot from Qubes OS

Introduction

This guide is for users who would like to create a Wi-Fi hotspot from their Qubes OS qube. This can be useful if you want to provide a Wi-Fi network tunnelling the traffic through a VPN or sys-whonix.

Basically, the hotspot runs in a qube, and the traffic will pass through the qube’s netvm.

Requirements

You need a Wi-Fi device, either USB or integrated. If you connect to the Internet / Network with Wi-Fi, you need a second Wi-Fi device.

Setup

The guide uses non-minimal templates.

  • Create a qube called sys-hotspot, choose Fedora or Debian, it is up to you:
    • Add the service network-manager
    • Make it Provides network
    • Make it Disposable template
  • Start the qube

Edit /rw/config/rc.local to add the following script to it:

if [ "$(qubesdb-read /qubes-vm-persistence)" = "none" ]
then
    while true
    do
        WIFI_INTERFACE=$(iw dev | awk '/Interface/ { print $2 }')
        if [ -n "$WIFI_INTERFACE" ]
        then
            # the device may require a few seconds to initialize after being attached
            sleep 4

            # configure the access point
            # pick the SSID you want
            # define the password you want
            nmcli dev wifi hotspot ifname "$WIFI_INTERFACE" ssid "your_ssid_name_here" password "the_PSK_password"

            # allow incoming DHCP traffic so clients can have an IP
            nft add rule ip qubes custom-input iifname "$WIFI_INTERFACE" meta l4proto udp udp dport 67 accept
            nft add rule ip qubes custom-input iifname "$WIFI_INTERFACE" meta l4proto tcp tcp dport 53 accept
            nft add rule ip qubes custom-input iifname "$WIFI_INTERFACE" meta l4proto udp udp dport 53 accept


            # handle DNS requests to Qubes OS DNS, you can still catch them later from a VPN qube anyway
            nft flush chain ip qubes dnat-dns
            nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 udp dport 53 dnat to 10.139.1.1
            nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 tcp dport 53 dnat to 10.139.1.1
            break
        fi
        sleep 5
    done
fi

  • Stop the qube,

:information_source: This piece of shell waits indefinitely for a wifi interface to be connected, when it happens, it allows all incoming traffic on this interface and starts a Wi-Fi hotspot named “your_ssid_name_here” with the password “the_PSK_password”. Adapt to your needs.

:information_source: For better security, this guide configures a named disposable qube to be the hotspot. You will never need to start sys-hotspot again except if you want to change the SSID or PSK.

USB Wi-Fi

If your Wi-Fi device is USB, follow this:

  • Create a named disposable qube sys-hotspot-dvm, with the template sys-hotspot
  • Start the qube
  • Attach your WiFi USB device to the qube

Integrated Wi-Fi

:warning: This is more for advanced users, as you will need to modify sys-net and to juggle between two “sys-net” depending if you connect to Ethernet or Wi-Fi

If your Wi-Fi device is integrated into the computer, it is slightly complicated and you will not be able to connect to Wi-Fi and create a hotspot at the same time.

You will need to remove the Wi-Fi device from sys-net devices:

  • Open sys-net settings
  • In “Devices” tab, remove the Wi-Fi device

You need a new qube to connect to Wi-Fi when you need to:

  • Create a new qube sys-net-wifi:
  • An AppVM if you want changes to be persistent, or a Named disposable based on default-dvm if you do not want to store information about Wi-Fi access points
  • Make it “Provides network”
  • When you want to switch between ethernet and Wi-Fi, toggle sys-firewall netvm between sys-net and sys-net-wifi

Now, you need to create the qube that will do the hotspot:

  • Create a named disposable qube sys-hotspot-dvm, with the template sys-hotspot
  • Open its settings
    • Make it a “HVM” type qube (remove memory ballooning if any)
    • Add the Wi-Fi device
  • Start the qube

Extra

Use PFSense or OPNsense

It is possible to use PFSense or OPNsense as the system in the qube providing Wi-Fi, this allows you to administrate it using the Web user interface through a client of the Wi-Fi with some fancy features. This might require some work to bootstrap the configuration from the command line version.

Random password at boot

You can easily modify the script to have a random SSID and/or random password every time the Wi-Fi starts. I recommend the program pwgen but some pure shell snippet like $(tr -dc 'a-zA-Z0-9_@' </dev/urandom | head -c 16) could be used too.

In the network manager applet, you can display the Wi-Fi information that will display both the SSID and the password in cleartext.

USB devices issues

I tried with an old atheros device, I need to disconnect / reconnect it after attaching to a qube.

Graphene OS does not see the SSID

@solene had issues with a qube running debian 12 that was advertising a SSID working with everything but Graphene which was not seeing the network. Switching to Fedora 42 magically fixes the problem.

5 Likes

My card seems to trigger a freeze in the qube, this happens on both Debian and Fedora. I don’t know if it’s a common issue that will happen to many, or if it is specific to my own card.

You can limit it to only accept DHCP like this:

nft add rule ip qubes custom-input iifname "$WIFI_INTERFACE" meta l4proto udp udp dport 67 accept

You’ll also need to configure DNS:

mkdir -p /rw/bind-dirs/etc/NetworkManager/dispatcher.d
cat << 'EOF' | tee /rw/bind-dirs/etc/NetworkManager/dispatcher.d/zz-hotspot  > /dev/null
#!/bin/sh
if [ "$(qubesdb-read /qubes-vm-persistence)" = "none" ]
then
    while true
    do
        WIFI_INTERFACE=$(iw dev | awk '/Interface/ { print $2 }')
        if [ -n "$WIFI_INTERFACE" ]
        then
            nft flush chain ip qubes dnat-dns
            nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 udp dport 53 dnat to 10.139.1.1
            nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 tcp dport 53 dnat to 10.139.1.1
            break
        fi
        sleep 5
    done
fi
EOF

USB WiFi adapter passed from sys-usb to a sys-hotspot qube based on debian-12-minimal works fine for me, so it should be hardware-specific issue.

1 Like

What is the dispatcher exactly doing?

And I guess it’s enough for the AP to work as Qubes OS default firewall rules will allow forwarding from all non eth0 interfaces? This is a great enhancement.

Good catch

Guide extended to support HVM qubes for the integrated card.

It’ll run the script on any of the network changes described here:
NetworkManager-dispatcher: NetworkManager Reference Manual
But I guess it’d be enough to just limit it so it’d be called only on up interface action like this:

mkdir -p /rw/config/qubes-bind-dirs.d/
cat << 'EOF' | tee /rw/config/qubes-bind-dirs.d/50_user.conf > /dev/null
binds+=( '/etc/NetworkManager/dispatcher.d/zz-hotspot' )
EOF
mkdir -p /rw/bind-dirs/etc/NetworkManager/dispatcher.d
cat << 'EOF' | tee /rw/bind-dirs/etc/NetworkManager/dispatcher.d/zz-hotspot  > /dev/null
#!/bin/sh
if [ "$(qubesdb-read /qubes-vm-persistence)" = "none" ]
then
    while true
    do
        WIFI_INTERFACE=$(iw dev | awk '/Interface/ { print $1 }')
        if [ -n "$WIFI_INTERFACE" ]
        then
            if [ "$2" = "up" ]; then
                nft flush chain ip qubes dnat-dns
                nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 udp dport 53 dnat to 10.139.1.1
                nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 tcp dport 53 dnat to 10.139.1.1
            fi
            break
        fi
        sleep 5
    done
fi
EOF

Also, I forgot to add /rw/config/qubes-bind-dirs.d/50_user.conf in previous post, so I added it here.
zz- at the beginning of the file is needed so that it’ll be called after the /etc/NetworkManager/dispatcher.d/qubes-nmhook file that will setup the default DNS DNAT rules.

Yes.

I was confused and I didn't have to change the `print $2` in `awk`, so disregard this.

Another edit, I forgot to change the interface name.
In dispatcher script the $1 argument is an interface name and $2 is an action.

mkdir -p /rw/config/qubes-bind-dirs.d/
cat << 'EOF' | tee /rw/config/qubes-bind-dirs.d/50_user.conf > /dev/null
binds+=( '/etc/NetworkManager/dispatcher.d/zz-hotspot' )
EOF
mkdir -p /rw/bind-dirs/etc/NetworkManager/dispatcher.d
cat << 'EOF' | tee /rw/bind-dirs/etc/NetworkManager/dispatcher.d/zz-hotspot  > /dev/null
#!/bin/sh
if [ "$(qubesdb-read /qubes-vm-persistence)" = "none" ]
then
    while true
    do
        WIFI_INTERFACE=$(iw dev | awk '/Interface/ { print $1 }')
        if [ -n "$WIFI_INTERFACE" ]
        then
            if [ "$2" = "up" ]; then
                nft flush chain ip qubes dnat-dns
                nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 udp dport 53 dnat to 10.139.1.1
                nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 tcp dport 53 dnat to 10.139.1.1
            fi
            break
        fi
        sleep 5
    done
fi
EOF

But this is untested:

        WIFI_INTERFACE=$(iw dev | awk '/Interface/ { print $1 }')

Since I don’t have iw in my template.

I’m trying to see how to make it work. I removed the infinite loop there, and the action “up” could be triggered by eth0 being up, I’m not sure in which situations this happen.

You can check if the interface name for which the dispatched script was called is the same as the interface name in the iw dev output:

mkdir -p /rw/config/qubes-bind-dirs.d/
cat << 'EOF' | tee /rw/config/qubes-bind-dirs.d/50_user.conf > /dev/null
binds+=( '/etc/NetworkManager/dispatcher.d/zz-hotspot' )
EOF
mkdir -p /rw/bind-dirs/etc/NetworkManager/dispatcher.d
cat << 'EOF' | tee /rw/bind-dirs/etc/NetworkManager/dispatcher.d/zz-hotspot  > /dev/null
#!/bin/sh
if [ "$(qubesdb-read /qubes-vm-persistence)" = "none" ]
then
    WIFI_INTERFACE=$(iw dev | awk '/Interface/ { print $2 }')
    if [ -n "$WIFI_INTERFACE" ] && [ "$1" = "$WIFI_INTERFACE" ] && [ "$2" = "up" ]; then
        nft flush chain ip qubes dnat-dns
        nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 udp dport 53 dnat to 10.139.1.1
        nft add rule ip qubes dnat-dns iifname "$WIFI_INTERFACE" ip daddr 10.42.0.1 tcp dport 53 dnat to 10.139.1.1
    fi
fi
EOF

This is sufficient to get an IP, not to have a working NAT.

DNS was also required in input to work :+1:

What do you mean by working NAT?
The default qube’s firewall rules enable the masquerading for all interfaces except lo and vif.:

        chain postrouting {
                type nat hook postrouting priority srcnat; policy accept;
                oifgroup 2 accept
                oif "lo" accept
                masquerade
        }

And the default firewall forward policy is accept and only blocks new connections to the vif interfaces:

        chain forward {
                type filter hook forward priority filter; policy accept;
                jump custom-forward
                ct state invalid counter packets 0 bytes 0 drop
                ct state established,related accept
                oifgroup 2 counter packets 0 bytes 0 drop
        }

Do you mean a DNS accept rule in the custom-input chain? It shouldn’t be needed if DNS requests are not redirected to the sys-hotspot qube itself, but to its upstream net qube with dnat to 10.139.1.1.
But if you want to set up a DNS server in the sys-hotspot, then yes, you need an input firewall rule to accept DNS requests.

I need to investigate this more, but I think the DHCP server advertise the qube IP as the DNS resolver.

That’s it, the DHCP server gives the qube IP as the DNS server.

I think it’s fine as this, with the nft rules to redirect to the chosen DNS server, this forces all DNS servers requests to the given server even if the client manually sets one. This does not prevent DoT or DoH to pass through obviously :slight_smile:

Do you mean that the DHCP server of the sys-hotspot sends the IP address of its eth0 interface as the DNS server for a client?
That’s strange, for me it sends the 10.42.0.1 IP address as the DNS server.
Maybe there is some difference between Fedora and Debian here.

I meant that, and this requires a firewall port to be opened IMO.

With these firewall rules:

The incoming connections to 10.42.0.1 are DNATed to 10.139.1.1, so they won’t be coming to the input firewall chain, because 10.139.1.1 doesn’t belong to any interface, so they will be sent to the default gateway and will go to the forward firewall chain.

This does not work for me, the rules are not passed to eth0 if I use tcpdump.

Add counters/logging to these DNAT firewall rules and check that they are actually triggered.

1 Like