From bba9ceff39e6146c175b7700f2ccd9912244ad7f Mon Sep 17 00:00:00 2001 From: Katharina Heidenreich Date: Mon, 9 Mar 2026 22:06:13 +0100 Subject: [PATCH] feat: initial --- .gitignore | 3 ++ configuration.nix | 108 +++++++++++++++++++++++++++++++++++++ data/network.nix | 67 +++++++++++++++++++++++ data/services.nix | 41 ++++++++++++++ data/ssh.nix | 37 +++++++++++++ data/storage.nix | 14 +++++ network/static-ip.nix | 18 +++++++ programs/default.nix | 3 ++ programs/git.nix | 16 ++++++ services/README.md | 13 +++++ services/blocky.nix | 61 +++++++++++++++++++++ services/kea.nix | 56 +++++++++++++++++++ services/kiwix-updater.nix | 87 ++++++++++++++++++++++++++++++ services/kiwix.nix | 36 +++++++++++++ services/nginx.nix | 47 ++++++++++++++++ services/openssh.nix | 17 ++++++ services/qbittorrent.nix | 92 +++++++++++++++++++++++++++++++ services/unbound.nix | 34 ++++++++++++ 18 files changed, 750 insertions(+) create mode 100644 .gitignore create mode 100644 configuration.nix create mode 100644 data/network.nix create mode 100644 data/services.nix create mode 100644 data/ssh.nix create mode 100644 data/storage.nix create mode 100644 network/static-ip.nix create mode 100644 programs/default.nix create mode 100644 programs/git.nix create mode 100644 services/README.md create mode 100644 services/blocky.nix create mode 100644 services/kea.nix create mode 100644 services/kiwix-updater.nix create mode 100644 services/kiwix.nix create mode 100644 services/nginx.nix create mode 100644 services/openssh.nix create mode 100644 services/qbittorrent.nix create mode 100644 services/unbound.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7987d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +users/ +ssh_keys/ +results diff --git a/configuration.nix b/configuration.nix new file mode 100644 index 0000000..b955fa0 --- /dev/null +++ b/configuration.nix @@ -0,0 +1,108 @@ +{ + config, + pkgs, + lib, + ... +}: let + nixosHardwareVersion = "7f1836531b126cfcf584e7d7d71bf8758bb58969"; + + timeZone = "Europe/Berlin"; + defaultLocale = "en_US.UTF-8"; + storageConfig = import ./data/storage.nix; + fileSystemDefinition = lib.mapAttrs' ( + name: value: { + name = storageConfig.${name}.path; + value = { + device = storageConfig.${name}.source; + fsType = storageConfig.${name}.type; + options = storageConfig.${name}.options; + }; + }) storageConfig; +in { + imports = [ + "${fetchTarball "https://github.com/NixOS/nixos-hardware/archive/${nixosHardwareVersion}.tar.gz"}/raspberry-pi/4" + ./network/static-ip.nix + ./services/openssh.nix + #./services/blocky.nix #dns + ./services/unbound.nix #dns + ./services/kea.nix #dhcp + ./services/nginx.nix #reverse proxy + ./services/qbittorrent.nix #torrent + ./services/kiwix.nix #wiki mirror + ./services/kiwix-updater.nix #wiki mirror update + ./users + ./programs + ]; + + fileSystems = fileSystemDefinition; + + networking.hostName = "raspberry"; + + environment.systemPackages = with pkgs; [ + docker-compose + docker-client + podman + podman-compose + ]; + + time.timeZone = timeZone; + + virtualisation.docker = { + enable = true; + autoPrune.enable = true; + daemon.settings = { + "log-driver" = "json-file"; + "log-opts" = { + "max-size" = "10m"; + "max-file" = "3"; + }; + }; + }; + virtualisation.podman = { + enable = true; + defaultNetwork.settings.dns_enabled = false; + }; + + i18n = { + defaultLocale = defaultLocale; + extraLocaleSettings = { + LC_ADDRESS = defaultLocale; + LC_IDENTIFICATION = defaultLocale; + LC_MEASUREMENT = defaultLocale; + LC_MONETARY = defaultLocale; + LC_NAME = defaultLocale; + LC_NUMERIC = defaultLocale; + LC_PAPER = defaultLocale; + LC_TELEPHONE = defaultLocale; + LC_TIME = defaultLocale; + }; + }; + + users = { + mutableUsers = false; + }; + + # Enable passwordless sudo. + security.sudo.extraRules = [ + { + users = ["nudelerde"]; + commands = [ + { + command = "ALL"; + options = ["NOPASSWD"]; + } + ]; + } + ]; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than +5"; # Keep last 5 generations + }; + + # Enable GPU acceleration + hardware.raspberry-pi."4".fkms-3d.enable = true; + + system.stateVersion = "23.11"; +} diff --git a/data/network.nix b/data/network.nix new file mode 100644 index 0000000..86d6a21 --- /dev/null +++ b/data/network.nix @@ -0,0 +1,67 @@ +let + lib = import ; +in +rec { + network = { + subnet = "192.168.2.0/24"; + subnet_base = "192.168.2.0"; + gateway = ips.router; + cidr = 24; + }; + + ips = { + pi = "192.168.2.100"; + desktop = "192.168.2.101"; + router = "192.168.2.1"; + }; + + dhcp = { + pool_start = "192.168.2.50"; + pool_end = "192.168.2.90"; + default_lease = 3600; + max_lease = 86400; + reservations = [{ + ip-address = ips.desktop; + hw-address = "30:9c:23:81:91:ea"; + hostname = "desktop"; + }]; + }; + + fallback_dns_servers = [ + "1.1.1.1" + "8.8.8.8" + ]; + + local_domain = "home"; + + services = { + "pi" = { + ip = ips.pi; + }; + "desktop" = { + ip = ips.desktop; + }; + "torrent" = { + ip = ips.pi; + reverse_proxy = { + port = 8085; + }; + }; + "wiki" = { + ip = ips.pi; + reverse_proxy = { + port = 8086; + }; + }; + "router" = { + ip = ips.router; + }; + }; + + dnsMappings = builtins.listToAttrs (map (name: { + name = "${name}.${local_domain}"; + value = services.${name}.ip; + }) (builtins.attrNames services)); + + reverse_proxy = lib.filterAttrs (name: value: value ? reverse_proxy) services; +} diff --git a/data/services.nix b/data/services.nix new file mode 100644 index 0000000..8af0db2 --- /dev/null +++ b/data/services.nix @@ -0,0 +1,41 @@ +let + lib = import ; + storage_data = import ./storage.nix; +in +rec { + qbittorrent = { + root_dir = "${storage_data.ssd.path}/qbittorrent"; + vpn = { + username = "KNLdup50RYT1911K"; + password = "FQCd6rfszoze0BJGgBhMHa3pIzpUdtyt"; + }; + }; + kiwix = { + root_dir = "${storage_data.ssd.path}/kiwix"; + urls = [ + "https://ftp.fau.de/kiwix/zim/wikipedia/wikipedia_en_all_nopic_2025-08.zim" + "https://download.kiwix.org/zim/wikipedia/wikipedia_de_all_nopic_2026-01.zim" + ]; + }; +} + + + + + + + + + + + + + + + + + + + + + diff --git a/data/ssh.nix b/data/ssh.nix new file mode 100644 index 0000000..dac7959 --- /dev/null +++ b/data/ssh.nix @@ -0,0 +1,37 @@ +let + allKeyDir = "/etc/nixos/ssh_keys"; + readKeyFile = filePath: + let + content = builtins.readFile filePath; + # Split on newlines and filter out empty strings + lines = builtins.filter (line: line != "") ( + builtins.filter builtins.isString ( + builtins.split "\n" content + ) + ); + in lines; + + # Get all keys for a user + getUserKeys = username: + let + userDir = "${allKeyDir}/${username}"; + in + if builtins.pathExists userDir then + let + files = builtins.attrNames (builtins.readDir userDir); + # Read all key files and flatten the list + allKeys = builtins.concatMap (file: + readKeyFile "${userDir}/${file}" + ) files; + in allKeys + else []; + users = builtins.attrNames (builtins.readDir allKeyDir); +in +rec { + keys = builtins.listToAttrs (map (user: { + name = user; + value = getUserKeys user; + }) users); + ssh_users = users; + getKeys = getUserKeys; +} diff --git a/data/storage.nix b/data/storage.nix new file mode 100644 index 0000000..22d7009 --- /dev/null +++ b/data/storage.nix @@ -0,0 +1,14 @@ +rec { + sdcard = { + path = "/"; + type = "ext4"; + source = "/dev/disk/by-label/NIXOS_SD"; + options = ["noatime"]; + }; + ssd = { + path = "/mnt/ssd"; + type = "ext4"; + source = "/dev/disk/by-uuid/a3ffb02e-fe9f-4bce-bd94-af0294ebff8f"; + options = ["noatime"]; + }; +} diff --git a/network/static-ip.nix b/network/static-ip.nix new file mode 100644 index 0000000..e069e23 --- /dev/null +++ b/network/static-ip.nix @@ -0,0 +1,18 @@ +{ config, pkgs, ... }: + +let + net = import ../data/network.nix; +in +{ + # Set a static IP address + networking.interfaces.eth0.ipv4.addresses = [{ + address = net.ips.pi; + prefixLength = net.network.cidr; + }]; + + # Set default gateway (your router's IP) + networking.defaultGateway = net.ips.router; + + # Set DNS servers (fallback when Blocky isn't working) + networking.nameservers = net.fallback_dns_servers; +} diff --git a/programs/default.nix b/programs/default.nix new file mode 100644 index 0000000..70598c9 --- /dev/null +++ b/programs/default.nix @@ -0,0 +1,3 @@ +{...}: { + imports = [./git.nix]; +} diff --git a/programs/git.nix b/programs/git.nix new file mode 100644 index 0000000..64e0afd --- /dev/null +++ b/programs/git.nix @@ -0,0 +1,16 @@ +{ config, pkgs, lib, ... }: +{ + programs.git = { + enable = true; + config = { + user = { + name = "Katharina Heidenreich"; + email = "katharina.heidenreich02@gmail.com"; + }; + init.defaultBranch = "main"; + pull.rebase = false; + core.editor = "nano"; + safe.directory = ["/etc/nixos"]; + }; + }; +} diff --git a/services/README.md b/services/README.md new file mode 100644 index 0000000..f73f254 --- /dev/null +++ b/services/README.md @@ -0,0 +1,13 @@ +# Services + +## List +- DHCP + - Kea +- DNS + - Blocky +- Reverse Proxy + - nginx +- Torrent + - qbittorrent +- Wiki + - kiwix diff --git a/services/blocky.nix b/services/blocky.nix new file mode 100644 index 0000000..970fb4c --- /dev/null +++ b/services/blocky.nix @@ -0,0 +1,61 @@ +{ config, pkgs, ... }: + +let + net = import ../data/network.nix; +in +{ + # Enable Blocky + services.blocky = { + enable = true; + settings = { + # Listen on port 53 (standard DNS port) + ports.dns = 53; + + # Custom DNS entries for your local services + customDNS = { + # This maps your domains to your Pi's IP + mapping = net.dnsMappings; + # mapping = dnsMappings; + }; + + conditional = { + fallbackUpstream = false; + mapping = builtins.mapAttrs (name: value: net.ips.router) net.dnsMappings; + }; + + # Upstream DNS servers (with fallback) + upstreams = { + groups = { + default = + ["https://cloudflare-dns.com/dns-query"] ++ net.fallback_dns_servers; + }; + }; + + # Bootstrap DNS (for initially resolving DoH servers) + bootstrapDns = { + upstream = "https://1.1.1.1/dns-query"; + ips = ["1.1.1.1" "1.0.0.1"]; + }; + + # Enable caching for better performance + caching = { + minTime = "5m"; + maxTime = "30m"; + prefetching = true; + }; + + # blocking = { + # denylists = { + # ads = ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"]; + # }; + # clientGroupsBlock = { + # default = ["ads"]; + # }; + # }; + }; + }; + + # Allow DNS through the firewall + networking.firewall.allowedTCPPorts = [ 53 ]; + networking.firewall.allowedUDPPorts = [ 53 ]; +} diff --git a/services/kea.nix b/services/kea.nix new file mode 100644 index 0000000..634f3be --- /dev/null +++ b/services/kea.nix @@ -0,0 +1,56 @@ +{ config, pkgs, ... }: + +let + net = import ../data/network.nix; +in +{ + services.kea.dhcp4 = { + enable = true; + settings = { + interfaces-config = { + interfaces = [ "eth0" ]; + }; + lease-database = { + name = "/var/lib/kea/dhcp4.leases"; + type = "memfile"; + }; + subnet4 = [{ + id = 1; + subnet = net.network.subnet; + pools = [{ + pool = "${net.dhcp.pool_start} - ${net.dhcp.pool_end}"; + }]; + option-data = [ + { + name = "routers"; + data = net.ips.router; + } + { + name = "domain-name-servers"; + data = builtins.concatStringsSep ", " ([net.ips.pi] ++ net.fallback_dns_servers); + } + { + name = "domain-name"; + data = net.local_domain; + } + { + name = "domain-search"; + data = net.local_domain; + } + ]; + + reservations = net.dhcp.reservations; + }]; + + valid-lifetime = net.dhcp.default_lease; + renew-timer = net.dhcp.default_lease / 2; + rebind-timer = net.dhcp.default_lease * 3 / 4; + }; + }; + + # Firewall rules for DHCP + networking.firewall = { + allowedUDPPorts = [ 67 68 ]; # DHCP ports + checkReversePath = false; # Sometimes needed for DHCP + }; +} diff --git a/services/kiwix-updater.nix b/services/kiwix-updater.nix new file mode 100644 index 0000000..356825c --- /dev/null +++ b/services/kiwix-updater.nix @@ -0,0 +1,87 @@ +{ config, pkgs, lib, ... }: + +let + # Import service data (make sure this path is correct) + service_data = import ../data/services.nix; + kiwix = service_data.kiwix; + zimUrls = kiwix.urls; + updater = pkgs.writeShellScriptBin "kiwix-updater" '' + set -e + + URLS=( + ${builtins.concatStringsSep " " zimUrls} + ) + + download() { + local url=''$1 + local filename=''$(basename "''$url") + local filepath="${kiwix.root_dir}"/''$filename + + if [ -f "''$filepath" ]; then + echo "''$filepath exists!" + return 0 + fi + cd ${kiwix.root_dir} + ${pkgs.wget}/bin/wget --continue --quiet "''$url" -O "''$filename.tmp" + mv ''$filename.tmp ''$filename + } + + build_lib() { + { + echo '' + echo '' + for zim in "${kiwix.root_dir}"/*.zim; do + if [ -f "''$zim" ]; then + filename=''$(basename "''$zim") + size=''$(stat -c%s "''$zim") + case "''$filename" in + *_en_*) + title="English Wikipedia (Text Only)" + lang="eng" + ;; + *_de_*) + title="German Wikipedia (Text Only)" + lang="deu" + ;; + *) + title="''$filename" + lang="unknown" + ;; + esac + cat << EOF + + ''$title + ''$lang + /data/''$filename + ''$size + +EOF + fi + done + echo '' + } > "${kiwix.root_dir}/library.xml.tmp" + mv "${kiwix.root_dir}/library.xml.tmp" "${kiwix.root_dir}/library.xml" + } + + for url in "''${URLS[@]}"; do + download "''$url" + done + + build_lib + + ${pkgs.systemd}/bin/systemctl restart podman-kiwix-serve.service + ''; +in { + + environment.systemPackages = with pkgs; [ + wget + curl + ]; + + services.cron = { + enable = true; + systemCronJobs = [ + "0 3 * * * root ${updater}/bin/kiwix-updater >/dev/null 2>&1" + ]; + }; +} diff --git a/services/kiwix.nix b/services/kiwix.nix new file mode 100644 index 0000000..b6b656a --- /dev/null +++ b/services/kiwix.nix @@ -0,0 +1,36 @@ +{ config, pkgs, lib, ... }: +let + net = import ../data/network.nix; + service_data = import ../data/services.nix; + kiwix = service_data.kiwix; +in { + systemd.tmpfiles.rules = [ + "d ${kiwix.root_dir} 0755 root root - -" + "d ${kiwix.root_dir}/data 0755 root root - -" + ]; + + virtualisation.oci-containers.containers = { + kiwix-serve = { + image = "ghcr.io/kiwix/kiwix-serve:3.8.2"; + ports = ["8086:8080"]; + volumes = ["${kiwix.root_dir}/:/data:ro"]; + cmd = [ + "--monitorLibrary" + "--library" "/data/library.xml" + ]; + environment = { + TZ = "Europe/Berlin"; + }; + extraOptions = [ + "--memory=512m" # Limit container to 512MB RAM + "--memory-swap=512m" # Disable swap usage + "--cpus=1" # Limit to 1 CPU core + ]; + autoStart = true; + }; + }; + + networking.firewall = { + allowedTCPPorts = [8086]; + }; +} diff --git a/services/nginx.nix b/services/nginx.nix new file mode 100644 index 0000000..d91c7ef --- /dev/null +++ b/services/nginx.nix @@ -0,0 +1,47 @@ +{ config, pkgs, lib, ... }: +let + network = import ../data/network.nix; + rproxyServices = builtins.mapAttrs (name: service: { + serverName = "${name}.${network.local_domain}"; + listen = [ {addr = "0.0.0.0"; port = 80;} ]; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${builtins.toString service.reverse_proxy.port}/"; + proxyWebsockets = true; + }; + }; + extraConfig = '' + allow ${network.network.subnet}; + deny all; + ''; + }) network.reverse_proxy; + serviceNamesMessage = builtins.toString (builtins.attrNames network.reverse_proxy); + fallback = { + serverName = "_"; + listen = [ {addr = "0.0.0.0"; port = 80;} ]; + locations."/" = { + return = "404"; + extraConfig = '' + add_header Content-Type text/plain; + ''; + }; + + extraConfig = '' + return 404 "This domain is not configured. Available services: ${serviceNamesMessage}"; + ''; + }; +in { + services.nginx = { + enable = true; + + recommendedProxySettings = true; + recommendedTlsSettings = true; + recommendedOptimisation = true; + recommendedGzipSettings = true; + + virtualHosts = rproxyServices // {fallback = fallback;}; + }; + + # TODO add 443 for https + networking.firewall.allowedTCPPorts = [80]; +} diff --git a/services/openssh.nix b/services/openssh.nix new file mode 100644 index 0000000..66626b6 --- /dev/null +++ b/services/openssh.nix @@ -0,0 +1,17 @@ +#{ config, pkgs, lib, ... }: +let + ssh_data = import ../data/ssh.nix; +in { + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "no"; + AllowUsers = ssh_data.ssh_users; + }; + }; + + users.users = builtins.mapAttrs (username: value: { + openssh.authorizedKeys.keys = ssh_data.keys.${username}; + }) ssh_data.keys; +} diff --git a/services/qbittorrent.nix b/services/qbittorrent.nix new file mode 100644 index 0000000..2ad17bc --- /dev/null +++ b/services/qbittorrent.nix @@ -0,0 +1,92 @@ +{ config, pkgs, lib, ... }: +let + net = import ../data/network.nix; + serviceData = import ../data/services.nix; + qbt = serviceData.qbittorrent; +in { + systemd.tmpfiles.rules = [ + "d ${qbt.root_dir} 0755 root root - -" + "d ${qbt.root_dir}/gluetun 0755 root root - -" + "d ${qbt.root_dir}/downloads 0755 root root - -" + "d ${qbt.root_dir}/config 0755 root root - -" + ]; + + environment.etc."qbittorrent-compose/docker-compose.yml" = { + mode = "0444"; + text = '' +services: + gluetun: + image: docker.io/qmcgaw/gluetun:latest + pull_policy: always + cap_add: + - NET_ADMIN + network_mode: bridge + ports: + - 127.0.0.1:8085:8085 # qBittorrent + devices: + - /dev/net/tun:/dev/net/tun + volumes: + - ${qbt.root_dir}/gluetun/:/gluetun + environment: + - VPN_SERVICE_PROVIDER=protonvpn + - SERVER_HOSTNAME=node-nl-28.protonvpn.net,node-ch-06.protonvpn.net,node-nl-13.protonvpn.net,node-ch-06.protonvpn.net,node-es-04.protonvpn.net + - UPDATER_PERIOD=24h + + - OPENVPN_USER=${qbt.vpn.username} + - OPENVPN_PASSWORD=${qbt.vpn.password} + + - DOT_PROVIDERS=cloudflare,google + - BLOCK_ADS=off + - BLOCK_MALICIOUS=off + - BLOCK_SURVEILLANCE=off + + - TZ=Europe/Berlin + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + pull_policy: always + network_mode: 'service:gluetun' + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Berlin + - WEBUI_PORT=8085 + volumes: + - ${qbt.root_dir}/config/:/config + - ${qbt.root_dir}/downloads/:/downloads + ''; + }; + systemd.services.qbittorrent-stack = { + description = "qbittorrent stack"; + after = ["docker.service" "network.target"]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + WorkingDirectory = qbt.root_dir; + ExecStart = "${pkgs.writeShellScript "torrent-start" '' + set -e + # Copy compose file to working directory + cp /etc/qbittorrent-compose/docker-compose.yml ${qbt.root_dir}/ + cd ${qbt.root_dir} + ${pkgs.docker-compose}/bin/docker-compose up -d + ''}"; + ExecStop = "${pkgs.writeShellScript "torrent-stop" '' + cd ${qbt.root_dir} + ${pkgs.docker-compose}/bin/docker-compose down + ''}"; + ExecReload = "${pkgs.writeShellScript "torrent-reload" '' + cd ${qbt.root_dir} + ${pkgs.docker-compose}/bin/docker-compose restart + ''}"; + + Restart = "on-failure"; + RestartSec = 10; + }; + }; + + networking.firewall = { + allowedTCPPorts = [8085]; + }; +} diff --git a/services/unbound.nix b/services/unbound.nix new file mode 100644 index 0000000..a6879f2 --- /dev/null +++ b/services/unbound.nix @@ -0,0 +1,34 @@ +{ config, pkgs, ... }: + +let + net = import ../data/network.nix; +in +{ + services.unbound = { + enable = true; + settings = { + + server = { + interface = ["0.0.0.0" "::0"]; + + access-control = ["127.0.0.1 allow" "${net.network.subnet} allow"]; + + local-zone = "\"${net.local_domain}.\" static"; + local-data = + (map (name: + let ip = net.dnsMappings.${name}; in + "\"${name}. IN A ${ip}\"" + ) (builtins.attrNames net.dnsMappings)); + }; + + forward-zone = { + name = "."; + forward-addr = net.fallback_dns_servers; + }; + }; + }; + + # Allow DNS through the firewall + networking.firewall.allowedTCPPorts = [ 53 ]; + networking.firewall.allowedUDPPorts = [ 53 ]; +}