feat: initial

This commit is contained in:
Katharina 2026-03-09 22:06:13 +01:00
commit bba9ceff39
18 changed files with 750 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
users/
ssh_keys/
results

108
configuration.nix Normal file
View file

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

67
data/network.nix Normal file
View file

@ -0,0 +1,67 @@
let
lib = import <nixpkgs/lib>;
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;
}

41
data/services.nix Normal file
View file

@ -0,0 +1,41 @@
let
lib = import <nixpkgs/lib>;
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"
];
};
}

37
data/ssh.nix Normal file
View file

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

14
data/storage.nix Normal file
View file

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

18
network/static-ip.nix Normal file
View file

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

3
programs/default.nix Normal file
View file

@ -0,0 +1,3 @@
{...}: {
imports = [./git.nix];
}

16
programs/git.nix Normal file
View file

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

13
services/README.md Normal file
View file

@ -0,0 +1,13 @@
# Services
## List
- DHCP
- Kea
- DNS
- Blocky
- Reverse Proxy
- nginx
- Torrent
- qbittorrent
- Wiki
- kiwix

61
services/blocky.nix Normal file
View file

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

56
services/kea.nix Normal file
View file

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

View file

@ -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 '<?xml version="1.0" encoding="UTF-8"?>'
echo '<library>'
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
<book id="''$filename" path="/data/''$filename" title="''$title" language="''$lang">
<title>''$title</title>
<language>''$lang</language>
<path>/data/''$filename</path>
<size>''$size</size>
</book>
EOF
fi
done
echo '</library>'
} > "${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"
];
};
}

36
services/kiwix.nix Normal file
View file

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

47
services/nginx.nix Normal file
View file

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

17
services/openssh.nix Normal file
View file

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

92
services/qbittorrent.nix Normal file
View file

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

34
services/unbound.nix Normal file
View file

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