initial commit

This commit is contained in:
2025-04-25 23:34:04 +02:00
commit 11a34aa04c
7 changed files with 921 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
{ config, options, lib, pkgs, ... }:
let
cfg = config.cloonar-assistant;
vpn-client-opts = peerOpts = self: {
options = {
publicKey = mkOption {
example = "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=";
type = types.singleLineStr;
description = "The base64 public key of the peer.";
};
presharedKey = mkOption {
default = null;
example = "rVXs/Ni9tu3oDBLS4hOyAUAa1qTWVA3loR8eL20os3I=";
type = with types; nullOr str;
description = ''
Base64 preshared key generated by {command}`wg genpsk`.
Optional, and may be omitted. This option adds an additional layer of
symmetric-key cryptography to be mixed into the already existing
public-key cryptography, for post-quantum resistance.
Warning: Consider using presharedKeyFile instead if you do not
want to store the key in the world-readable Nix store.
'';
};
presharedKeyFile = mkOption {
default = null;
example = "/private/wireguard_psk";
type = with types; nullOr str;
description = ''
File pointing to preshared key as generated by {command}`wg genpsk`.
Optional, and may be omitted. This option adds an additional layer of
symmetric-key cryptography to be mixed into the already existing
public-key cryptography, for post-quantum resistance.
'';
};
allowedIPs = mkOption {
example = [
"10.192.122.3/32"
"10.192.124.1/24"
];
type = with types; listOf str;
description = ''
List of IP (v4 or v6) addresses with CIDR masks from
which this peer is allowed to send incoming traffic and to which
outgoing traffic for this peer is directed. The catch-all 0.0.0.0/0 may
be specified for matching all IPv4 addresses, and ::/0 may be specified
for matching all IPv6 addresses.'';
};
};
};
in {
options.cloonar-assistant = {
networkPrefix = lib.mkOption {
type = lib.types.str;
example = "10.42";
description = "First two octets of the network";
};
domain = lib.mkOption {
type = lib.types.str;
example = "example.smart.cloonar.com";
description = "domain of the network";
};
updns = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable updns";
};
key = lib.mkOption {
type = lib.types.str;
example = "example";
description = "key for updns";
};
};
vpn = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable VPN";
};
clients = mkOption {
default = [ ];
description = "VPN Clients";
type = with types; listOf (submodule vpn-client-opts);
};
};
multiroom-audio = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable multiroom audio";
};
};
firewall = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable firewall";
};
interfaces = {
wan = lib.mkOption {
type = lib.types.str;
example = "enp2s0";
description = "Network interface for WAN";
};
internal = lib.mkOption {
type = lib.types.str;
example = "enp3s0";
description = "Internal network interface";
};
};
};
};
imports = [
# Include the results of the hardware scan.
./networking
./updns
./home-assistant
./multiroom-audio
];
}

View File

@@ -0,0 +1,249 @@
{ config, pkgs, ... }:
let
domain = "home-assistant.${config.cloonar-assistant.domain}";
pkgs-with-home-assistant = import (builtins.fetchGit {
name = "new-home-assistant";
url = "https://github.com/nixos/nixpkgs/";
rev = "18dd725c29603f582cf1900e0d25f9f1063dbf11";
}) {};
networkPrefix = config.networkPrefix;
home-assistant-config = config.home-assistant;
home-assistant-config.package = pkgs-with-home-assistant.home-assistant;
certDir = "/var/lib/ssl/home-assistant";
certFile = "${certDir}/selfsigned.crt";
keyFile = "${certDir}/selfsigned.key";
in
{
users.users.hass = {
home = "/var/lib/hass";
createHome = true;
group = "hass";
uid = config.ids.uids.hass;
extraGroups = [ "dialout" ];
};
users.groups.hass.gid = config.ids.gids.hass;
security.acme.certs."${domain}" = {
group = "nginx";
};
sops.secrets."home-assistant-secrets.yaml" = {
owner = "hass";
restartUnits = [ "container@hass.service" ];
};
containers.hass = {
autoStart = true;
ephemeral = false;
privateNetwork = true;
hostBridge = "server";
hostAddress = "${networkPrefix}.97.1";
localAddress = "${networkPrefix}.97.20/24";
extraFlags = [
"--capability=CAP_NET_ADMIN"
"--capability=CAP_MKNOD"
];
bindMounts = {
"/etc/localtime" = {
hostPath = "/etc/localtime";
};
"/var/lib/hass" = {
hostPath = "/var/lib/hass/";
isReadOnly = false;
};
"/var/lib/acme/hass/" = {
hostPath = "${config.security.acme.certs.${domain}.directory}";
};
"/var/lib/hass/secrets.yaml" = {
hostPath = config.sops.secrets."home-assistant-secrets.yaml".path;
};
};
config = { lib, config, pkgs, ... }: {
networkPrefix = networkPrefix;
imports = [
];
networking = {
hostName = "home-assistant";
useHostResolvConf = false;
defaultGateway = {
address = "${networkPrefix}.96.1";
interface = "eth0";
};
firewall.enable = false;
nameservers = [ "${networkPrefix}.97.1" ];
};
environment.systemPackages = [
pkgs.mariadb
];
systemd.services.generate-selfsigned-cert = {
description = "Generate/renew self-signed SSL certificate";
wantedBy = [ "nginx.service" ];
path = [ pkgs.openssl pkgs.gnugrep ];
script = ''
if [ -f ${certFile} ]; then
expiry=$(openssl x509 -enddate -noout -in ${certFile} | cut -d= -f2)
expiry_epoch=$(date -d "$expiry" +%s)
current_epoch=$(date +%s)
days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
if [ $days_left -lt 30 ]; then # Regenerate if expiring in <30 days
echo "Certificate expiring soon, regenerating..."
rm ${certFile} ${keyFile}
fi
fi
if [ ! -f ${certFile} ] || [ ! -f ${keyFile} ]; then
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout ${keyFile} \
-out ${certFile} \
-subj "/CN=${domain}"
fi
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
};
services.nginx.enable = true;
services.nginx.virtualHosts."${domain}" = {
sslCertificate = certFile;
sslCertificateKey = keyFile;
forceSSL = true;
extraConfig = ''
proxy_buffering off;
'';
locations."/".extraConfig = ''
proxy_pass http://127.0.0.1:8123;
proxy_set_header Host $host;
proxy_redirect http:// https://;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
'';
};
services.home-assistant = home-assistant-config;
services.home-assistant.extraComponents = [
"mobile_app"
"backup"
];
systemd.services.install-hacs = {
description = "Install HACS";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
};
script = ''
set -e
HACS_VERSION="2.0.5" # Replace with the latest version
HACS_DIR="/var/lib/hass/custom_components/hacs"
mkdir -p "$HACS_DIR"
${pkgs.curl}/bin/curl -L "https://github.com/hacs/integration/releases/download/$HACS_VERSION/hacs.zip" -o /tmp/hacs.zip
${pkgs.unzip}/bin/unzip -o /tmp/hacs.zip -d "$HACS_DIR"
rm /tmp/hacs.zip
chown -R hass:hass "$HACS_DIR"
'';
};
services.home-assistant.extraPackages = ps: with ps; [
mysqlclient
];
services.mysql = {
enable = true;
package = pkgs.mariadb;
ensureDatabases = [ "hass" ];
ensureUsers = [
{
name = "hass";
ensurePermissions = {
"hass.*" = "ALL PRIVILEGES";
};
}
];
};
services.mysqlBackup = {
enable = true;
databases = [ "hass" ];
};
services.home-assistant.config =
let
hiddenEntities = [
"sensor.last_boot"
"sensor.date"
];
in
{
recorder = {
db_url = "mysql://hass@localhost/hass?unix_socket=/var/run/mysqld/mysqld.sock";
};
homeassistant = {
name = "Home";
latitude = "!secret home_latitude";
longitude = "!secret home_longitude";
elevation = "!secret home_elevation";
unit_system = "metric";
currency = "EUR";
country = "AT";
time_zone = "Europe/Vienna";
external_url = "https://${domain}";
};
zone = {
name = "Home";
latitude = "!secret home_latitude";
longitude = "!secret home_longitude";
radius = 35;
icon = "mdi:account-multiple";
};
automation = "!include automations.yaml";
frontend = { };
http = {
use_x_forwarded_for = true;
trusted_proxies = [
"127.0.0.1"
"::1"
];
};
api = { };
history.exclude = {
entities = hiddenEntities;
domains = [
"automation"
"updater"
];
};
"map" = { };
# logbook.exclude.entities = "hiddenEntities";
logger = {
default = "warning";
};
network = { };
zeroconf = { };
system_health = { };
default_config = { };
system_log = { };
};
users.users.hass.extraGroups = [ "dialout" ];
system.stateVersion = "23.05";
};
};
}

View File

@@ -0,0 +1,7 @@
{}: {
imports = [
./interfaces.nix
./dhcp.nix
./firewall.nix
];
}

View File

@@ -0,0 +1,187 @@
{ config, lib, ... }:
{
services.kea.dhcp4 = lib.mkIf config.cloonar-assistant.firewall.enable {
enable = true;
settings = {
interfaces-config = {
interfaces = [
"lan"
"server"
"infrastructure"
"multimedia"
"smart"
"guest"
];
};
lease-database = {
name = "/var/lib/kea/dhcp4.leases";
persist = true;
type = "memfile";
};
rebind-timer = 2000;
renew-timer = 1000;
subnet4 = [
{
id = 96;
pools = [
{
pool = "${config.networkPrefix}.96.100 - ${config.networkPrefix}.96.240";
}
];
subnet = "${config.networkPrefix}.96.0/24";
interface = "lan";
option-data = [
{
name = "routers";
data = "${config.networkPrefix}.96.1";
}
{
name = "domain-name";
data = config.cloonar-assistant.domain;
}
{
name = "domain-search";
data = config.cloonar-assistant.domain;
}
{
name = "domain-name-servers";
data = "${config.networkPrefix}.96.1";
}
];
reservations = [
];
}
{
id = 97;
pools = [
{
pool = "${config.networkPrefix}.97.100 - ${config.networkPrefix}.97.240";
}
];
subnet = "${config.networkPrefix}.97.0/24";
interface = "server";
option-data = [
{
name = "routers";
data = "${config.networkPrefix}.97.1";
}
{
name = "domain-name";
data = config.cloonar-assistant.domain;
}
{
name = "domain-name-servers";
data = "${config.networkPrefix}.97.1";
}
];
reservations = [
];
}
{
id = 101;
pools = [
{
pool = "${config.networkPrefix}.101.100 - ${config.networkPrefix}.101.240";
}
];
subnet = "${config.networkPrefix}.101.0/24";
interface = "infrastructure";
option-data = [
{
name = "routers";
data = "${config.networkPrefix}.101.1";
}
{
name = "domain-name";
data = config.cloonar-assistant.domain;
}
{
name = "domain-name-servers";
data = "${config.networkPrefix}.101.1";
}
{
name = "capwap-ac-v4";
code = 138;
data = "${config.networkPrefix}.97.2";
}
];
reservations = [
];
}
{
id = 99;
pools = [
{
pool = "${config.networkPrefix}.99.100 - ${config.networkPrefix}.99.240";
}
];
subnet = "${config.networkPrefix}.99.0/24";
interface = "multimedia";
option-data = [
{
name = "routers";
data = "${config.networkPrefix}.99.1";
}
{
name = "domain-name";
data = config.cloonar-assistant.domain;
}
{
name = "domain-name-servers";
data = "${config.networkPrefix}.99.1";
}
];
reservations = [
];
}
{
id = 254;
pools = [
{
pool = "${config.networkPrefix}.254.10 - ${config.networkPrefix}.254.254";
}
];
subnet = "${config.networkPrefix}.254.0/24";
interface = "guest";
option-data = [
{
name = "routers";
data = "${config.networkPrefix}.254.1";
}
{
name = "domain-name-servers";
data = "9.9.9.9";
}
];
}
{
id = 100;
pools = [
{
pool = "${config.networkPrefix}.100.100 - ${config.networkPrefix}.100.240";
}
];
subnet = "${config.networkPrefix}.100.0/24";
interface = "smart";
option-data = [
{
name = "routers";
data = "${config.networkPrefix}.100.1";
}
{
name = "domain-name";
data = config.cloonar-assistant.domain;
}
{
name = "domain-name-servers";
data = "${config.networkPrefix}.100.1";
}
];
reservations = [
];
}
];
valid-lifetime = 4000;
};
};
}

View File

@@ -0,0 +1,150 @@
{ config, lib, pkgs, ... }:
let
forward-chain = ''
'';
in {
networking = {
firewall.checkReversePath = false;
nat.enable = false;
nftables = {
enable = true;
tables = {
"cloonar-fw" = {
family = "inet";
content = ''
chain output {
type filter hook output priority 100; policy accept;
}
chain rpfilter {
type filter hook prerouting priority mangle + 10; policy drop;
meta nfproto ipv4 udp sport . udp dport { 68 . 67, 67 . 68 } accept comment "DHCPv4 client/server"
fib saddr . mark . iif oif exists accept
}
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept comment "trusted interfaces"
iifname "lan" counter accept comment "Spice"
ct state vmap { invalid : drop, established : accept, related : accept, new : jump input-allow, untracked : jump input-allow }
tcp flags syn / fin,syn,rst,ack log prefix "refused connection: " level info
}
chain input-allow {
udp dport != { 53, 5353 } ct state new limit rate over 1/second burst 10 packets drop comment "rate limit for new connections"
iifname lo accept
${lib.optionalString config.cloonar-assistant.vpn.enable ''
iifname "wan" udp dport 51820 counter accept comment "Wireguard traffic"
''}
${lib.optionalString config.cloonar-assistant.firewall.enable ''
iifname "wan" tcp dport 9273 counter accept comment "Prometheus traffic"
''}
${lib.optionalString config.cloonar-assistant.firewall.enable ''
iifname { "server", "vserver", "vm-*", "lan", "wg_cloonar" } counter accept comment "allow trusted to router"
iifname { "multimedia", "smart", "infrastructure", "podman0", "setup" } udp dport { 53, 5353 } counter accept comment "DNS"
''}
iifname { "wan", "multimedia" } icmp type { echo-request, destination-unreachable, time-exceeded } counter accept comment "Allow select ICMP"
# Accept mDNS for avahi reflection
${lib.optionalString config.cloonar-assistant.multiroom-audio.enable ''
iifname "server" ip saddr ${config.networkPrefix}.97.20/32 tcp dport { llmnr } counter accept
iifname "server" ip saddr ${config.networkPrefix}.97.20/32 udp dport { mdns, llmnr } counter accept
''}
# Allow all returning traffic
ct state { established, related } counter accept
# Allow returning traffic from wan and drop everthing else
iifname "wan" ct state { established, related } accept comment "Allow established traffic"
iifname "wan" icmp type { echo-request, destination-unreachable, time-exceeded } counter accept comment "Allow select ICMP"
iifname "wan" counter drop comment "Drop all other unsolicited traffic from wan"
limit rate 60/minute burst 100 packets log prefix "Input - Drop: " comment "Log any unmatched traffic"
}
chain forward {
type filter hook forward priority filter; policy drop;
iifname "wg_cloonar" counter accept comment "test wireguard"
${lib.optionalString config.cloonar-assistant.vpn.enable ''
iifname "wg_cloonar" oifname lo counter accept comment "wireguard to server"
''}
# enable flow offloading for better throughput
# ip protocol { tcp, udp } flow offload @f
# multimedia airplay
${lib.optionalString config.cloonar-assistant.multiroom-audio.enable ''
iifname "multimedia" oifname { "lan" } counter accept
iifname "multimedia" oifname "server" tcp dport { 1704, 1705 } counter accept
iifname "lan" oifname "server" udp dport { 5000, 5353, 6001 - 6011 } counter accept
# avahi
iifname "server" ip saddr ${config.networkPrefix}.97.20/32 oifname { "lan" } counter accept
''}
${lib.optionalString config.cloonar-assistant.firewall.enable ''
# smart home coap
iifname "smart" oifname "server" ip daddr ${config.networkPrefix}.97.20/32 udp dport { 5683 } counter accept
iifname "smart" oifname "server" ip daddr ${config.networkPrefix}.97.20/32 tcp dport { 1883 } counter accept
# lan and vpn to any
iifname { "lan", "server", "vserver", "wg_cloonar" } oifname { "lan", "vb-*", "vm-*", "server", "vserver", "infrastructure", "multimedia", "smart", "wg_cloonar", "guest", "setup" } counter accept
iifname { "infrastructure", "setup" } oifname { "server", "vserver" } counter accept
iifname { "lan", "wan" } udp dport { 8211, 27015 } counter accept comment "palworld"
''}
# allow all established, related
ct state { established, related } accept comment "Allow established traffic"
# Allow trusted network WAN access
${lib.optionalString config.cloonar-assistant.firewall.enable ''
iifname {
"lan",
"infrastructure",
"server",
"vserver",
"multimedia",
"smart",
"wg_cloonar",
"podman*",
"guest",
"setup",
"vb-*",
"vm-*",
} oifname {
"wan",
} counter accept comment "Allow trusted LAN to WAN"
''}
limit rate 60/minute burst 100 packets log prefix "Forward - Drop: " comment "Log any unmatched traffic"
}
'';
};
"cloonar-nat" = {
family = "ip";
content = ''
chain prerouting {
type nat hook prerouting priority filter; policy accept;
iifname "server" ip daddr ${config.networkPrefix}.96.255 udp dport { 9 } dnat to ${config.networkPrefix}.96.255
}
# Setup NAT masquerading on external interfaces
chain postrouting {
type nat hook postrouting priority filter; policy accept;
oifname { "wan" } masquerade
${lib.optionalString config.cloonar-assistant.vpn.enable ''
oifname { "wg_cloonar" } masquerade
''}
}
'';
};
};
};
};
}

View File

@@ -0,0 +1,112 @@
{ config, lib, ... }:
{
boot.kernel.sysctl = {
# if you use ipv4, this is all you need
"net.ipv4.conf.all.forwarding" = true;
# If you want to use it for ipv6
"net.ipv6.conf.all.forwarding" = false;
};
systemd.network = {
enable = true;
wait-online.anyInterface = true;
links = {
"10-wan" = {
matchConfig.Name = cloonar-assistant.interfaces.wan;
linkConfig.Name = "wan";
};
};
netdevs = {
"30-server".netdevConfig = {
Kind = "bridge";
Name = "server";
};
};
networks = {
"31-server" = {
matchConfig.Name = [ "vserver" ];
# Attach to the bridge that was configured above
networkConfig.Bridge = "server";
};
};
};
networking = if config.cloonar-assistant.firewall.enable then {
useDHCP = false;
# Define VLANS
nameservers = [ "${config.networkPrefix}.97.1" ];
# resolvconf.enable = false;
vlans = {
infrastructure = {
id = 101;
interface = config.cloonar-assistant.interfaces.internal;
};
lan = {
id = 96;
interface = config.cloonar-assistant.interfaces.internal;
};
vserver = {
id = 97;
interface = config.cloonar-assistant.interfaces.internal;
};
multimedia = {
id = 99;
interface = config.cloonar-assistant.interfaces.internal;
};
smart = {
id = 100;
interface = config.cloonar-assistant.interfaces.internal;
};
guest = {
id = 254;
interface = config.cloonar-assistant.interfaces.internal;
};
};
interfaces = {
# Don't request DHCP on the physical interfaces
internal.useDHCP = false;
# Handle the VLANs
wan.useDHCP = true;
lan = {
ipv4.addresses = [{
address = "${config.networkPrefix}.96.1";
prefixLength = 24;
}];
};
server = {
ipv4.addresses = [{
address = "${config.networkPrefix}.97.1";
prefixLength = 24;
}];
};
infrastructure = {
ipv4.addresses = [{
address = "${config.networkPrefix}.101.1";
prefixLength = 24;
}];
};
multimedia = {
ipv4.addresses = [{
address = "${config.networkPrefix}.99.1";
prefixLength = 24;
}];
};
smart = {
ipv4.addresses = [{
address = "${config.networkPrefix}.100.1";
prefixLength = 24;
}];
};
guest = {
ipv4.addresses = [{
address = "${config.networkPrefix}.254.1";
prefixLength = 24;
}];
};
};
} else {
useDHCP = true;
};
}

View File

@@ -0,0 +1,88 @@
{ config, pkgs, lib, ... }: {
### 1) Make sure we have the tools we need
environment.systemPackages = with pkgs; [
curl
jq
];
sops.secrets.updns-client-token = {
owner = "updns-client";
restartUnits = [ "updns-client.service" ];
};
### 3) Write the checkscript into /etc/external-ip/check.sh (0400, executable)
environment.etc."updns-client/run.sh".text = lib.mkIf config.cloonar-assistant.updns-client.enable lib.concatStringsSep "\n" [
"#!/usr/bin/env bash"
"set -euo pipefail"
""
"# Where our secret lives (encrypted)"
"SECRET=${config.sops.secrets.updns-client.path}"
"# Where we record the lastseen IP"
"LAST_IP_FILE=/var/lib/updns-client/last-ip"
""
"# Decrypt the API key at runtime"
"API_KEY=$(cat \"$SECRET\")"
""
"# Fetch current external IP"
"IP=$(curl -fsSL https://ifconfig.me)"
""
"# Ensure state directory exists"
"mkdir -p \"$(dirname \"$LAST_IP_FILE\")\""
""
"# Read old IP (if any)"
"if [[ -f \"$LAST_IP_FILE\" ]]; then"
" OLD_IP=$(< \"$LAST_IP_FILE\")"
"else"
" OLD_IP=\"\""
"fi"
""
"# If it's changed, notify the API and update the file"
"if [[ \"$IP\" != \"$OLD_IP\" ]]; then"
""
" PAYLOAD=$(jq -n \\"
" --arg key \"${config.cloonar-assistant.updns-client.key}\" \\"
" --arg secret \"$SECRET\" \\"
" --arg host \"${config.cloonar-assistant.domain}\" \\"
" --arg ip \"$IP\" \\"
" '{key: $key, secret: $secret, host: $host, ip: $ip}')"
""
" curl -fsS -X POST https://updns-client.cloonar.com/update \\"
" -H \"Content-Type: application/json\" \\"
" -d \"$PAYLOAD\""
""
" echo \"$IP\" > \"$LAST_IP_FILE\""
"fi"
];
environment.etc."updns-client/run.sh".mode = "0500";
### 4) Ensure /var/lib/external-ip exists on boot
systemd.tmpfiles.rules = [
# path mode owner group age
"d /var/lib/updns-client 0755 root root -"
];
### 5) Define the oneshot service
systemd.services.updns-client = lib.mkIf config.cloonar-assistant.updns-client.enable {
description = "Check external IP and notify API on change";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
WorkingDirectory = "/var/lib/updns-client";
ExecStart = "${pkgs.bash}/bin/bash /etc/updns-client/run.sh";
};
install.WantedBy = [ "multi-user.target" ];
};
### 6) Define the timer (runs at boot + every 5 minutes)
systemd.timers.updns-client = lib.mkIf config.cloonar-assistant.updns-client.enable {
description = "Run updns-client.service every 5 minutes";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "1min";
OnUnitActiveSec = "5min";
Persistent = true;
};
unit = "updns-client.service";
};
}