From 5d8c1c902d9543106b37ada47733c63f28e3729d Mon Sep 17 00:00:00 2001 From: oddlama Date: Wed, 22 Mar 2023 20:18:25 +0100 Subject: [PATCH] feat: modulize esphome --- flake.lock | 12 +-- hosts/zackbiene/esphome.nix | 73 +++-------------- modules/esphome.nix | 153 ++++++++++++++++++++++++++++++++++++ modules/hostapd.nix | 119 +++++++++++++++++----------- 4 files changed, 241 insertions(+), 116 deletions(-) create mode 100644 modules/esphome.nix diff --git a/flake.lock b/flake.lock index 3be47f7..22be4d9 100644 --- a/flake.lock +++ b/flake.lock @@ -166,11 +166,11 @@ ] }, "locked": { - "lastModified": 1679265143, - "narHash": "sha256-5RDMW+O4owjdPz7t4K4YxH2fOHCNOcyVmSiKRUikiv0=", + "lastModified": 1679480702, + "narHash": "sha256-npuRD61YmxUPitI1TqKwlxLrU6iGl5E+BPT196LgUDo=", "owner": "nix-community", "repo": "home-manager", - "rev": "1b8bf5c3270386a1b6850bd77d79dbdbaf0d7a7c", + "rev": "363c46b2480f1b73ec37cf68caac61f5daa82a2e", "type": "github" }, "original": { @@ -211,11 +211,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1679172431, - "narHash": "sha256-XEh5gIt5otaUbEAPUY5DILUTyWe1goAyeqQtmwaFPyI=", + "lastModified": 1679262748, + "narHash": "sha256-DQCrrAFrkxijC6haUzOC5ZoFqpcv/tg2WxnyW3np1Cc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "1603d11595a232205f03d46e635d919d1e1ec5b9", + "rev": "60c1d71f2ba4c80178ec84523c2ca0801522e0a6", "type": "github" }, "original": { diff --git a/hosts/zackbiene/esphome.nix b/hosts/zackbiene/esphome.nix index 40da2f8..c541c1c 100644 --- a/hosts/zackbiene/esphome.nix +++ b/hosts/zackbiene/esphome.nix @@ -1,68 +1,17 @@ -{ - lib, - config, - nixos-hardware, - pkgs, - ... -}: let - dataDir = "/var/lib/esphome"; -in { - systemd.services.esphome = { - description = "ESPHome Service"; - wantedBy = ["multi-user.target"]; - after = ["network.target"]; - serviceConfig = { - ExecStart = "${pkgs.esphome}/bin/esphome dashboard --socket /run/esphome/esphome.sock ${dataDir}"; - User = "esphome"; - Group = "esphome"; - WorkingDirectory = dataDir; - RuntimeDirectory = "esphome"; - Restart = "on-failure"; +{nodeSecrets, ...}: { + imports = [../../modules/esphome.nix]; - # Hardening - CapabilityBoundingSet = ""; - LockPersonality = true; - MemoryDenyWriteExecute = true; - DevicePolicy = "closed"; - DeviceAllow = "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"; - SupplementaryGroups = ["dialout"]; - NoNewPrivileges = true; - PrivateUsers = true; - PrivateTmp = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectProc = "invisible"; - ProcSubset = "pid"; - ProtectSystem = "strict"; - ReadWritePaths = dataDir; - RemoveIPC = true; - RestrictAddressFamilies = ["AF_UNIX" "AF_NETLINK" "AF_INET" "AF_INET6"]; - RestrictNamespaces = false; # Required by platformio for chroot - RestrictRealtime = true; - RestrictSUIDSGID = true; - SystemCallArchitectures = "native"; - SystemCallFilter = [ - "@system-service" - "@mount" # Required by platformio for chroot - ]; - UMask = "0077"; - }; + services.esphome = { + enable = true; + enableUnixSocket = true; + allowedDevices = [ + { + node = "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"; + modifier = "rw"; + } + ]; }; - users.users.esphome = { - home = dataDir; - createHome = true; - group = "esphome"; - uid = 316; - }; - - users.groups.esphome.gid = 316; - # TODO esphome.sock permissions pls nginx currently world writable services.nginx.upstreams = { "esphome" = { diff --git a/modules/esphome.nix b/modules/esphome.nix new file mode 100644 index 0000000..7e9b753 --- /dev/null +++ b/modules/esphome.nix @@ -0,0 +1,153 @@ +{ + config, + lib, + pkgs, + ... +}: let + inherit + (lib) + literalExpression + mkEnableOption + mkIf + mkOption + mdDoc + types + ; + + cfg = config.services.esphome; + + name = "esphome"; + + stateDir = "/var/lib/${name}"; +in { + options.services.esphome = { + enable = mkEnableOption (mdDoc "esphome"); + + package = mkOption { + type = types.package; + default = pkgs.esphome; + defaultText = literalExpression "pkgs.esphome"; + description = mdDoc "The package to use for the esphome command."; + }; + + enableUnixSocket = mkEnableOption (lib.mdDoc '' + Expose a unix socket under /run/esphome/esphome.sock instead of using a TCP socket. + ''); + + address = mkOption { + type = types.str; + default = "localhost"; + description = mdDoc "esphome address"; + }; + + port = mkOption { + type = types.port; + default = 6052; + description = mdDoc "esphome port"; + }; + + openFirewall = mkOption { + default = false; + type = types.bool; + description = mdDoc "Whether to open the firewall for the specified port."; + }; + + allowedDevices = mkOption { + default = []; + example = [ + { + node = "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"; + modifier = "rw"; + } + ]; + description = lib.mdDoc '' + A list of device nodes to which {command}`esphome` has access to. + Beware that permissions are not added dynamically when a device + is plugged in while the service is already running. + ''; + type = types.listOf (types.submodule { + options = { + node = mkOption { + example = "/dev/ttyUSB*"; + type = types.str; + description = lib.mdDoc "Path to device node"; + }; + modifier = mkOption { + example = "rw"; + type = types.str; + description = lib.mdDoc '' + Device node access modifier. Takes a combination + `r` (read), `w` (write), and `m` (mknod). See the + `systemd.resource-control(5)` man page for more + information. + ''; + }; + }; + }); + }; + }; + + config = mkIf cfg.enable { + networking.firewall.allowedTCPPorts = mkIf (cfg.openFirewall && !cfg.enableUnixSocket) [cfg.port]; + + systemd.services.esphome = { + description = "ESPHome dashboard"; + after = ["network.target"]; + wantedBy = ["multi-user.target"]; + path = [cfg.package]; + + serviceConfig = { + ExecStart = let + extraParams = + if cfg.enableUnixSocket + then "--socket /run/${name}/esphome.sock" + else "--address ${cfg.address} --port ${toString cfg.port}"; + in "${cfg.package}/bin/esphome dashboard ${extraParams} ${stateDir}"; + DynamicUser = true; + WorkingDirectory = stateDir; + StateDirectory = name; + StateDirectoryMode = "0750"; + Restart = "on-failure"; + RuntimeDirectory = mkIf cfg.enableUnixSocket name; + RuntimeDirectoryMode = "0750"; + + # Hardening + CapabilityBoundingSet = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + DevicePolicy = "closed"; + DeviceAllow = map (d: "${d.node} ${d.modifier}") cfg.allowedDevices; + SupplementaryGroups = ["dialout"]; + NoNewPrivileges = true; + PrivateUsers = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + RestrictNamespaces = false; # Required by platformio for chroot + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "@mount" # Required by platformio for chroot + ]; + UMask = "0077"; + }; + }; + }; +} diff --git a/modules/hostapd.nix b/modules/hostapd.nix index 8100fc9..ace87ed 100644 --- a/modules/hostapd.nix +++ b/modules/hostapd.nix @@ -7,21 +7,22 @@ }: let inherit (lib) - any + attrNames attrValues concatLists + concatMap concatMapStrings concatStringsSep count escapeShellArg filter + getAttr literalExpression mapAttrsToList mdDoc mkIf mkOption optional - optionals optionalString stringLength toLower @@ -30,35 +31,6 @@ cfg = config.services.hostapd; - # Maps the specified acl mode to values understood by hostapd - macaddrAclModes = { - "allow" = "0"; - "deny" = "1"; - "radius" = "2"; - }; - - # Maps the specified ignore broadcast ssid mode to values understood by hostapd - ignoreBroadcastSsidModes = { - "disabled" = "0"; - "empty" = "1"; - "clear" = "2"; - }; - - # Maps the specified vht and he channel widths to values understood by hostapd - operatingChannelWidth = { - "20or40" = "0"; - "80" = "1"; - "160" = "2"; - "80+80" = "3"; - }; - - # Maps the specified vht and he channel widths to values understood by hostapd - managementFrameProtection = { - "disabled" = "0"; - "optional" = "1"; - "required" = "2"; - }; - bool01 = b: if b then "1" @@ -91,7 +63,7 @@ channel=${toString ifcfg.channel} noscan=${bool01 ifcfg.noScan} # Set the MAC-address access control mode - macaddr_acl=${macaddrAclModes.${ifcfg.macAcl}} + macaddr_acl=${ifcfg.macAcl} ${optionalString (ifcfg.macAllow != [] || ifcfg.macAllowFile != null || ifcfg.authentication.saeAddToMacAllow) '' accept_mac_file=/run/hostapd/${interface}.mac.allow ''} @@ -100,7 +72,7 @@ ''} # Only allow WPA, disable insecure WEP auth_algs=1 - ignore_broadcast_ssid=${ignoreBroadcastSsidModes.${ifcfg.ignoreBroadcastSsid}} + ignore_broadcast_ssid=${ifcfg.ignoreBroadcastSsid} # Always enable QoS, which is required for 802.11n and above wmm_enabled=1 ap_isolate=${bool01 ifcfg.apIsolate} @@ -119,14 +91,14 @@ ieee80211ac=1 vht_capab=${concatMapStrings (x: "[${x}]") ifcfg.wifi5.capabilities} require_vht=${bool01 ifcfg.wifi5.require} - vht_oper_chwidth=${operatingChannelWidth.${ifcfg.wifi5.operatingChannelWidth}} + vht_oper_chwidth=${ifcfg.wifi5.operatingChannelWidth} ''} ${optionalString ifcfg.wifi6.enable '' ##### IEEE 802.11ax (WiFi 6) related configuration ##################################### ieee80211ax=1 require_he=${bool01 ifcfg.wifi6.require} - he_oper_chwidth=${operatingChannelWidth.${ifcfg.wifi6.operatingChannelWidth}} + he_oper_chwidth=${ifcfg.wifi6.operatingChannelWidth} he_su_beamformer=${bool01 ifcfg.wifi6.singleUserBeamformer} he_su_beamformee=${bool01 ifcfg.wifi6.singleUserBeamformee} he_mu_beamformer=${bool01 ifcfg.wifi6.multiUserBeamformer} @@ -135,7 +107,7 @@ ##### IEEE 802.11be (WiFi 7) related configuration ##################################### ieee80211be=1 - eht_oper_chwidth=${operatingChannelWidth.${ifcfg.wifi7.operatingChannelWidth}} + eht_oper_chwidth=${ifcfg.wifi7.operatingChannelWidth} eht_su_beamformer=${bool01 ifcfg.wifi7.singleUserBeamformer} eht_su_beamformee=${bool01 ifcfg.wifi7.singleUserBeamformee} eht_mu_beamformer=${bool01 ifcfg.wifi7.multiUserBeamformer} @@ -144,7 +116,7 @@ ##### WPA/IEEE 802.11i configuration ########################################## # Encrypt management frames to protect against deauthentication and similar attacks - ieee80211w=${managementFrameProtection.${ifcfg.managementFrameProtection}} + ieee80211w=${ifcfg.managementFrameProtection} ${optionalString (ifcfg.authentication.mode == "none") '' wpa=0 ''} @@ -424,6 +396,12 @@ in { macAcl = mkOption { default = "allow"; type = types.enum ["allow" "deny" "radius"]; + apply = x: + getAttr x { + "allow" = "0"; + "deny" = "1"; + "radius" = "2"; + }; description = mdDoc '' Station MAC address -based authentication. The following modes are available: @@ -484,6 +462,12 @@ in { ignoreBroadcastSsid = mkOption { default = "disabled"; type = types.enum ["disabled" "empty" "clear"]; + apply = x: + getAttr x { + "disabled" = "0"; + "empty" = "1"; + "clear" = "2"; + }; description = mdDoc '' Send empty SSID in beacons and ignore probe request frames that do not specify full SSID, i.e., require stations to know SSID. Note that this does @@ -717,6 +701,12 @@ in { managementFrameProtection = mkOption { default = "required"; type = types.enum ["disabled" "optional" "required"]; + apply = x: + getAttr x { + "disabled" = "0"; + "optional" = "1"; + "required" = "2"; + }; description = mdDoc '' Management frame protection (MFP) authenticates management frames to prevent deauthentication (or related) attacks. @@ -789,6 +779,13 @@ in { operatingChannelWidth = mkOption { default = "20or40"; type = types.enum ["20or40" "80" "160" "80+80"]; + apply = x: + getAttr x { + "20or40" = "0"; + "80" = "1"; + "160" = "2"; + "80+80" = "3"; + }; description = mdDoc '' Determines the operating channel width for VHT. @@ -837,6 +834,13 @@ in { operatingChannelWidth = mkOption { default = "20or40"; type = types.enum ["20or40" "80" "160" "80+80"]; + apply = x: + getAttr x { + "20or40" = "0"; + "80" = "1"; + "160" = "2"; + "80+80" = "3"; + }; description = mdDoc '' Determines the operating channel width for HE. @@ -882,6 +886,13 @@ in { operatingChannelWidth = mkOption { default = "20or40"; type = types.enum ["20or40" "80" "160" "80+80"]; + apply = x: + getAttr x { + "20or40" = "0"; + "80" = "1"; + "160" = "2"; + "80+80" = "3"; + }; description = mdDoc '' Determines the operating channel width for EHT. @@ -908,14 +919,18 @@ in { ] # Interface warnings ++ (concatLists (mapAttrsToList (interface: ifcfg: let - countWpaPasswordDefinitions = count (x: x != null) [ifcfg.authentication.wpaPassword ifcfg.authentication.wpaPasswordFile ifcfg.authentication.wpaPskFile]; + countWpaPasswordDefinitions = count (x: x != null) [ + ifcfg.authentication.wpaPassword + ifcfg.authentication.wpaPasswordFile + ifcfg.authentication.wpaPskFile + ]; in [ { - assertion = ifcfg.authentication.mode == "wpa3-sae" -> ifcfg.managementFrameProtection == "required"; + assertion = ifcfg.authentication.mode == "wpa3-sae" -> ifcfg.managementFrameProtection == "2"; message = ''hostapd interface ${interface} uses WPA3-SAE which requires managementFrameProtection="required"''; } { - assertion = ifcfg.authentication.mode == "wpa3-sae-transition" -> ifcfg.managementFrameProtection != "disabled"; + assertion = ifcfg.authentication.mode == "wpa3-sae-transition" -> ifcfg.managementFrameProtection != "0"; message = ''hostapd interface ${interface} uses WPA3-SAE in transition mode with WPA2-SHA256, which requires managementFrameProtection="optional" or ="required"''; } { @@ -947,15 +962,14 @@ in { environment.systemPackages = [pkgs.hostapd]; - services.udev.packages = optionals (any (i: i.countryCode != null) (attrValues cfg.interfaces)) [pkgs.crda]; + services.udev.packages = with pkgs; [crda]; systemd.services.hostapd = { - description = "Hostapd IEEE 802.11 AP Daemon"; + description = "IEEE 802.11 Host Access-Point Daemon"; path = [pkgs.hostapd]; - after = mapAttrsToList (interface: _: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") cfg.interfaces; - bindsTo = mapAttrsToList (interface: _: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") cfg.interfaces; - requiredBy = mapAttrsToList (interface: _: "network-link-${interface}.service") cfg.interfaces; + after = map (interface: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") (attrNames cfg.interfaces); + bindsTo = map (interface: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") (attrNames cfg.interfaces); wantedBy = ["multi-user.target"]; # Create merged configuration and acl files for each interface prior to starting @@ -973,7 +987,7 @@ in { DevicePolicy = "closed"; DeviceAllow = "/dev/rfkill rw"; NoNewPrivileges = true; - PrivateUsers = false; # hostapd requires real system root access. + PrivateUsers = false; # hostapd requires true root access. PrivateTmp = true; ProtectClock = true; ProtectControlGroups = true; @@ -985,12 +999,21 @@ in { ProtectProc = "invisible"; ProcSubset = "pid"; ProtectSystem = "strict"; - RestrictAddressFamilies = ["AF_UNIX" "AF_NETLINK" "AF_INET" "AF_INET6"]; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SystemCallArchitectures = "native"; - SystemCallFilter = ["@system-service" "~@privileged" "@chown"]; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "@chown" + ]; UMask = "0077"; }; };