feat: modulize esphome

This commit is contained in:
oddlama 2023-03-22 20:18:25 +01:00
parent 8545dff4e7
commit 5d8c1c902d
No known key found for this signature in database
GPG key ID: 14EFE510775FE39A
4 changed files with 241 additions and 116 deletions

12
flake.lock generated
View file

@ -166,11 +166,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1679265143, "lastModified": 1679480702,
"narHash": "sha256-5RDMW+O4owjdPz7t4K4YxH2fOHCNOcyVmSiKRUikiv0=", "narHash": "sha256-npuRD61YmxUPitI1TqKwlxLrU6iGl5E+BPT196LgUDo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "1b8bf5c3270386a1b6850bd77d79dbdbaf0d7a7c", "rev": "363c46b2480f1b73ec37cf68caac61f5daa82a2e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -211,11 +211,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1679172431, "lastModified": 1679262748,
"narHash": "sha256-XEh5gIt5otaUbEAPUY5DILUTyWe1goAyeqQtmwaFPyI=", "narHash": "sha256-DQCrrAFrkxijC6haUzOC5ZoFqpcv/tg2WxnyW3np1Cc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "1603d11595a232205f03d46e635d919d1e1ec5b9", "rev": "60c1d71f2ba4c80178ec84523c2ca0801522e0a6",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -1,68 +1,17 @@
{ {nodeSecrets, ...}: {
lib, imports = [../../modules/esphome.nix];
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";
# Hardening services.esphome = {
CapabilityBoundingSet = ""; enable = true;
LockPersonality = true; enableUnixSocket = true;
MemoryDenyWriteExecute = true; allowedDevices = [
DevicePolicy = "closed"; {
DeviceAllow = "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"; node = "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0";
SupplementaryGroups = ["dialout"]; modifier = "rw";
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";
};
}; };
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 # TODO esphome.sock permissions pls nginx currently world writable
services.nginx.upstreams = { services.nginx.upstreams = {
"esphome" = { "esphome" = {

153
modules/esphome.nix Normal file
View file

@ -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";
};
};
};
}

View file

@ -7,21 +7,22 @@
}: let }: let
inherit inherit
(lib) (lib)
any attrNames
attrValues attrValues
concatLists concatLists
concatMap
concatMapStrings concatMapStrings
concatStringsSep concatStringsSep
count count
escapeShellArg escapeShellArg
filter filter
getAttr
literalExpression literalExpression
mapAttrsToList mapAttrsToList
mdDoc mdDoc
mkIf mkIf
mkOption mkOption
optional optional
optionals
optionalString optionalString
stringLength stringLength
toLower toLower
@ -30,35 +31,6 @@
cfg = config.services.hostapd; 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: bool01 = b:
if b if b
then "1" then "1"
@ -91,7 +63,7 @@
channel=${toString ifcfg.channel} channel=${toString ifcfg.channel}
noscan=${bool01 ifcfg.noScan} noscan=${bool01 ifcfg.noScan}
# Set the MAC-address access control mode # 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) '' ${optionalString (ifcfg.macAllow != [] || ifcfg.macAllowFile != null || ifcfg.authentication.saeAddToMacAllow) ''
accept_mac_file=/run/hostapd/${interface}.mac.allow accept_mac_file=/run/hostapd/${interface}.mac.allow
''} ''}
@ -100,7 +72,7 @@
''} ''}
# Only allow WPA, disable insecure WEP # Only allow WPA, disable insecure WEP
auth_algs=1 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 # Always enable QoS, which is required for 802.11n and above
wmm_enabled=1 wmm_enabled=1
ap_isolate=${bool01 ifcfg.apIsolate} ap_isolate=${bool01 ifcfg.apIsolate}
@ -119,14 +91,14 @@
ieee80211ac=1 ieee80211ac=1
vht_capab=${concatMapStrings (x: "[${x}]") ifcfg.wifi5.capabilities} vht_capab=${concatMapStrings (x: "[${x}]") ifcfg.wifi5.capabilities}
require_vht=${bool01 ifcfg.wifi5.require} require_vht=${bool01 ifcfg.wifi5.require}
vht_oper_chwidth=${operatingChannelWidth.${ifcfg.wifi5.operatingChannelWidth}} vht_oper_chwidth=${ifcfg.wifi5.operatingChannelWidth}
''} ''}
${optionalString ifcfg.wifi6.enable '' ${optionalString ifcfg.wifi6.enable ''
##### IEEE 802.11ax (WiFi 6) related configuration ##################################### ##### IEEE 802.11ax (WiFi 6) related configuration #####################################
ieee80211ax=1 ieee80211ax=1
require_he=${bool01 ifcfg.wifi6.require} 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_beamformer=${bool01 ifcfg.wifi6.singleUserBeamformer}
he_su_beamformee=${bool01 ifcfg.wifi6.singleUserBeamformee} he_su_beamformee=${bool01 ifcfg.wifi6.singleUserBeamformee}
he_mu_beamformer=${bool01 ifcfg.wifi6.multiUserBeamformer} he_mu_beamformer=${bool01 ifcfg.wifi6.multiUserBeamformer}
@ -135,7 +107,7 @@
##### IEEE 802.11be (WiFi 7) related configuration ##################################### ##### IEEE 802.11be (WiFi 7) related configuration #####################################
ieee80211be=1 ieee80211be=1
eht_oper_chwidth=${operatingChannelWidth.${ifcfg.wifi7.operatingChannelWidth}} eht_oper_chwidth=${ifcfg.wifi7.operatingChannelWidth}
eht_su_beamformer=${bool01 ifcfg.wifi7.singleUserBeamformer} eht_su_beamformer=${bool01 ifcfg.wifi7.singleUserBeamformer}
eht_su_beamformee=${bool01 ifcfg.wifi7.singleUserBeamformee} eht_su_beamformee=${bool01 ifcfg.wifi7.singleUserBeamformee}
eht_mu_beamformer=${bool01 ifcfg.wifi7.multiUserBeamformer} eht_mu_beamformer=${bool01 ifcfg.wifi7.multiUserBeamformer}
@ -144,7 +116,7 @@
##### WPA/IEEE 802.11i configuration ########################################## ##### WPA/IEEE 802.11i configuration ##########################################
# Encrypt management frames to protect against deauthentication and similar attacks # Encrypt management frames to protect against deauthentication and similar attacks
ieee80211w=${managementFrameProtection.${ifcfg.managementFrameProtection}} ieee80211w=${ifcfg.managementFrameProtection}
${optionalString (ifcfg.authentication.mode == "none") '' ${optionalString (ifcfg.authentication.mode == "none") ''
wpa=0 wpa=0
''} ''}
@ -424,6 +396,12 @@ in {
macAcl = mkOption { macAcl = mkOption {
default = "allow"; default = "allow";
type = types.enum ["allow" "deny" "radius"]; type = types.enum ["allow" "deny" "radius"];
apply = x:
getAttr x {
"allow" = "0";
"deny" = "1";
"radius" = "2";
};
description = mdDoc '' description = mdDoc ''
Station MAC address -based authentication. The following modes are available: Station MAC address -based authentication. The following modes are available:
@ -484,6 +462,12 @@ in {
ignoreBroadcastSsid = mkOption { ignoreBroadcastSsid = mkOption {
default = "disabled"; default = "disabled";
type = types.enum ["disabled" "empty" "clear"]; type = types.enum ["disabled" "empty" "clear"];
apply = x:
getAttr x {
"disabled" = "0";
"empty" = "1";
"clear" = "2";
};
description = mdDoc '' description = mdDoc ''
Send empty SSID in beacons and ignore probe request frames that do not 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 specify full SSID, i.e., require stations to know SSID. Note that this does
@ -717,6 +701,12 @@ in {
managementFrameProtection = mkOption { managementFrameProtection = mkOption {
default = "required"; default = "required";
type = types.enum ["disabled" "optional" "required"]; type = types.enum ["disabled" "optional" "required"];
apply = x:
getAttr x {
"disabled" = "0";
"optional" = "1";
"required" = "2";
};
description = mdDoc '' description = mdDoc ''
Management frame protection (MFP) authenticates management frames Management frame protection (MFP) authenticates management frames
to prevent deauthentication (or related) attacks. to prevent deauthentication (or related) attacks.
@ -789,6 +779,13 @@ in {
operatingChannelWidth = mkOption { operatingChannelWidth = mkOption {
default = "20or40"; default = "20or40";
type = types.enum ["20or40" "80" "160" "80+80"]; type = types.enum ["20or40" "80" "160" "80+80"];
apply = x:
getAttr x {
"20or40" = "0";
"80" = "1";
"160" = "2";
"80+80" = "3";
};
description = mdDoc '' description = mdDoc ''
Determines the operating channel width for VHT. Determines the operating channel width for VHT.
@ -837,6 +834,13 @@ in {
operatingChannelWidth = mkOption { operatingChannelWidth = mkOption {
default = "20or40"; default = "20or40";
type = types.enum ["20or40" "80" "160" "80+80"]; type = types.enum ["20or40" "80" "160" "80+80"];
apply = x:
getAttr x {
"20or40" = "0";
"80" = "1";
"160" = "2";
"80+80" = "3";
};
description = mdDoc '' description = mdDoc ''
Determines the operating channel width for HE. Determines the operating channel width for HE.
@ -882,6 +886,13 @@ in {
operatingChannelWidth = mkOption { operatingChannelWidth = mkOption {
default = "20or40"; default = "20or40";
type = types.enum ["20or40" "80" "160" "80+80"]; type = types.enum ["20or40" "80" "160" "80+80"];
apply = x:
getAttr x {
"20or40" = "0";
"80" = "1";
"160" = "2";
"80+80" = "3";
};
description = mdDoc '' description = mdDoc ''
Determines the operating channel width for EHT. Determines the operating channel width for EHT.
@ -908,14 +919,18 @@ in {
] ]
# Interface warnings # Interface warnings
++ (concatLists (mapAttrsToList (interface: ifcfg: let ++ (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 [ 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"''; 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"''; 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]; 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 = { systemd.services.hostapd = {
description = "Hostapd IEEE 802.11 AP Daemon"; description = "IEEE 802.11 Host Access-Point Daemon";
path = [pkgs.hostapd]; path = [pkgs.hostapd];
after = mapAttrsToList (interface: _: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") cfg.interfaces; after = map (interface: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") (attrNames cfg.interfaces);
bindsTo = mapAttrsToList (interface: _: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") cfg.interfaces; bindsTo = map (interface: "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device") (attrNames cfg.interfaces);
requiredBy = mapAttrsToList (interface: _: "network-link-${interface}.service") cfg.interfaces;
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
# Create merged configuration and acl files for each interface prior to starting # Create merged configuration and acl files for each interface prior to starting
@ -973,7 +987,7 @@ in {
DevicePolicy = "closed"; DevicePolicy = "closed";
DeviceAllow = "/dev/rfkill rw"; DeviceAllow = "/dev/rfkill rw";
NoNewPrivileges = true; NoNewPrivileges = true;
PrivateUsers = false; # hostapd requires real system root access. PrivateUsers = false; # hostapd requires true root access.
PrivateTmp = true; PrivateTmp = true;
ProtectClock = true; ProtectClock = true;
ProtectControlGroups = true; ProtectControlGroups = true;
@ -985,12 +999,21 @@ in {
ProtectProc = "invisible"; ProtectProc = "invisible";
ProcSubset = "pid"; ProcSubset = "pid";
ProtectSystem = "strict"; ProtectSystem = "strict";
RestrictAddressFamilies = ["AF_UNIX" "AF_NETLINK" "AF_INET" "AF_INET6"]; RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
"AF_UNIX"
];
RestrictNamespaces = true; RestrictNamespaces = true;
RestrictRealtime = true; RestrictRealtime = true;
RestrictSUIDSGID = true; RestrictSUIDSGID = true;
SystemCallArchitectures = "native"; SystemCallArchitectures = "native";
SystemCallFilter = ["@system-service" "~@privileged" "@chown"]; SystemCallFilter = [
"@system-service"
"~@privileged"
"@chown"
];
UMask = "0077"; UMask = "0077";
}; };
}; };