diff --git a/hosts/by-name/moby/default.nix b/hosts/by-name/moby/default.nix index e76fb53cf..84d2c0d4d 100644 --- a/hosts/by-name/moby/default.nix +++ b/hosts/by-name/moby/default.nix @@ -23,6 +23,11 @@ sane.zsh.showDeadlines = false; # unlikely to act on them when in shell sane.services.wg-home.enable = true; sane.services.wg-home.ip = config.sane.hosts.by-name."moby".wg-home.ip; + sane.wowlan.enable = true; + sane.wowlan.patterns = [ + { destPort = 22; } # wake on SSH + { srcPort = 2587; } # wake on `ntfy-sh` push from servo + ]; # XXX colin: phosh doesn't work well with passwordless login, # so set this more reliable default password should anything go wrong diff --git a/modules/default.nix b/modules/default.nix index 1e09ea95f..60ee56370 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -14,6 +14,7 @@ ./sops.nix ./ssh.nix ./users.nix + ./wowlan.nix ]; _module.args = rec { diff --git a/modules/wowlan.nix b/modules/wowlan.nix new file mode 100644 index 000000000..151cb1709 --- /dev/null +++ b/modules/wowlan.nix @@ -0,0 +1,156 @@ +# wake on wireless LAN +# developed for the PinePhone (rtl8723cs), but may work on other systems. +# key info was found from this chat between Peetz0r and megi: +# +# wowlan configuration can be inspected with: +# - `iw phy0 wowlan` +# - `cat /proc/net/rtl8723cs/wlan0/wow_pattern_info` +# - the "pattern mask" is a bitfield, where each bit `i` indicates if the "pattern content" byte at index `i` must match, or should be treated as Don't Care (`-`) +# +# wakeup sources can be monitored with: +# - `cat /proc/interrupts | rg rtw_wifi_gpio_wakeup` +# - e.g. `cat /sys/kernel/irq/25/actions` (if the above points to irq 25) + +{ config, lib, pkgs, ... }: +let + cfg = config.sane.wowlan; + patternOpts = with lib; types.submodule { + options = { + destPort = mkOption { + type = types.nullOr types.port; + default = null; + description = '' + IP port from which the packet was *sent*. + use this if you want to wake when an outbound connection shows activity + (e.g. this machine made a long-running HTTP request, and the other side finally has data for us). + ''; + }; + srcPort = mkOption { + type = types.nullOr types.port; + default = null; + description = '' + IP port on which the packet was *received*. + use this if you want to wake on inbound connections + (e.g. sshing *into* this machine). + ''; + }; + }; + }; + # bytesToStr: [ u8|null ] -> String + # format an array of octets into a pattern recognizable by iwpriv. + # a null byte means "don't care" at its position. + bytesToStr = bytes: lib.concatStringsSep ":" ( + builtins.map + (b: if b == null then "-" else hexByte b) + bytes + ); + # format a byte as hex, with leading zero to force a width of two characters. + # the wlan driver doesn't parse single-character hex bytes. + hexByte = b: if b < 16 then + "0" + (lib.toHexString b) + else + lib.toHexString b; + # formatPattern: patternOpts -> String + # produces a string like this (example matches source port=0x0a1b): + # "-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:-:0a:1b:-:-" + # + # N.B.: the produced patterns match ONLY IPv4 -- not IPv6! + formatPattern = pat: let + destPortBytes = if pat.destPort != null then { + high = pat.destPort / 256; + low = lib.mod pat.destPort 256; + } else { + high = null; low = null; + }; + srcPortBytes = if pat.srcPort != null then { + high = pat.srcPort / 256; + low = lib.mod pat.srcPort 256; + } else { + high = null; low = null; + }; + + bytes = [ + # ethernet frame: + ## dest MAC address (this should be the device's MAC, but i think that's implied?) + null null null null null null + ## src MAC address + null null null null null null + ## ethertype: + 08 00 # 0x0800 = IPv4 + + # IP frame: + ## Version, Internet Header Length. 0x45 = 69 decimal + 69 + ## Differentiated Services Code Point (DSCP), Explicit Congestion Notification (ECN) + null + ## total length + null null + ## identification + null null + ## flags, fragment offset + null null + ## Time-to-live + null + ## protocol: + 6 # 6 = TCP + ## header checksum + null null + ## source IP addr + null null null null + ## dest IP addr + null null null null + + # TCP frame: + srcPortBytes.high srcPortBytes.low + destPortBytes.high destPortBytes.low + ## rest is Don't Care + ]; + in bytesToStr bytes; +in +{ + options = with lib; { + sane.wowlan.enable = mkOption { + default = false; + type = types.bool; + }; + sane.wowlan.patterns = mkOption { + default = []; + type = types.listOf patternOpts; + description = '' + each entry represents a pattern which if seen in a packet should wake the system. + if any pattern matches, the system will wake. + for a pattern to match, all of its non-null fields must match. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.wowlan = { + description = "configure the WiFi chip to wake the system on specific activity"; + path = [ pkgs.iw pkgs.wirelesstools ]; + script = let + writePattern = pat: '' + iwpriv wlan0 wow_set_pattern pattern=${formatPattern pat} + ''; + writePatterns = lib.concatStringsSep + "\n" + (builtins.map writePattern cfg.patterns); + in '' + set -x + iw phy0 wowlan enable any + iwpriv wlan0 wow_set_pattern clean + ${writePatterns} + ''; + serviceConfig = { + # TODO: re-run this periodically, just to be sure? + # it's kinda bad if this fails or gets undone unexpectedly. + Type = "oneshot"; + RemainAfterExit = true; + Restart = "on-failure"; + RestartSec = "30s"; + }; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + }; + }; +}