Compare commits

...

13 Commits

Author SHA1 Message Date
jory ac2ca3c8be feat(hetzner): add netdata
CI / Flake check (aarch64-linux) (push) Failing after 3h12m58s
CI / Flake check (x86_64-linux) (push) Failing after 3h12m58s
2026-08-24 20:08:43 +02:00
jory 9f7431bb36 feat(virtualcam): add Google OAuth, enable Shkeeper payments, update Caddy rules
CI / Flake check (aarch64-linux) (push) Failing after 3h5m1s
CI / Flake check (x86_64-linux) (push) Failing after 3h5m0s
- Update virtualcam website and API to latest repository revisions.
- Add Google OAuth client ID and secrets decryption for Google auth.
- Enable Shkeeper BTC/USD payment processing (`BYPASS_PAYMENTS=false`) and add service dependencies.
- Update Caddy `admin_gate` IP access rules and remove redundant `admin_gate` import from app proxy.
- Configure weekly automatic Nix garbage collection (`--delete-older-than 14d`) and nix store optimization.
2026-08-22 16:52:08 +02:00
jory b80316267f feat(hetzner): update virtualcam with bypassing payments 2026-08-22 14:46:22 +02:00
jory e64c3d1310 feat(hetzner): fix SMTP on virtualcam website and rm opencode
CI / Flake check (aarch64-linux) (push) Failing after 1m41s
CI / Flake check (x86_64-linux) (push) Failing after 2m41s
2026-08-17 05:29:08 +02:00
jory 75be43140f feat(hetzner): give stalwart read access to certain keys 2026-08-17 05:26:39 +02:00
jory 38bd5b63f5 feat(hetzner): update password cause i'm stupid and didnt save
CI / Flake check (x86_64-linux) (push) Failing after 2m13s
CI / Flake check (aarch64-linux) (push) Failing after 3h10m50s
2026-08-15 17:07:30 +02:00
jory 31de8eccb0 feat(hetzner): full migration of docker-mailserver to stalwart
CI / Flake check (aarch64-linux) (push) Failing after 3h12m52s
CI / Flake check (x86_64-linux) (push) Failing after 1h2m1s
2026-08-15 15:59:27 +02:00
jory cb8835bbfb feat(hetzner): add my api and update website
CI / Flake check (aarch64-linux) (push) Failing after 3h12m24s
CI / Flake check (x86_64-linux) (push) Failing after 3h12m23s
2026-08-14 21:34:50 +02:00
jory ea79663c96 feat(hetzner): Updated wg-easy to newer version
CI / Flake check (aarch64-linux) (push) Successful in 4m24s
CI / Flake check (x86_64-linux) (push) Successful in 2m3s
2026-08-14 16:10:06 +02:00
jory 2e9ea868b6 feat(hetzner): add restricted release hosting and update CI
CI / Flake check (aarch64-linux) (push) Successful in 5m20s
CI / Flake check (x86_64-linux) (push) Successful in 3m6s
2026-08-11 15:38:56 +02:00
jory f806506d9a feat(hetzner): add Stalwart, restic backups, and virtualcam 2026-08-11 15:36:40 +02:00
jory 62c70dab19 chore: improve code formatting and configuration across multiple files
CI / Flake check (aarch64-linux) (push) Failing after 17m27s
CI / Flake check (x86_64-linux) (push) Failing after 53m5s
CI / Pre-commit checks (x86_64-linux) (push) Successful in 11s
The diff shows comprehensive code cleanup and formatting improvements across 18 files, including cleaner argument structures, additional package configurations, and improved formatting in the `home/terminal/software/git.nix` hook script.
2026-07-20 06:52:25 +02:00
jory 4ee5658053 chore(coredns): wait for network connectivity to improve service reliability
CI / Pre-commit checks (x86_64-linux) (push) Failing after 14s
CI / Flake check (aarch64-linux) (push) Failing after 11m38s
CI / Flake check (x86_64-linux) (push) Failing after 13m22s
Added network-online.target to after and wants arrays to ensure CoreDNS service starts only after network is available. This prevents service failures due to missing DNS resolution during system boot.
2026-07-19 11:35:20 +02:00
34 changed files with 1263 additions and 295 deletions
+7 -25
View File
@@ -13,10 +13,13 @@
# PATH before invoking `nix`. This uses the host's actual Lix rather than installing a # PATH before invoking `nix`. This uses the host's actual Lix rather than installing a
# second Nix client that would mismatch the running Lix daemon. # second Nix client that would mismatch the running Lix daemon.
# * Gitea context vars (gitea.workflow / gitea.head_ref / gitea.sha) for concurrency. # * Gitea context vars (gitea.workflow / gitea.head_ref / gitea.sha) for concurrency.
# * matrix over x86_64-linux + aarch64-linux for flake-check (--no-build, eval-only). # * matrix over x86_64-linux + aarch64-linux.
# Pre-commit checks run on x86_64-linux only: building aarch64 derivations needs # All jobs use --no-build (eval-only) because the runner has 2 cores / 4GB RAM:
# QEMU binfmt (registered via boot.binfmt.emulatedSystems) + nix extra-platforms, # building nix derivations would exhaust memory and crash the server.
# which require a nixos-rebuild switch that hasn't been applied yet. # Pre-commit hooks run locally via git-hooks-nix on the developer's machine.
# * aarch64 builds need QEMU binfmt (boot.binfmt.emulatedSystems) + nix extra-platforms,
# which require a nixos-rebuild switch not yet applied. Flake-check for aarch64
# runs eval-only (no build) and passes.
name: CI name: CI
@@ -61,24 +64,3 @@ jobs:
run: | run: |
export PATH=/run/current-system/sw/bin:$PATH export PATH=/run/current-system/sw/bin:$PATH
nix flake check --no-build --system ${{ matrix.system }} nix flake check --no-build --system ${{ matrix.system }}
pre-commit:
name: Pre-commit checks (${{ matrix.system }})
runs-on: native
strategy:
fail-fast: false
matrix:
# aarch64-linux omitted: building aarch64 derivations needs QEMU binfmt +
# extra-platforms; system hasn't been rebuilt to apply them yet.
system:
- x86_64-linux
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Pre-commit checks (${{ matrix.system }})
run: |
export PATH=/run/current-system/sw/bin:$PATH
nix build .#checks.${{ matrix.system }}.pre-commit
Generated
+21
View File
@@ -308,11 +308,32 @@
"nixos-24-05": "nixos-24-05", "nixos-24-05": "nixos-24-05",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable", "nixpkgs-unstable": "nixpkgs-unstable",
"rustlock": "rustlock",
"sops-nix": "sops-nix", "sops-nix": "sops-nix",
"systems": "systems_3", "systems": "systems_3",
"zen-browser": "zen-browser" "zen-browser": "zen-browser"
} }
}, },
"rustlock": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1782663473,
"narHash": "sha256-wPpiuL3EVvNb3+9QgRGtopZXbebb9uUs3Slwuigvh8w=",
"owner": "JorySeverijnse",
"repo": "rustlock",
"rev": "76039750623825172fbee93adae8bb48d4ad36ad",
"type": "github"
},
"original": {
"owner": "JorySeverijnse",
"repo": "rustlock",
"type": "github"
}
},
"sops-nix": { "sops-nix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
+5
View File
@@ -104,6 +104,11 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
rustlock = {
url = "github:JorySeverijnse/rustlock";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-index-db = { nix-index-db = {
url = "github:Mic92/nix-index-database"; url = "github:Mic92/nix-index-database";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
+1 -1
View File
@@ -43,7 +43,7 @@
"Mod+B".spawn._args = ["zen"]; "Mod+B".spawn._args = ["zen"];
"Mod+Return".spawn._args = ["alacritty"]; "Mod+Return".spawn._args = ["alacritty"];
"Mod+Q".close-window = {}; "Mod+Q".close-window = {};
"Mod+Shift+P".spawn._args = ["rofi-powermenu"]; "Mod+Shift+P".spawn._args = ["rustlock" "--screenshots" "--clock" "--effect-blur" "7x5" "--effect-vignette" "0.5:0.5"];
"Mod+S".switch-preset-column-width = {}; "Mod+S".switch-preset-column-width = {};
"Mod+F".fullscreen-window = {}; "Mod+F".fullscreen-window = {};
"Mod+1".set-column-width = "25%"; "Mod+1".set-column-width = "25%";
+22 -22
View File
@@ -10,34 +10,34 @@
# Hook that auto-generates a Conventional Commit message from staged diff # Hook that auto-generates a Conventional Commit message from staged diff
prepareCommitMsg = pkgs.writeShellScript "prepare-commit-msg" '' prepareCommitMsg = pkgs.writeShellScript "prepare-commit-msg" ''
# Only run if there are staged changes # Only run if there are staged changes
if git diff --cached --quiet 2>/dev/null; then if git diff --cached --quiet 2>/dev/null; then
exit 0 exit 0
fi fi
echo "Generating commit message from staged changes..." >&2 echo "Generating commit message from staged changes..." >&2
# Generate Conventional Commit message using opencode run # Generate Conventional Commit message using opencode run
git diff --cached | timeout 30 ${pkgs.opencode}/bin/opencode run \ git diff --cached | timeout 30 ${pkgs.opencode}/bin/opencode run \
-m opencode/north-mini-code-free \ -m opencode/north-mini-code-free \
"You are an expert software engineer writing professional Git commit messages. "You are an expert software engineer writing professional Git commit messages.
Create a clean Conventional Commit for the provided diff. Create a clean Conventional Commit for the provided diff.
Rules: Rules:
- Format: type(optional scope): imperative description - Format: type(optional scope): imperative description
- Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore - Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- First line: maximum 72 characters, starts with capital letter, imperative present tense - First line: maximum 72 characters, starts with capital letter, imperative present tense
- If relevant, add a blank line followed by a short body explaining the motivation and key changes - If relevant, add a blank line followed by a short body explaining the motivation and key changes
- Be concise and professional. No emojis, no markdown. - Be concise and professional. No emojis, no markdown.
Output ONLY the commit message. Do not add any extra text, quotes, or explanations." \ Output ONLY the commit message. Do not add any extra text, quotes, or explanations." \
2>/dev/null > "$1" 2>/dev/null > "$1"
# Fallback if generation failed or timed out # Fallback if generation failed or timed out
if [ ! -s "$1" ]; then if [ ! -s "$1" ]; then
echo "chore: auto-generated commit message" > "$1" echo "chore: auto-generated commit message" > "$1"
fi fi
''; '';
in { in {
users.users.someone.packages = with pkgs; [ users.users.someone.packages = with pkgs; [
+27 -6
View File
@@ -1,8 +1,8 @@
{ {
config, config,
inputs,
lib, lib,
pkgs, pkgs,
inputs,
... ...
}: { }: {
imports = [ imports = [
@@ -172,19 +172,40 @@
sops = { sops = {
defaultSopsFile = ./../../secrets/secrets.yaml; defaultSopsFile = ./../../secrets/secrets.yaml;
secrets = { secrets = {
gitea_laptop = { owner = "someone"; group = "users"; mode = "0400"; }; gitea_laptop = {
github_laptop = { owner = "someone"; group = "users"; mode = "0400"; }; owner = "someone";
hetzner_server = { owner = "someone"; group = "users"; mode = "0400"; }; group = "users";
mode = "0400";
};
github_laptop = {
owner = "someone";
group = "users";
mode = "0400";
};
hetzner_server = {
owner = "someone";
group = "users";
mode = "0400";
};
}; };
}; };
nixpkgs.config.allowUnfree = true; nixpkgs.config.allowUnfree = true;
nixpkgs.overlays = [ nixpkgs.overlays = [
(self: super: { (_self: super: {
cisco-secure-client = super.callPackage ../../pkgs/cisco-secure-client {}; cisco-secure-client = super.callPackage ../../pkgs/cisco-secure-client {};
}) })
]; ];
environment.systemPackages = [pkgs.cryptsetup pkgs.age pkgs.nixd pkgs.apparmor-parser pkgs.xdg-desktop-portal-wlr]; security.pam.services.rustlock.text = "auth include login";
environment.systemPackages = with pkgs; [
cryptsetup
age
nixd
apparmor-parser
xdg-desktop-portal-wlr
inputs.rustlock.packages.${pkgs.stdenv.hostPlatform.system}.default
];
} }
+2 -3
View File
@@ -5,7 +5,6 @@
}: let }: let
# shorten paths # shorten paths
inherit (inputs.nixpkgs.lib) nixosSystem; inherit (inputs.nixpkgs.lib) nixosSystem;
lib = inputs.nixpkgs.lib;
# Server uses its own pinned 24.05 nixpkgs (kept isolated from the laptop's unstable) # Server uses its own pinned 24.05 nixpkgs (kept isolated from the laptop's unstable)
nixosSystem24 = inputs.nixos-24-05.lib.nixosSystem; nixosSystem24 = inputs.nixos-24-05.lib.nixosSystem;
unstablePkgs = import inputs.nixpkgs-unstable {system = "x86_64-linux";}; unstablePkgs = import inputs.nixpkgs-unstable {system = "x86_64-linux";};
@@ -60,7 +59,7 @@ in {
++ sharedModules ++ sharedModules
++ [ ++ [
"${mod}/services/location.nix" "${mod}/services/location.nix"
({ config, ... }: { ({...}: {
boot.loader.limine.bootMode = "uefi"; boot.loader.limine.bootMode = "uefi";
}) })
]; ];
@@ -74,7 +73,7 @@ in {
++ sharedModules ++ sharedModules
++ [ ++ [
"${mod}/services/location.nix" "${mod}/services/location.nix"
({ config, ... }: { ({...}: {
boot.loader.limine.bootMode = "bios"; boot.loader.limine.bootMode = "bios";
boot.loader.limine.biosDevice = "/dev/nvme0n1"; boot.loader.limine.biosDevice = "/dev/nvme0n1";
}) })
+139 -116
View File
@@ -1,8 +1,18 @@
{ lib, stdenv, fetchurl, dpkg, makeWrapper, patchelf {
, coreutils, systemd, glib, zlib, xz, curl, libxml2 lib,
}: stdenv,
fetchurl,
let dpkg,
makeWrapper,
patchelf,
coreutils,
systemd,
glib,
zlib,
xz,
curl,
libxml2,
}: let
version = "5.1.17.3394"; version = "5.1.17.3394";
# libxml2's "out" output has lib/libxml2.so.16 # libxml2's "out" output has lib/libxml2.so.16
libxml2_out = libxml2.out; libxml2_out = libxml2.out;
@@ -10,117 +20,130 @@ let
# System library RPATH for all bundled ELF binaries (cisco's own lib dir is # System library RPATH for all bundled ELF binaries (cisco's own lib dir is
# appended during fixupPhase via $out) # appended during fixupPhase via $out)
sysRpath = lib.makeLibraryPath [ sysRpath = lib.makeLibraryPath [
systemd glib zlib xz stdenv.cc.cc.lib curl libxml2_out systemd
glib
zlib
xz
stdenv.cc.cc.lib
curl
libxml2_out
]; ];
in stdenv.mkDerivation { in
pname = "cisco-secure-client"; stdenv.mkDerivation {
inherit version; pname = "cisco-secure-client";
inherit version;
src = fetchurl { src = fetchurl {
url = "https://archive.org/download/cisco-secure-client-linux64-${version}/cisco-secure-client-linux64-${version}-predeploy-deb-k9.tgz"; url = "https://archive.org/download/cisco-secure-client-linux64-${version}/cisco-secure-client-linux64-${version}-predeploy-deb-k9.tgz";
sha256 = "5c4cafb4694e64cbf041481f5df3d70389399926f8aa2a469d480c0555b58c2c"; sha256 = "5c4cafb4694e64cbf041481f5df3d70389399926f8aa2a469d480c0555b58c2c";
};
nativeBuildInputs = [ dpkg makeWrapper patchelf ];
buildInputs = [
systemd glib zlib xz stdenv.cc.cc.lib curl libxml2_out
];
dontStrip = true;
dontAutoPatchelf = true;
unpackPhase = ''
tar xzf "$src"
DEB_FILE=$(ls cisco-secure-client-vpn-cli_*_amd64.deb 2>/dev/null || true)
if [ -z "$DEB_FILE" ]; then
DEB_FILE=$(ls cisco-secure-client-vpn_*_amd64.deb 2>/dev/null || true)
fi
if [ -z "$DEB_FILE" ]; then
echo "ERROR: No .deb file found"
exit 1
fi
dpkg-deb -x "$DEB_FILE" .
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
cp -r opt/* "$out/"
runHook postInstall
'';
# fixupPhase: patchelf corrupts Cisco's embedded code signatures on ALL
# shipped ELF binaries and .so plugins. We skip patchelf entirely and
# instead rely on LD_LIBRARY_PATH wrappers for system library resolution.
# The binaries' original RPATH of /opt/cisco/secureclient/lib resolves
# correctly at runtime via the /opt/cisco/secureclient -> store symlink.
fixupPhase = ''
runHook preFixup
# Fix absolute symlinks the deb assumes install under /opt/cisco/secureclient/
# but nix puts it in the store. Convert to relative symlinks.
for link in $(find "$out" -type l); do
target=$(readlink "$link")
if echo "$target" | grep -q "^/opt/"; then
rel=$(basename "$target")
ln -sf "$rel" "$link"
fi
done
for f in "$out"/cisco/secureclient/bin/* "$out"/cisco/secureclient/lib/*.so*; do
chmod +x "$f" 2>/dev/null || true
done
# Create libxml2.so.2 symlink (SONAME mismatch: Cisco wants .2, nixpkgs provides .16)
libxml2_so=$(find ${libxml2_out}/lib -name "libxml2.so.16*" 2>/dev/null | head -1)
if [ -n "$libxml2_so" ]; then
ln -sf "$libxml2_so" "$out/cisco/secureclient/lib/libxml2.so.2"
fi
# NO patchelf on Cisco ELFs their embedded code signatures are
# verified at runtime (especially plugins loaded by vpnagentd).
# All ELFs keep their original RPATH /opt/cisco/secureclient/lib
# which resolves via the tmpfiles symlink.
# System libs are provided via LD_LIBRARY_PATH in wrappers below.
ldPath="$out/cisco/secureclient/lib:${sysRpath}"
# Wrap vpn CLI wrapProgram renames the original to .vpn-wrapped
wrapProgram "$out/cisco/secureclient/bin/vpn" \
--prefix LD_LIBRARY_PATH : "$ldPath" \
--prefix PATH : ${lib.makeBinPath [ coreutils ]}
# Create $out/bin/ wrappers for all user-facing executables
mkdir -p "$out/bin"
makeWrapper "$out/cisco/secureclient/bin/vpn" "$out/bin/vpn" \
--prefix LD_LIBRARY_PATH : "$ldPath" \
--prefix PATH : ${lib.makeBinPath [ coreutils ]}
makeWrapper "$out/cisco/secureclient/bin/vpnagentd" "$out/bin/vpnagentd" \
--prefix LD_LIBRARY_PATH : "$ldPath" \
--prefix PATH : ${lib.makeBinPath [ coreutils ]}
for cli in acinstallhelper manifesttool_vpn vpndownloader-cli; do
if [ -f "$out/cisco/secureclient/bin/$cli" ]; then
makeWrapper "$out/cisco/secureclient/bin/$cli" "$out/bin/$cli" \
--prefix LD_LIBRARY_PATH : "$ldPath"
fi
done
runHook postFixup
'';
meta = with lib; {
description = "Cisco Secure Client (AnyConnect successor) VPN client";
homepage = "https://www.cisco.com/site/us/en/products/security/secure-client/index.html";
sourceProvenance = with sourceTypes; [ binaryNativeCode ];
license = {
name = "Cisco Secure Client EULA proprietary, not redistributable";
url = "https://www.cisco.com/c/en/us/products/security/secure-client/eula.html";
}; };
platforms = [ "x86_64-linux" ];
maintainers = [ ]; nativeBuildInputs = [dpkg makeWrapper patchelf];
};
} buildInputs = [
systemd
glib
zlib
xz
stdenv.cc.cc.lib
curl
libxml2_out
];
dontStrip = true;
dontAutoPatchelf = true;
unpackPhase = ''
tar xzf "$src"
DEB_FILE=$(ls cisco-secure-client-vpn-cli_*_amd64.deb 2>/dev/null || true)
if [ -z "$DEB_FILE" ]; then
DEB_FILE=$(ls cisco-secure-client-vpn_*_amd64.deb 2>/dev/null || true)
fi
if [ -z "$DEB_FILE" ]; then
echo "ERROR: No .deb file found"
exit 1
fi
dpkg-deb -x "$DEB_FILE" .
'';
installPhase = ''
runHook preInstall
mkdir -p "$out"
cp -r opt/* "$out/"
runHook postInstall
'';
# fixupPhase: patchelf corrupts Cisco's embedded code signatures on ALL
# shipped ELF binaries and .so plugins. We skip patchelf entirely and
# instead rely on LD_LIBRARY_PATH wrappers for system library resolution.
# The binaries' original RPATH of /opt/cisco/secureclient/lib resolves
# correctly at runtime via the /opt/cisco/secureclient -> store symlink.
fixupPhase = ''
runHook preFixup
# Fix absolute symlinks the deb assumes install under /opt/cisco/secureclient/
# but nix puts it in the store. Convert to relative symlinks.
for link in $(find "$out" -type l); do
target=$(readlink "$link")
if echo "$target" | grep -q "^/opt/"; then
rel=$(basename "$target")
ln -sf "$rel" "$link"
fi
done
for f in "$out"/cisco/secureclient/bin/* "$out"/cisco/secureclient/lib/*.so*; do
chmod +x "$f" 2>/dev/null || true
done
# Create libxml2.so.2 symlink (SONAME mismatch: Cisco wants .2, nixpkgs provides .16)
libxml2_so=$(find ${libxml2_out}/lib -name "libxml2.so.16*" 2>/dev/null | head -1)
if [ -n "$libxml2_so" ]; then
ln -sf "$libxml2_so" "$out/cisco/secureclient/lib/libxml2.so.2"
fi
# NO patchelf on Cisco ELFs their embedded code signatures are
# verified at runtime (especially plugins loaded by vpnagentd).
# All ELFs keep their original RPATH /opt/cisco/secureclient/lib
# which resolves via the tmpfiles symlink.
# System libs are provided via LD_LIBRARY_PATH in wrappers below.
ldPath="$out/cisco/secureclient/lib:${sysRpath}"
# Wrap vpn CLI wrapProgram renames the original to .vpn-wrapped
wrapProgram "$out/cisco/secureclient/bin/vpn" \
--prefix LD_LIBRARY_PATH : "$ldPath" \
--prefix PATH : ${lib.makeBinPath [coreutils]}
# Create $out/bin/ wrappers for all user-facing executables
mkdir -p "$out/bin"
makeWrapper "$out/cisco/secureclient/bin/vpn" "$out/bin/vpn" \
--prefix LD_LIBRARY_PATH : "$ldPath" \
--prefix PATH : ${lib.makeBinPath [coreutils]}
makeWrapper "$out/cisco/secureclient/bin/vpnagentd" "$out/bin/vpnagentd" \
--prefix LD_LIBRARY_PATH : "$ldPath" \
--prefix PATH : ${lib.makeBinPath [coreutils]}
for cli in acinstallhelper manifesttool_vpn vpndownloader-cli; do
if [ -f "$out/cisco/secureclient/bin/$cli" ]; then
makeWrapper "$out/cisco/secureclient/bin/$cli" "$out/bin/$cli" \
--prefix LD_LIBRARY_PATH : "$ldPath"
fi
done
runHook postFixup
'';
meta = with lib; {
description = "Cisco Secure Client (AnyConnect successor) VPN client";
homepage = "https://www.cisco.com/site/us/en/products/security/secure-client/index.html";
sourceProvenance = with sourceTypes; [binaryNativeCode];
license = {
name = "Cisco Secure Client EULA proprietary, not redistributable";
url = "https://www.cisco.com/c/en/us/products/security/secure-client/eula.html";
};
platforms = ["x86_64-linux"];
maintainers = [];
};
}
Symlink
+1
View File
@@ -0,0 +1 @@
/nix/store/p2h0fr7k47yrx3x0qkr3rwsp5nf5bj30-nixos-system-debian-4gb-fsn1-1-24.05.20241230.b134951
+9 -2
View File
@@ -12,15 +12,22 @@
../../modules/services/wireguard.nix ../../modules/services/wireguard.nix
../../modules/services/fail2ban.nix ../../modules/services/fail2ban.nix
../../modules/services/vaultwarden.nix ../../modules/services/vaultwarden.nix
../../modules/services/mailserver.nix
../../modules/services/snappymail.nix
../../modules/services/rustdesk.nix ../../modules/services/rustdesk.nix
../../modules/services/wrxproxy.nix ../../modules/services/wrxproxy.nix
../../modules/services/watchtower.nix ../../modules/services/watchtower.nix
../../modules/services/tlsa-updater.nix ../../modules/services/tlsa-updater.nix
../../modules/services/shkeeper.nix ../../modules/services/shkeeper.nix
../../modules/services/backup.nix ../../modules/services/backup.nix
../../modules/services/stalwart.nix
../../modules/services/virtualcam.nix
../../modules/services/netdata.nix
]; ];
# Only 4GB RAM — limit nix builds to one core at a time to avoid OOM
nix.settings = {
cores = 1;
max-jobs = 1;
};
system.stateVersion = "24.05"; system.stateVersion = "24.05";
} }
+1 -1
View File
@@ -2,7 +2,7 @@
# This is the authoritative DNS zone served by CoreDNS # This is the authoritative DNS zone served by CoreDNS
# Update serial number on changes # Update serial number on changes
{ {
serial = "2026071003"; serial = "2026071004";
adminEmail = "abuse.severijnse.eu"; adminEmail = "abuse.severijnse.eu";
nameservers = ["ns1.severijnse.eu" "ns2.severijnse.eu"]; nameservers = ["ns1.severijnse.eu" "ns2.severijnse.eu"];
ipv4 = "49.13.92.205"; ipv4 = "49.13.92.205";
+1 -1
View File
@@ -17,7 +17,7 @@
swapDevices = [ swapDevices = [
{ {
device = "/swap"; device = "/swap";
size = 2048; size = 4096;
} }
]; ];
+78 -26
View File
@@ -1,41 +1,93 @@
{pkgs, ...}: let {
backupScript = pkgs.writeShellScript "weekly-backup" '' pkgs,
BACKUP_DIR="/home/admin/backups" lib,
SRC="/home/admin" ...
DATE=$(date +%Y-%m-%dT%H-%M-%S) }: let
FILENAME="weekly-backup-$DATE.tar.gz" # sops-encrypted secrets (single file holds all service secrets).
secretsFile = ../../secrets/secrets.yaml;
# Root-only runtime files restic reads from (0600 root).
runtimeDir = "/var/lib/restic";
passwordFile = "/var/lib/restic/.password";
environmentFile = "/var/lib/restic/environment";
# Backblaze B2 backend, per restic docs: b2:bucketname.
repo = "b2:hetzner-severijnse";
mkdir -p "$BACKUP_DIR" # Materialize the restic password and B2 credentials from sops into
# Backup everything under /home/admin EXCEPT: # root-only files, so secrets are never world-readable in the Nix store.
# - The backups dir itself (infinite loop) writeSecrets = pkgs.writeShellScript "restic-write-secrets" ''
# - DMS mail data (GBs of email, backed up separately) set -euo pipefail
# - NixOS-managed service data (at their own paths below) mkdir -p ${runtimeDir}
tar czf "$BACKUP_DIR/$FILENAME" \ ${pkgs.sops}/bin/sops \
--exclude="$BACKUP_DIR" \ --decrypt --extract '["restic_password"]' \
--exclude="/home/admin/backups" \ --input-type yaml --output-type yaml ${secretsFile} \
--exclude="/home/admin/dms/mail-data" \ | tr -d '\n' > "${passwordFile}"
--exclude="/home/admin/dms/mail-state" \ chmod 0600 "${passwordFile}"
"$SRC"
# Prune backups older than 14 days : > "${environmentFile}"
find "$BACKUP_DIR" -name "weekly-backup-*" -mtime +14 -delete chmod 0600 "${environmentFile}"
${pkgs.sops}/bin/sops --decrypt --input-type yaml --output-type yaml ${secretsFile} |
${pkgs.gnused}/bin/sed -nE \
's/^b2_key_id: (.*)/B2_ACCOUNT_ID=\1/p; s/^b2_application_key: (.*)/B2_ACCOUNT_KEY=\1/p' \
>> "${environmentFile}"
''; '';
in { in {
systemd.services.weekly-backup = { systemd.services.restic-password = {
description = "Weekly backup of home directory"; description = "Materialize restic repository password and B2 credentials from sops";
path = with pkgs; [coreutils gnutar findutils]; wantedBy = ["multi-user.target"];
# The age key lives in /etc/age/keys.txt; the service must know where it is
# and needs a HOME for age to report its user config directory.
environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt";
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = "${backupScript}"; Environment = ["HOME=/root"];
User = "root"; ExecStart = "${writeSecrets}";
}; };
}; };
systemd.timers.weekly-backup = { # B2 credentials are supplied via environmentFile (B2_ACCOUNT_ID / B2_ACCOUNT_KEY),
wantedBy = ["timers.target"]; # matching the official module example which combines `repository` and `environmentFile`.
services.restic.backups.localbackup = {
repository = "b2:hetzner-severijnse";
environmentFile = environmentFile;
passwordFile = passwordFile;
initialize = true;
paths = [
"/home/admin"
"/var/lib/postgresql"
"/var/lib/gitea"
"/var/lib/caddy"
"/var/lib/virtualcam"
"/var/lib/stalwart"
"/var/lib/bulwark"
"/var/lib/coredns"
"/etc/nixos"
];
exclude = [
"/home/admin/backups"
"/home/admin/.opencode"
"/home/admin/.local"
"/home/admin/.npm"
"/home/admin/.config"
"*.log"
"*.log.*"
"**/.cache"
];
timerConfig = { timerConfig = {
OnCalendar = "Mon *-*-* 03:00:00"; OnCalendar = "Mon *-*-* 03:00:00";
Persistent = true; Persistent = true;
RandomizedDelaySec = "15m";
}; };
pruneOpts = [
"--keep-daily 7"
"--keep-weekly 4"
"--keep-monthly 6"
];
runCheck = true;
};
# The backup must never run before the secrets exist.
systemd.services."restic-backups-localbackup" = {
requires = ["restic-password.service"];
after = ["restic-password.service"];
}; };
} }
+74 -10
View File
@@ -1,4 +1,4 @@
{...}: let {unstablePkgs, ...}: let
antiScrape = '' antiScrape = ''
@bad_bot { @bad_bot {
header_regexp User-Agent "(?i)(scrapy|cpython-requests|python-requests|curl|wget|go-http-client|ltx71|petalbot|bytespider|dotbot|ahrefsbot|semrushbot|mj12bot|dataforseo|facebookexternalhit|claudebot|anthropic-ai|perplexity|gptbot|chatgpt-user|omnisci|imgproxy|ccbot|exabot|360spider|baiduspider|sogou|duckduckgo|amazonbot|cohere-ai|diffbot|imagesiftbot).*" header_regexp User-Agent "(?i)(scrapy|cpython-requests|python-requests|curl|wget|go-http-client|ltx71|petalbot|bytespider|dotbot|ahrefsbot|semrushbot|mj12bot|dataforseo|facebookexternalhit|claudebot|anthropic-ai|perplexity|gptbot|chatgpt-user|omnisci|imgproxy|ccbot|exabot|360spider|baiduspider|sogou|duckduckgo|amazonbot|cohere-ai|diffbot|imagesiftbot).*"
@@ -12,6 +12,7 @@ in {
services.caddy = { services.caddy = {
enable = true; enable = true;
group = "caddy"; group = "caddy";
package = unstablePkgs.caddy;
dataDir = "/var/lib/caddy"; dataDir = "/var/lib/caddy";
logDir = "/var/log/caddy"; logDir = "/var/log/caddy";
globalConfig = '' globalConfig = ''
@@ -23,9 +24,16 @@ in {
# Global Caddyfile snippets (shared across all virtual hosts). # Global Caddyfile snippets (shared across all virtual hosts).
extraConfig = '' extraConfig = ''
(admin_gate) { (admin_gate) {
@notvpn not remote_ip 10.8.0.0/24 @notvpn not remote_ip 127.0.0.1 ::1 10.8.0.0/24 fd10:8::/64 49.13.92.205 2a01:4f8:c014:2585::1
respond @notvpn "Forbidden" 403 respond @notvpn "Forbidden" 403
} }
# Restrict access to the server itself (loopback + public IPs) or the
# WireGuard VPN. Gitea reaches this from the server when publishing a
# release, and VPN clients can download from it; the public cannot.
(release_gate) {
@deny not remote_ip 127.0.0.1 ::1 10.8.0.0/24 fd10:8::/64 49.13.92.205 2a01:4f8:c014:2585::1
respond @deny "Forbidden" 403
}
# Security headers applied to every response of every site that # Security headers applied to every response of every site that
# imports this snippet (covers all current and future hosts). # imports this snippet (covers all current and future hosts).
# For proxied hosts whose upstream sets its own copies, strip them # For proxied hosts whose upstream sets its own copies, strip them
@@ -133,7 +141,6 @@ in {
import security_headers import security_headers
import csp import csp
${antiScrape} ${antiScrape}
import admin_gate
header Strict-Transport-Security "max-age=31536000;" header Strict-Transport-Security "max-age=31536000;"
reverse_proxy 127.0.0.1:1001 reverse_proxy 127.0.0.1:1001
encode zstd gzip encode zstd gzip
@@ -154,13 +161,33 @@ in {
extraConfig = '' extraConfig = ''
import security_headers import security_headers
${antiScrape} ${antiScrape}
reverse_proxy 127.0.0.1:8888 { # Stalwart JMAP API (http-management listener on 8080): required so the
# Strip copies set by the upstream SnappyMail container so we # browser can reach /.well-known/jmap, /jmap/session and the JMAP
# emit exactly one correct value of each security header. # upload/download/eventsource/ws paths at the public origin.
header_down -X-Frame-Options handle /.well-known/jmap {
header_down -X-XSS-Protection reverse_proxy 127.0.0.1:8080
header_down -X-Content-Type-Options
} }
# NOTE: use handle (not handle_path) so the /jmap prefix is preserved.
handle /jmap {
reverse_proxy 127.0.0.1:8080
}
handle /jmap/* {
reverse_proxy 127.0.0.1:8080
}
handle {
# Bulwark webmail (JMAP client for Stalwart), running on host port 3002.
reverse_proxy 127.0.0.1:3002
}
encode zstd gzip
'';
};
"admin.mail.severijnse.eu" = {
extraConfig = ''
import security_headers
${antiScrape}
# Stalwart webadmin UI (served by the http-management listener on 8080).
reverse_proxy 127.0.0.1:8080
encode zstd gzip encode zstd gzip
''; '';
}; };
@@ -218,12 +245,48 @@ in {
import security_headers import security_headers
import csp import csp
${antiScrape} ${antiScrape}
import admin_gate
reverse_proxy 127.0.0.1:5000 reverse_proxy 127.0.0.1:5000
encode zstd gzip encode zstd gzip
''; '';
}; };
"virtualcam.severijnse.eu" = {
extraConfig = ''
import security_headers
import csp
${antiScrape}
basic_auth {
chan $2a$14$7ZeNpGT0L68uZwzdWBcF0OulzhrYbfAs232Ojt//LHQ1qvXU4x32O
}
reverse_proxy 127.0.0.1:3001
encode zstd gzip
'';
};
"api.severijnse.eu" = {
extraConfig = ''
import security_headers
# Machine-facing license API for the Android app: no antiScrape
# bot-blocking (it would drop legitimate non-browser clients) and no
# admin_gate (endpoints are protected by the bearer token).
reverse_proxy 127.0.0.1:3004
encode zstd gzip
'';
};
"releases.severijnse.eu" = {
extraConfig = ''
import security_headers
root * /srv/releases
file_server browse
encode zstd gzip
handle /private* {
import release_gate
}
'';
};
"http://ip.severijnse.eu" = { "http://ip.severijnse.eu" = {
extraConfig = '' extraConfig = ''
import security_headers import security_headers
@@ -238,5 +301,6 @@ in {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /srv 0755 caddy caddy -" "d /srv 0755 caddy caddy -"
"d /srv/jory 0755 caddy caddy -" "d /srv/jory 0755 caddy caddy -"
"d /srv/releases 0755 admin caddy -"
]; ];
} }
+6 -3
View File
@@ -2,7 +2,7 @@
zoneFile = pkgs.writeText "severijnse.eu.db" '' zoneFile = pkgs.writeText "severijnse.eu.db" ''
$ORIGIN severijnse.eu. $ORIGIN severijnse.eu.
$TTL 3600 $TTL 3600
severijnse.eu. 3600 IN SOA ns1.severijnse.eu. abuse.severijnse.eu. 2026071003 3600 1800 1209600 86400 severijnse.eu. 3600 IN SOA ns1.severijnse.eu. abuse.severijnse.eu. 2026071004 3600 1800 1209600 86400
IN NS ns1.severijnse.eu. IN NS ns1.severijnse.eu.
IN NS ns2.severijnse.eu. IN NS ns2.severijnse.eu.
@@ -11,12 +11,14 @@
ns1 IN A 49.13.92.205 ns1 IN A 49.13.92.205
ns2 IN A 49.13.92.205 ns2 IN A 49.13.92.205
mail IN A 49.13.92.205 mail IN A 49.13.92.205
admin.mail IN A 49.13.92.205
@ IN AAAA 2a01:4f8:c014:2585::1 @ IN AAAA 2a01:4f8:c014:2585::1
www IN AAAA 2a01:4f8:c014:2585::1 www IN AAAA 2a01:4f8:c014:2585::1
ns1 IN AAAA 2a01:4f8:c014:2585::1 ns1 IN AAAA 2a01:4f8:c014:2585::1
ns2 IN AAAA 2a01:4f8:c014:2585::1 ns2 IN AAAA 2a01:4f8:c014:2585::1
mail IN AAAA 2a01:4f8:c014:2585::1 mail IN AAAA 2a01:4f8:c014:2585::1
admin.mail IN AAAA 2a01:4f8:c014:2585::1
*.severijnse.eu. IN A 49.13.92.205 *.severijnse.eu. IN A 49.13.92.205
*.severijnse.eu. IN AAAA 2a01:4f8:c014:2585::1 *.severijnse.eu. IN AAAA 2a01:4f8:c014:2585::1
@@ -25,7 +27,7 @@
mail._domainkey.severijnse.eu. 300 IN TXT ( mail._domainkey.severijnse.eu. 300 IN TXT (
"v=DKIM1; k=rsa; " "v=DKIM1; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAskpG7m4kninxRE4JF5KzpnBhLbOlGJL2RO/iDfzdz6sHEvxe78q9c5UnZ0OQddOSuQo4q48dJkXR/XzqY7Ak109lhoAx+Kr1neYsi8/" "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAskpG7m4kninxRE4JF5KzpnBhLbOlGJL2RO/iDfzdz6sHEvxe78q9c5UnZ0OQddOSuQo4q48dJkXR/XzqY7Ak109lhoAx+Kr1neYsi8K/"
"JaoTC8OURk365+/aBSmWXUCCBphCx43QWfC9h8GMQ6PUIqawkz5CcGPT7X7hPdwHQcd5Vn3CqmABptxdwshdkBjZs" "JaoTC8OURk365+/aBSmWXUCCBphCx43QWfC9h8GMQ6PUIqawkz5CcGPT7X7hPdwHQcd5Vn3CqmABptxdwshdkBjZs"
"oi79BOo9ZrQSTY7iiLcOP7hVVC9Ad+ydlZ4MWGfy5BxgyTGrrtuSuLcM219oqdovIvr2EtXs8AMx5fyXplKE3R/" "oi79BOo9ZrQSTY7iiLcOP7hVVC9Ad+ydlZ4MWGfy5BxgyTGrrtuSuLcM219oqdovIvr2EtXs8AMx5fyXplKE3R/"
"YlwF2Jcy50Gmb5y/E9pOaFjVv8HXUmKsvuhA2b8K+rt0WVHNc3dvbgZUl8bGAQIDAQAB" "YlwF2Jcy50Gmb5y/E9pOaFjVv8HXUmKsvuhA2b8K+rt0WVHNc3dvbgZUl8bGAQIDAQAB"
@@ -91,7 +93,8 @@ in {
''; '';
}; };
coredns = { coredns = {
after = ["decrypt-coredns-keys.service"]; after = ["decrypt-coredns-keys.service" "network-online.target"];
wants = ["network-online.target"];
requires = ["decrypt-coredns-keys.service"]; requires = ["decrypt-coredns-keys.service"];
# Copy zone file from Nix store to writable location on service start # Copy zone file from Nix store to writable location on service start
@@ -4,7 +4,6 @@
image = "crazymax/fail2ban:latest"; image = "crazymax/fail2ban:latest";
autoStart = true; autoStart = true;
volumes = [ volumes = [
"/home/admin/dms/mail-logs:/var/log/mail:ro"
"/home/admin/fail2ban/data:/data:Z" "/home/admin/fail2ban/data:/data:Z"
]; ];
environment = { environment = {
@@ -15,8 +14,4 @@
"--cap-add=NET_ADMIN" "--cap-add=NET_ADMIN"
]; ];
}; };
systemd.tmpfiles.rules = [
"d /var/log/mail 0755 root root -"
];
} }
@@ -0,0 +1,32 @@
{
config,
pkgs,
...
}: {
#
# Netdata
#
services.netdata = {
enable = true;
config = {
global = {
"memory mode" = "ram";
"debug log" = "none";
"access log" = "none";
"error log" = "syslog";
};
web = {
"bind to" = "127.0.0.1:19999";
};
};
};
services.caddy.virtualHosts."netdata.severijnse.eu" = {
extraConfig = ''
import security_headers
import csp
reverse_proxy 127.0.0.1:19999
encode zstd gzip
'';
};
}
@@ -1,23 +0,0 @@
{...}: {
virtualisation.oci-containers.containers.snappymail = {
image = "djmaze/snappymail:latest";
autoStart = true;
ports = ["127.0.0.1:8888:8888"];
volumes = [
"/home/admin/snappymail-data:/var/lib/snappymail:Z"
];
environment = {
TZ = "Europe/Berlin";
};
extraOptions = [
"--label=com.centurylinklabs.watchtower.enable=true"
];
};
# Ensure the persistent data dir exists so podman's :Z relabel (statfs) succeeds on first boot.
# Owned by 82:82 (www-data) because the container's PHP worker runs as UID 82 and must be
# able to write to /var/lib/snappymail (SnappyMail checks is_writable on that path).
systemd.tmpfiles.rules = [
"d /home/admin/snappymail-data 0755 82 82 - -"
];
}
@@ -0,0 +1,310 @@
{
lib,
pkgs,
unstablePkgs,
...
}: let
# Caddy's dist dir (see tlsa-updater.nix): cert 0644, key 0640 root:root.
# The Stalwart service runs as "stalwart"; grant it read access to the key.
certDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
# sops-encrypted secrets (single file holds all service secrets), same as backup.nix.
secretsFile = ../../secrets/secrets.yaml;
# Root-only runtime file holding the fallback-admin password hash (0600 root).
adminHashFile = "/var/lib/stalwart/.admin-hash";
# Materialize the fallback-admin password hash from sops into a root-only file.
writeAdminHash = pkgs.writeShellScript "stalwart-write-admin-hash" ''
set -euo pipefail
install -d -o root -g root -m 0755 "$(dirname ${adminHashFile})"
${pkgs.sops}/bin/sops \
--decrypt --extract '["stalwart_admin_hash"]' \
--input-type yaml --output-type yaml ${secretsFile} \
| tr -d '\n' > "${adminHashFile}"
chmod 0600 "${adminHashFile}"
'';
# The outbound DKIM key is the existing opendkim key from docker-mailserver
# (selector "mail", domain severijnse.eu, PKCS#8 RSA 2048). Install it into the
# stalwart-owned data dir so the server can read it and it is covered by the
# /var/lib/stalwart backup.
dkimKeyDir = "/var/lib/stalwart/dkim/severijnse.eu";
dkimKeySrc = "/home/admin/dms/config/opendkim/keys/severijnse.eu/mail.private";
writeDkimKey = pkgs.writeShellScript "stalwart-write-dkim-key" ''
set -euo pipefail
install -d -o stalwart -g stalwart -m 0750 ${dkimKeyDir}
install -o stalwart -g stalwart -m 0640 ${dkimKeySrc} ${dkimKeyDir}/mail.private
'';
in {
# The hetzner host is built with nixos-24.05, which ships its own
# `services.stalwart-mail` module (for the old 0.8.x package). We want the
# 0.15.5 module from the locked nixpkgs-unstable instead, so we must exclude
# the 24.05 default module (which defines the same option namespace) to avoid
# the rename-based infinite recursion, and import the unstable one in its place.
disabledModules = [
"services/mail/stalwart-mail.nix"
];
imports = [
"${unstablePkgs.path}/nixos/modules/services/mail/stalwart.nix"
];
services.stalwart = {
enable = true;
stateVersion = "26.05";
package = unstablePkgs.stalwart;
# Public listeners. The host firewall (networking.nix) already allows
# 25/143/465/587/993, so openFirewall stays off: the module would also open
# every other listener port incl. the 8080 webadmin.
openFirewall = false;
settings = {
# EHLO / hostname for the server (docs server.hostname).
server.hostname = "mail.severijnse.eu";
# Public origin the JMAP/webadmin API is served from, advertised in the
# JMAP session (docs server/core/network.md "http.url"). Value must be a
# JScript expression, hence the single-quoted string literal. Without this
# Stalwart advertises http://mail.severijnse.eu:8080, which the browser
# refuses to fetch and breaks Bulwark webmail.
http.url = "'https://mail.severijnse.eu'";
certificate."mail-severijnse-eu" = {
cert = "%{file:${certDir}/mail.severijnse.eu.crt}%";
private-key = "%{file:${certDir}/mail.severijnse.eu.key}%";
# Docs server/tls/certificates.md: used when the client sends no SNI.
default = true;
};
server.tls = {
certificate = "mail-severijnse-eu";
enable = true;
implicit = false;
};
# Public listeners (docs server/listener.md + protocol, tls.implicit override).
# Bind "[::]:port" for dual-stack IPv4+IPv6 (docs: "to bind a listener to
# all interfaces"); listing both 0.0.0.0 and [::] makes the [::] bind fail
# with EADDRINUSE on kernels with net.ipv6.bindv6only=0.
# 143/587 use STARTTLS (server.tls.implicit=false default), 993/465 the
# implicit-TLS variants, 25 the plain (STARTTLS) MX port.
server.listener = {
"imap" = {
bind = ["[::]:143"];
protocol = "imap";
};
"imaps" = {
bind = ["[::]:993"];
protocol = "imap";
tls.implicit = true;
};
"smtp" = {
bind = ["[::]:25"];
protocol = "smtp";
};
"smtp-submission" = {
bind = ["[::]:587"];
protocol = "smtp";
};
"smtp-submissions" = {
bind = ["[::]:465"];
protocol = "smtp";
tls.implicit = true;
};
"http-management" = {
bind = ["127.0.0.1:8080"];
protocol = "http";
};
};
# Auth per docs mta/inbound/auth.md (AUTH stage): authentication is
# disabled on the plain SMTP listener (port 25) and required everywhere
# else (IMAP + submission). Only offer PLAIN/LOGIN over TLS, so clear-text
# listeners (143/587 pre-STARTTLS) advertise no SASL mechanisms. This
# mirrors the code defaults (crates/common/src/config/smtp/session.rs).
# The JMAP/webadmin "http-management" listener (bind 127.0.0.1:8080) is
# plain HTTP and reachable only from localhost, where bulwark connects;
# its basic-auth uses the "plain" mechanism, so it must be exempted from
# the is_tls gate or webmail logins fail with "Authentication not allowed".
session.auth.mechanisms = [
{
"if" = "local_port != 25 && (is_tls || listener == 'http-management')";
"then" = "[plain, login]";
}
{"else" = false;}
];
session.auth.directory = [
{
"if" = "listener != 'smtp'";
"then" = "'internal'";
}
{"else" = false;}
];
session.auth.require = [
{
"if" = "listener != 'smtp'";
"then" = true;
}
{"else" = false;}
];
# Outbound DKIM signing (docs mta/authentication/dkim/sign): sign with the
# "mail" signature on everything submitted via non-25 listeners; do not sign
# inbound mail received on the plain "smtp" listener.
auth.dkim.sign = [
{
"if" = "listener != 'smtp'";
"then" = "['mail']";
}
{"else" = false;}
];
# ARC sealing uses the same "mail" signature (docs mta/authentication/arc).
# The code default ('rsa-' + report.domain) would reference a signature
# name that does not exist and log "ARC sealer not found".
auth.arc.seal = "'mail'";
# Every *downstream* sign rule defaults to signing with
# ['rsa-<report.domain>', 'ed25519-<report.domain>'] (queue.rs/report.rs),
# names that do not exist here and log "DKIM signer not found" on DSNs and
# reports. Point them all at the real "mail" signature.
report.dsn.sign = "['mail']";
report.spf.sign = "['mail']";
report.dmarc.sign = "['mail']";
report.dmarc.aggregate.sign = "['mail']";
report.tls.aggregate.sign = "['mail']";
# Reuse the existing opendkim key (selector mail) so no DNS change is needed.
signature.mail = {
private-key = "%{file:/var/lib/stalwart/dkim/severijnse.eu/mail.private}%";
domain = "severijnse.eu";
selector = "mail";
headers = ["From" "To" "Date" "Subject" "Message-Id"];
algorithm = "rsa-sha256";
canonicalization = "relaxed/relaxed";
set-body-length = false;
};
# Fallback admin (auth/authorization/administrator.md): bootstrap admin with
# every permission, used to create the internal-directory accounts via the
# management REST API / CLI. Secret is a SHA-512-crypt hash, injected via
# LoadCredential (services.stalwart.credentials) so no secret lands in the
# Nix store.
authentication."fallback-admin" = {
user = "admin";
secret = "%{file:/run/credentials/stalwart.service/stalwart-admin}%";
};
# Route docs mta/outbound/routing.md + strategy.md: local domains → local
# store, everything else → MX. The strategy names are defined explicitly
# (docs define queue.route.mx/local; the built-in fallback in
# core.rs:get_route_or_default only kicks in for undeclared names).
queue.strategy.route = [
{
"if" = "is_local_domain('', rcpt_domain)";
"then" = "'local'";
}
{"else" = "'mx'";}
];
queue.route."mx" = {
type = "mx";
ip-lookup = "ipv4_then_ipv6";
};
queue.route."local" = {
type = "local";
};
};
};
# The module's service runs as user/group "stalwart" (ProtectHome=true,
# ProtectSystem=strict). The TLS key tlsa-updater installs is 0640 root:root;
# regrant it to the stalwart group after every cert sync so stalwart can serve TLS.
systemd.services.stalwart = {
after = ["tlsa-update.service" "stalwart-admin-secret.service" "stalwart-dkim.service"];
requires = ["tlsa-update.service" "stalwart-admin-secret.service" "stalwart-dkim.service"];
};
# Make the management CLI available for account creation and maildir import
# (docs management/cli/). Version-pinned to the locked unstable nixpkgs.
environment.systemPackages = [unstablePkgs.stalwart-cli];
# Materialize the fallback-admin password hash from sops before stalwart starts.
# The admin hash is a SHA-512-crypt value, safe to pass through a root-only file.
systemd.services.stalwart-admin-secret = {
description = "Materialize Stalwart fallback-admin password hash from sops";
wantedBy = ["multi-user.target"];
before = ["stalwart.service"];
# The age key lives in /etc/age/keys.txt; the service must know where it is
# and needs a HOME for age to report its user config directory.
environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt";
serviceConfig = {
Type = "oneshot";
Environment = ["HOME=/root"];
ExecStart = "${writeAdminHash}";
};
};
# Install the outbound DKIM key into the stalwart data dir before the service starts.
systemd.services.stalwart-dkim = {
description = "Install Stalwart outbound DKIM key";
wantedBy = ["multi-user.target"];
before = ["stalwart.service"];
serviceConfig = {
Type = "oneshot";
ExecStart = "${writeDkimKey}";
};
};
# LoadCredential: expose the materialized hash to stalwart only at
# /run/credentials/stalwart.service/stalwart-admin (see `credentials` option
# in the upstream module; the value is the source path on disk).
services.stalwart.credentials.stalwart-admin = adminHashFile;
systemd.services.stalwart-cert-perm = {
description = "Grant stalwart read access to its TLS private key";
# Belt-and-suspenders: tlsa-update already chgrps the key after every sync;
# this guarantees the group grant also exists at first boot, before stalwart
# starts (previously ordered after stalwart, so a fresh sync could leave a
# root:root key and webadmin reload would fail with EACCES).
after = ["tlsa-update.service"];
before = ["stalwart.service"];
partOf = ["tlsa-update.service"];
wantedBy = ["multi-user.target"];
path = [pkgs.coreutils];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/chgrp stalwart ${certDir}/mail.severijnse.eu.key";
ExecStartPost = "${pkgs.coreutils}/bin/chmod 0640 ${certDir}/mail.severijnse.eu.key";
};
};
# Bulwark webmail (self-hosted JMAP webmail for Stalwart). Serving on
# mail.severijnse.eu behind Caddy (see caddy.nix). It connects to Stalwart's
# JMAP endpoint at 127.0.0.1:8080, so uses host networking. Next.js defaults
# to POST_SIZE/etc via env; JMAP_SERVER_URL points at the Stalwart http
# listener which serves JMAP at /jmap.
virtualisation.oci-containers.containers.bulwark = {
image = "ghcr.io/bulwarkmail/webmail:latest";
autoStart = true;
volumes = [
"/var/lib/bulwark:/app/data:Z"
];
environment = {
# JMAP_SERVER_URL is the public origin (the browser uses it verbatim for
# /.well-known/jmap + the session apiUrl). HOSTNAME stays 127.0.0.1 so
# Next.js binds to loopback (caddy reverse_proxy's 127.0.0.1:3002); binding
# to the public hostname made the container unreachable for caddy (502).
JMAP_SERVER_URL = "https://mail.severijnse.eu";
HOSTNAME = "127.0.0.1";
PORT = "3002";
};
extraOptions = [
"--network=host"
"--label=com.centurylinklabs.watchtower.enable=true"
];
};
systemd.tmpfiles.rules = [
"d /var/lib/bulwark 0755 1001 1001 - -"
];
}
@@ -1,11 +1,15 @@
{pkgs, ...}: let {pkgs, ...}: let
# Caddy's canonical certificate storage (XDG data dir). Renewals land here, # Caddy's canonical certificate storage (XDG data dir). Renewals land here,
# owned caddy:caddy 0600 — the mail server's non-root Postfix/Dovecot cannot # owned caddy:caddy 0600 — stalwart cannot read it directly, so we copy it
# read it directly, so we copy it into a world-readable distribution dir. # into a distribution dir stalwart's service (user "stalwart") can reach.
caddyCertDir = "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu"; caddyCertDir = "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
# World-readable distribution dir mounted (RO) into the mail server container. # Distribution dir read by stalwart (cert 0644, private key regranted to the
# "stalwart" group by systemd.services.stalwart-cert-perm).
distCertDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu"; distCertDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
zoneFile = "/var/lib/coredns/zones/severijnse.eu.db"; zoneFile = "/var/lib/coredns/zones/severijnse.eu.db";
# Records the SPKI hash applied at the last restart of stalwart, so cert
# renewals trigger exactly one restart and unchanged certs never do.
stateFile = "/var/lib/tlsa-update/.last-spki";
syncScript = pkgs.writeShellScript "tlsa-update" '' syncScript = pkgs.writeShellScript "tlsa-update" ''
set -euo pipefail set -euo pipefail
@@ -22,13 +26,17 @@
exit 0 exit 0
fi fi
# 1) Propagate Caddy's renewed certificate into the distribution dir the # 1) Propagate Caddy's renewed certificate into the distribution dir stalwart
# mail server mounts. Caddy stores certs 0600 caddy:caddy. The cert is # reads. Caddy stores certs 0600 caddy:caddy. The cert is world-readable;
# world-readable (Postfix/Dovecot read it as root before dropping # the private key is restricted to root (0640) and stalwart-cert-perm
# privileges); the private key is restricted to root (0640) so it is not # regrants it to the "stalwart" group so it is not exposed to other local
# exposed to other local users. # users.
install -D -m 0644 "$SRC_CERT" "$DST_CERT" install -D -m 0644 "$SRC_CERT" "$DST_CERT"
install -D -m 0640 "$SRC_KEY" "$DST_KEY" install -D -m 0640 "$SRC_KEY" "$DST_KEY"
# Stalwart reads the key as user "stalwart" via %{file:...}%; regrant the
# group immediately so every sync leaves it readable (0640 root:stalwart)
# and webadmin config reload never fails with EACCES.
chgrp stalwart "$DST_KEY"
# 2) TLSA 3 1 1 = SHA-256 of the certificate's SubjectPublicKeyInfo (SPKI), # 2) TLSA 3 1 1 = SHA-256 of the certificate's SubjectPublicKeyInfo (SPKI),
# NOT the whole certificate. Matching type 1 = SHA-256 of the SPKI DER. # NOT the whole certificate. Matching type 1 = SHA-256 of the SPKI DER.
@@ -47,8 +55,24 @@
# 4) Reload services so the changes take effect immediately. # 4) Reload services so the changes take effect immediately.
systemctl reload coredns.service || true systemctl reload coredns.service || true
podman exec mailserver postfix reload || true # Stalwart reads its TLS certs ($certDir) via %{file:...}% placeholders at
podman exec mailserver dovecot reload || true # startup only; there is no signal-based reload (management API reload needs
# admin credentials we must not store). Restart it, but only when the cert
# actually changed (SPKI hash differs from the last applied run), so the
# daily timer and inotify events for unchanged certs do not drop connections.
# NOTE: stalwart.service `requires` this unit, so the restart must be issued
# with --no-block (async): a synchronous restart waits for stalwart to come
# back up, which in turn waits for this unit to finish a deadlock. The state
# file is updated BEFORE the restart so the tlsa-update run that stalwart's
# required-activation re-triggers sees a matching hash and exits immediately.
if [ "$(cat "${stateFile}" 2>/dev/null || true)" != "$HEX" ]; then
printf '%s\n' "$HEX" > "${stateFile}"
# Restarting stalwart re-runs its Requires=tlsa-update dependency; guard
# with is-active so a boot-time run never races stalwart's initial start.
if systemctl is-active --quiet stalwart.service 2>/dev/null; then
systemctl --no-block restart stalwart.service || true
fi
fi
echo "tlsa-update: TLSA set to $HEX" echo "tlsa-update: TLSA set to $HEX"
''; '';
@@ -57,6 +81,7 @@ in {
# Ensure the distribution dir exists (Caddy does not write here). # Ensure the distribution dir exists (Caddy does not write here).
tmpfiles.rules = [ tmpfiles.rules = [
"d ${distCertDir} 0755 root root - -" "d ${distCertDir} 0755 root root - -"
"d /var/lib/tlsa-update 0755 root root - -"
]; ];
services.tlsa-update = { services.tlsa-update = {
@@ -64,7 +89,7 @@ in {
after = ["caddy.service" "coredns.service"]; after = ["caddy.service" "coredns.service"];
partOf = ["coredns.service"]; partOf = ["coredns.service"];
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
path = with pkgs; [openssl coreutils gnused podman systemd]; path = with pkgs; [openssl coreutils gnused systemd];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = "${syncScript}"; ExecStart = "${syncScript}";
@@ -0,0 +1,32 @@
--- a/src/app/api/checkout/route.ts
+++ b/src/app/api/checkout/route.ts
@@ -7,6 +7,8 @@
export const dynamic = "force-dynamic";
+const BYPASS_PAYMENTS = process.env.BYPASS_PAYMENTS === "true";
+
export async function POST(req: Request) {
// Each checkout hits the payment provider, so cap order creation per user
// and per IP to prevent order spam and provider-API abuse.
@@ -86,6 +88,20 @@
const paid = await prisma.order.findUnique({
where: { id: order.id },
include: { license: true },
+ });
+ return NextResponse.json({
+ ok: true,
+ orderId: order.id,
+ licenseKey: paid?.license?.key ?? null,
+ tier: tier.slug,
+ });
+ }
+
+ if (BYPASS_PAYMENTS) {
+ await markOrderPaid({ orderId: order.id, providerRef: "bypass-test" });
+ const paid = await prisma.order.findUnique({
+ where: { id: order.id },
+ include: { license: true },
});
return NextResponse.json({
ok: true,
@@ -0,0 +1,287 @@
{
pkgs,
lib,
unstablePkgs,
...
}: let
rev = "6e351ddc732421eb775eb9843ccd2389d6525edd";
apiRev = "390878d126185b21a14479f85c70134a155c61c6";
# Private repositories are fetched over SSH (port 2222). nix-daemon runs as
# root and uses /root/.ssh (identity materialized by the git-ssh-key unit),
# so the source ends up in the store without any Nix-native credentials.
src = builtins.fetchGit {
url = "ssh://git@git.severijnse.eu:2222/jory/virtualcam-website.git";
rev = rev;
};
srcPatched = pkgs.applyPatches {
name = "virtualcam-website-patched";
src = src;
patches = [./virtualcam-checkout.patch];
};
apiSrc = builtins.fetchGit {
url = "ssh://git@git.severijnse.eu:2222/jory/virtualcam-api.git";
rev = apiRev;
};
# Build the Next.js app entirely in Nix (offline npm deps from the lockfile).
app = unstablePkgs.buildNpmPackage {
pname = "virtualcam-website";
version = "0.1.0";
src = srcPatched;
npmDepsHash = "sha256-GodWQKtOtsLOjjiwzxun+wTPhtvjR2uCV91n+wiZHw4=";
nodejs = unstablePkgs.nodejs;
buildPhase = ''
runHook preBuild
npx prisma generate
npm run build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r .next node_modules public prisma src package.json package-lock.json \
prisma.config.ts next.config.ts tsconfig.json postcss.config.mjs $out/
runHook postInstall
'';
APP_URL = "https://virtualcam.severijnse.eu";
GOOGLE_CLIENT_ID = "754775011707-c699m092tv3icmovhk5qa106v3q6eh7c.apps.googleusercontent.com";
# Only used to satisfy prisma generate / next build metadata resolution.
DATABASE_URL = "postgresql://virtualcam@localhost/virtualcam?host=/run/postgresql&schema=public";
# Use the nixpkgs-bundled Prisma engine so the sandboxed offline build does
# not need to download it from binaries.prisma.sh. Version matches ^7.8.0.
PRISMA_SCHEMA_ENGINE_BINARY = "${unstablePkgs.prisma-engines}/bin/schema-engine";
NEXT_TELEMETRY_DISABLED = "1";
CI = "true";
};
# License validation / admin API (Go/Fiber), serving on host port 3004.
# go.mod demands go 1.26.5 but the pinned nixpkgs only has 1.26.4; the code
# uses nothing newer, so the directive is relaxed to match the toolchain.
api = unstablePkgs.buildGoModule {
pname = "virtualcam-api";
version = "0.1.0";
src = apiSrc;
vendorHash = "sha256-uvHClXHw9ycoIf6qBZmV2O3CSyIxCgnSPCSALM07qg8=";
go = unstablePkgs.go_1_26;
postPatch = ''
sed -i 's/^go 1\.26\.5$/go 1.26.4/' go.mod
'';
};
dbUrl = "postgresql://virtualcam@localhost/virtualcam?host=/run/postgresql&schema=public";
# lib/pq parses the DSN differently from node-postgres: a hostname in the URL
# authority wins over a `host=` query param (so it would go over TCP and fail
# password auth), and lib/pq rejects unknown URL params like `schema`. Use a
# keyword DSN: unix-socket + peer auth + no SSL, matching the OS user.
apiDbUrl = "host=/run/postgresql user=virtualcam dbname=virtualcam sslmode=disable";
# Root-only runtime environment file holding the secrets both services need.
secretsFile = ../../secrets/secrets.yaml;
envFile = "/var/lib/virtualcam/environment";
# Materialize the license signing key and admin token from sops into a
# root-only file (0600). systemd reads environmentFiles before dropping
# privileges, so the service users never need to read it themselves.
writeSecrets = pkgs.writeShellScript "virtualcam-write-secrets" ''
set -euo pipefail
install -d -o virtualcam -g virtualcam -m 0750 /var/lib/virtualcam
: > "${envFile}"
chmod 0600 "${envFile}"
${pkgs.sops}/bin/sops --decrypt --input-type yaml --output-type yaml ${secretsFile} |
${pkgs.gnused}/bin/sed -nE \
's/^virtualcam_license_signing_key: (.*)/LICENSE_SIGNING_KEY=\1/p; s/^virtualcam_admin_token: (.*)/ADMIN_TOKEN=\1/p; s/^virtualcam_smtp_pass: (.*)/SMTP_PASS=\1/p; s/^virtualcam_google_OAuth_secret: (.*)/GOOGLE_CLIENT_SECRET=\1/p; s/^shkeeper_api_key: (.*)/SHKEEPER_API_KEY=\1/p' \
>> "${envFile}"
'';
# Materialize the nix-daemon's SSH identity so private-repo fetches keep
# working after the one-off bootstrap copy in /root/.ssh.
writeGitSshKey = pkgs.writeShellScript "git-ssh-key" ''
set -euo pipefail
install -d -m 0700 /root/.ssh
${pkgs.sops}/bin/sops --decrypt --extract '["git_ssh_key_b64"]' \
--input-type yaml --output-type yaml ${secretsFile} |
${pkgs.coreutils}/bin/base64 -d > /root/.ssh/id_ed25519
chmod 0600 /root/.ssh/id_ed25519
cat > /root/.ssh/known_hosts <<'EOF'
git.severijnse.eu ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAzIVo9Jdp8kwnWmTn26Fj68baJjwDphYw/0HTH5BzYY
[git.severijnse.eu]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDGcPKlvdxApYRCZuQxrljEdETrxAu0JsEJ+28zFaMfA3P69J9vEkqqAZZ0Uq3rtE8ZqPBxeDh7JYn2XChb+QVGjOHVWMCWqcdD43ws26eY91aHGYnO2kW+sKSxIh803tdifIp2q8VRnaHVXcLHLx2rmwmvqIfMLnEEShxAt4Q846DianBoA7S8heoBvMLi8AAO09sVEgMPWm8ULkoRq0poHslsKkdYKlWzecP85tPo/tlCEjQq5cC5+xidH9TS4ILxVIXGUfErZzbWZq5LtaRmqiU5U1+zzdb83oJvHFtTZ8wHwZX+VE4jZzKw7wdFPk+dJZp/3ZAVva6fLSjlpyyDFNWMcf2/18Gs6YioD1n2fr1dXruZiXNMnI19Dzyp+8yWIPbAeWyVx3BoCrcjIApEDMLMo7ut0l6RfNy80hYpY91VBrpsjSRLB4uCVDsqhTLPTrFUlu+9YY9lSHEUVLRylOLK54TbY089hbHOB/UdpyCiXpI0ds3ww1UmnDQSFjwNknc+wTXRvzEz+VsX2nAzfO+5NWq2jHNn4wH6GuAinAxSVg1A7Ub9VSmPelRiTzK6ZnplPm762MxcPFmo+y/5cszumUwypcyTS8MJIC57S/kabfsnjdt97K+9eT9X2LDZ2uIQgDS8qEgvbnvfXVmMoqakgBLYG7Embqc8BDzNsQ==
EOF
chmod 0600 /root/.ssh/known_hosts
test -f /root/.ssh/id_ed25519.pub || ${pkgs.openssh}/bin/ssh-keygen -y -f /root/.ssh/id_ed25519 > /root/.ssh/id_ed25519.pub
'';
# The repo's seed uses tsx (a devDependency buildNpmPackage drops) plus the
# "@/..." path alias. nixpkgs ships tsx, which honours tsconfig paths, so we
# add it to the service path rather than working around the missing dep.
seed = pkgs.writeShellScript "virtualcam-seed" ''
set -euo pipefail
export DATABASE_URL="${dbUrl}"
export PRISMA_SCHEMA_ENGINE_BINARY="${unstablePkgs.prisma-engines}/bin/schema-engine"
export HOME=/var/lib/virtualcam
cd ${app}
tsx prisma/seed.ts
'';
# One shared PostgreSQL server (existing system postgres). Each service gets
# its own database + role. virtualcam authenticates over the Unix socket via
# peer auth: the systemd services run as OS user `virtualcam`, which matches
# the database role `virtualcam`, so no password is stored anywhere.
migrate = pkgs.writeShellScript "virtualcam-migrate" ''
set -euo pipefail
export DATABASE_URL="${dbUrl}"
# Use the local Prisma engine; no network download needed at runtime.
export PRISMA_SCHEMA_ENGINE_BINARY="${unstablePkgs.prisma-engines}/bin/schema-engine"
cd ${app}
./node_modules/.bin/prisma migrate deploy
'';
in {
users = {
users.virtualcam = {
isSystemUser = true;
group = "virtualcam";
description = "virtualcamera services user";
};
groups.virtualcam = {};
};
services.postgresql = {
ensureDatabases = ["virtualcam"];
ensureUsers = [
{
name = "virtualcam";
ensureDBOwnership = true;
}
];
};
systemd = {
services = {
git-ssh-key = {
description = "Materialize nix-daemon git SSH key from sops";
wantedBy = ["multi-user.target"];
environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt";
serviceConfig = {
Type = "oneshot";
Environment = ["HOME=/root"];
ExecStart = "${writeGitSshKey}";
};
};
virtualcam-secrets = {
description = "Materialize virtualcam secrets from sops";
wantedBy = ["multi-user.target"];
# The age key lives in /etc/age/keys.txt; the service must know where it
# is and needs a HOME for age to report its user config directory.
environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt";
serviceConfig = {
Type = "oneshot";
Environment = ["HOME=/root"];
ExecStart = "${writeSecrets}";
};
};
virtualcam-migrate = {
description = "Virtualcam Prisma migrations";
after = ["postgresql.service"];
requires = ["postgresql.service"];
wantedBy = ["multi-user.target"];
serviceConfig = {
Type = "oneshot";
User = "virtualcam";
Group = "virtualcam";
StateDirectory = "virtualcam";
StateDirectoryMode = "0750";
ExecStart = "${migrate}";
};
};
virtualcam-seed = {
description = "Virtualcam catalog seed";
after = ["virtualcam-migrate.service"];
requires = ["virtualcam-migrate.service"];
wantedBy = ["multi-user.target"];
path = [unstablePkgs.nodejs unstablePkgs.tsx];
serviceConfig = {
Type = "oneshot";
User = "virtualcam";
Group = "virtualcam";
ExecCondition = "!/var/lib/virtualcam/.seeded";
ExecStart = "${seed}";
ExecStartPost = "${pkgs.coreutils}/bin/touch /var/lib/virtualcam/.seeded";
StateDirectory = "virtualcam";
StateDirectoryMode = "0750";
};
};
virtualcam = {
description = "Virtualcamera website (Next.js)";
after = ["postgresql.service" "podman-shkeeper.service" "virtualcam-migrate.service" "virtualcam-seed.service" "virtualcam-secrets.service"];
requires = ["postgresql.service" "podman-shkeeper.service" "virtualcam-migrate.service" "virtualcam-seed.service" "virtualcam-secrets.service"];
wantedBy = ["multi-user.target"];
path = [unstablePkgs.nodejs];
serviceConfig = {
User = "virtualcam";
Group = "virtualcam";
WorkingDirectory = "${app}";
EnvironmentFile = [envFile];
ExecStart = "${app}/node_modules/.bin/next start -p 3001 -H 127.0.0.1";
Restart = "on-failure";
RestartSec = 5;
StateDirectory = "virtualcam";
StateDirectoryMode = "0750";
Environment = [
"DATABASE_URL=${dbUrl}"
"APP_URL=https://virtualcam.severijnse.eu"
"GOOGLE_CLIENT_ID=754775011707-c699m092tv3icmovhk5qa106v3q6eh7c.apps.googleusercontent.com"
"PAYMENTS_MODE=shkeeper"
"BYPASS_PAYMENTS=false"
"SHKEEPER_URL=https://pay.severijnse.eu"
"SHKEEPER_CRYPTO=BTC"
"SHKEEPER_FIAT=USD"
"ADMIN_EMAILS=jory@severijnse.eu"
"SMTP_HOST=mail.severijnse.eu"
"SMTP_PORT=587"
"SMTP_USER=no-reply@severijnse.eu"
"SMTP_FROM=no-reply@severijnse.eu"
"NODE_ENV=production"
"NEXT_TELEMETRY_DISABLED=1"
"HOME=/var/lib/virtualcam"
];
};
};
virtualcam-api = {
description = "Virtualcamera license API (Go/Fiber)";
after = ["postgresql.service" "podman-shkeeper.service" "virtualcam-migrate.service" "virtualcam-secrets.service"];
requires = ["postgresql.service" "podman-shkeeper.service" "virtualcam-migrate.service" "virtualcam-secrets.service"];
wantedBy = ["multi-user.target"];
serviceConfig = {
User = "virtualcam";
Group = "virtualcam";
WorkingDirectory = "${api}";
EnvironmentFile = [envFile];
ExecStart = "${api}/bin/virtualcam-api";
Restart = "on-failure";
RestartSec = 5;
StateDirectory = "virtualcam";
StateDirectoryMode = "0750";
Environment = [
"PORT=3004"
"DATABASE_URL=${apiDbUrl}"
"CORS_ORIGINS=https://virtualcam.severijnse.eu"
"HOME=/var/lib/virtualcam"
];
};
};
};
};
# Served behind Caddy (virtualHosts wired in caddy.nix).
networking.firewall.allowedTCPPorts = [];
}
+65 -7
View File
@@ -1,16 +1,40 @@
{...}: { {pkgs, ...}: let
# sops-encrypted secrets (single file holds all service secrets), same as backup.nix.
secretsFile = ../../secrets/secrets.yaml;
# Root-only env file wg-easy reads the admin password from (0600 root).
wgEnvFile = "/var/lib/wg-easy/environment";
# Materialize the wg-easy admin password from sops into a root-only env file,
# so the secret never lands in the Nix store.
writeSecrets = pkgs.writeShellScript "wg-easy-write-secrets" ''
set -euo pipefail
mkdir -p "$(dirname ${wgEnvFile})"
PASSWORD="$(${pkgs.sops}/bin/sops \
--decrypt --extract '["wg_admin_password"]' \
--input-type yaml --output-type yaml ${secretsFile} | tr -d '\n')"
printf 'INIT_PASSWORD=%s\n' "$PASSWORD" > "${wgEnvFile}"
chmod 0600 "${wgEnvFile}"
'';
in {
boot.kernelModules = ["ip6table_nat"];
virtualisation.oci-containers.containers.wg-easy = { virtualisation.oci-containers.containers.wg-easy = {
image = "ghcr.io/wg-easy/wg-easy:latest"; image = "ghcr.io/wg-easy/wg-easy:15";
autoStart = true; autoStart = true;
volumes = [ volumes = [
"/home/admin/config:/etc/wireguard:Z" "/home/admin/config:/etc/wireguard:Z"
]; ];
environmentFiles = [wgEnvFile];
environment = { environment = {
WG_HOST = "severijnse.eu"; INSECURE = "true";
PASSWORD_HASH = "$2a$12$b3n4drXgS3B6ubMZxxjPUOQ1XktZ1EuDwm4AIdVulhtoD7b1.WQGC"; INIT_ENABLED = "true";
WG_DEFAULT_ADDRESS = "10.8.0.x"; INIT_USERNAME = "admin";
WG_DEFAULT_DNS = "1.1.1.1"; INIT_HOST = "severijnse.eu";
WG_PORT = "51820"; INIT_PORT = "51820";
INIT_DNS = "1.1.1.1,2606:4700:4700::1111";
INIT_IPV4_CIDR = "10.8.0.0/24";
INIT_IPV6_CIDR = "fd10:8::/64";
INIT_ALLOWED_IPS = "0.0.0.0/0, ::/0";
}; };
extraOptions = [ extraOptions = [
"--cap-add=NET_ADMIN" "--cap-add=NET_ADMIN"
@@ -19,4 +43,38 @@
"--network=host" "--network=host"
]; ];
}; };
# Materialize the wg-easy admin password from sops before the container starts.
systemd.services.wg-easy-secrets = {
description = "Materialize wg-easy admin password from sops";
wantedBy = ["multi-user.target"];
# The age key lives in /etc/age/keys.txt; the service must know where it is
# and needs a HOME for age to report its user config directory.
environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt";
serviceConfig = {
Type = "oneshot";
Environment = ["HOME=/root"];
ExecStart = "${writeSecrets}";
};
};
systemd.services."podman-wg-easy" = {
requires = ["wg-easy-secrets.service"];
after = ["wg-easy-secrets.service"];
};
systemd.services.wg-nat66 = {
description = "NAT66 for WireGuard IPv6";
after = ["network.target" "podman-wg-easy.service"];
wants = ["podman-wg-easy.service"];
wantedBy = ["multi-user.target"];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
${pkgs.iptables}/bin/ip6tables -t nat -C POSTROUTING -s fd10:8::/64 -o enp1s0 -j MASQUERADE 2>/dev/null || \
${pkgs.iptables}/bin/ip6tables -t nat -A POSTROUTING -s fd10:8::/64 -o enp1s0 -j MASQUERADE
'';
};
} }
+49
View File
@@ -0,0 +1,49 @@
shkeeper_api_key: ENC[AES256_GCM,data:kUymtefYJOllK5cxKHhD3HnXO/uo1w==,iv:OH9ZaQJZsFfo00BIl5MlRw1XBzHUmFi1FW54/J6O/8Q=,tag:HQJyuEoz5fsDi7iMq3PEzw==,type:str]
restic_password: ENC[AES256_GCM,data:OHQlxUpNnTqMQm6A/o3ID/3F91NpVKOrsFYdLqrtI4vp+6TWHR8=,iv:bBd+gfi78lgTaTv0QUWYGQWPsurrzx90VvOzA2d2euA=,tag:qsHSdAHx1auwdZCgn2O5Qw==,type:str]
b2_key_id: ENC[AES256_GCM,data:4B9rvg06baH6aNiT,iv:Qk1ToF3lMYLTrZdzpfaoGVzdiKYs492w9fzn4/TbNfQ=,tag:djxfqwgNZmRGqvteTX9G3w==,type:str]
b2_application_key: ENC[AES256_GCM,data:xGAbBqx+6ErK7sy3FR0yza6mJU5oc5uQQGcwKtcPp1Ef4XEVd8do/wN7,iv:CojcoykDRBfvN8yqMMrPJq9mtAwxyswqXXVBKPupxDs=,tag:BkfFHAGJvtCDUpFoxXVP3Q==,type:str]
stalwart_admin_hash: ENC[AES256_GCM,data:WqRfogOC+LrlSmg8/A4kLaUVJRdGgfrCnbuAzwyf4SMed37vsialN5QPR1IxiY02ueqz12dZ1VObittLMGxK+tUIIAwv3+F3uyKboDSyP9+wGAhBkcwTFTMjc6RMzaJ5VHW4OGaSvijiMQ==,iv:jvzoEI8evtFxuZ7W4P3nkQ0KwccVLj0j/s0s/MMXmIo=,tag:KopbV++Y/aABh1c8zfo1dw==,type:str]
wg_admin_password: ENC[AES256_GCM,data:X/+YJoNoFFU7P/2HCpTI,iv:FXDS3xuFB9jxjpQhsX2EKn0YFVzL23c2sZO6RqpKTiw=,tag:ccyQVzqNc1HEY0rxJSLlKw==,type:str]
virtualcam_license_signing_key: ENC[AES256_GCM,data:zk8YDER9d9mBOdXgEKKC6dubSMfz6UMMb758ZC9SvjkYIv+R2EVd+CJwL93MCs0l62lUDxoBDCgTLbKM55kcaThkkqfgZCYPrndXevYZ7kquNQEMkoYg/pyGAWhAQ9trvT5QXfBES9UDdYi6J9taCVKCofutiMEdoJM8olgUwKsP4i+fbt3idYkdWNGelastPnvTuUK/M3/dOKR5eheiy5oIntG+sECOz/2eKHJ8cU3ja18uAEwcpd9VojuOa0fvfui8AujQcMZNSMv8VsLGnoR854iX3g1MvHkTBSzv3qaDzRC54MueXYR4WOzm0o+yv6Otmw1n9trWtk+WLUimmiKxDdW6nSFaddeisWzy47+QYzrSsd7iovNYEIHnW4kpnT5W2g5lwaJwA0J0pBOqvV9gziv8oBNeEbLurBxi78KwHBqG,iv:svx+hC4tS26xqI05+yo6N2d3uHzO7ULM0e76dMbShjs=,tag:EJ9fjHEi1wUhLkxy8sexww==,type:str]
virtualcam_admin_token: ENC[AES256_GCM,data:JC5zSFBCoEapxv/Kcs4VXk3HAkiqwXVrCv1rlqFsXVx3LGspnQdzjOIahlM=,iv:GRVfMNGkwC3xXR00ww29rFbhTrpuViad+H05yDhR9BA=,tag:OBnxBD/8KU4mn17FFTcdng==,type:str]
git_ssh_key_b64: ENC[AES256_GCM,data:LaUcR005w9iCd/DifDhzLM7tU4AuTiFM802gMU1HMtvFN2iSffucaFJWxpKv2zFxDxz0QpA3XwM1M5yJC4K3YFMxPkPXcWLYN4yHcc1yXuoB3SseQXjcJnPMqZvJfVYhHep+6CVVlVDQs1Y+miBvbAhrvCEblwUu5LhD7yvoJLwTnEpT15WGDvqezzYKzNSyQGNRa8KPjsD/7gJN3RyUPzKo6GsrIp8d8eOTuCZwEcJVDKE0ACXs4V6cd+akqIWZkNPyilnMTuK5cqg2YuqSpwu9NncKED6SjmPiozPKKgTVaWhNCRJUTBfIw85fdXizvq7PNbNRxxBsscAWd0ixXiLqg1RTipfv85wL4Nru3r3qcQGAWmOpTHKW5fA9kR4ygdNxEVkOgQ70m9U0wev+vdIJUoRYj09Ikobej370NKppOP6+oeEDE7NpkFH7tzRk1oHtwoc1dwP9Kq1T8XdxttCKFyb1pAJ6gMdMThCfJlC9/Uu/WYbYVu5bCj6BAmrdrmhbPrsEOylBeC9j9RhyRrFEqLd8AZ01Zg8oKcj9WtCWEMf9Y4biv2xE43JbBGRvLAx3sH9MRplu2hN2W/bRxa/EPj9GHgbUjScDBkm/QVbMeV+QOgi09sCHDTeSX3BHdZ8ZLNjuNUwYnWZq4lPix0iYS5MufiixNdQmncPeHq0UuBtVifEl2Jmjq1COLyeAQe47AKQ5zDjT91xtBRZVZEVk0kGBYHtBEJ9ezPwckSw=,iv:cbIpwwhmyJVvvTMB95YBzt+RQLOji6yJh+SpcJc3Q1Y=,tag:t8WxU1IPDfkB8muTdBLUhg==,type:str]
virtualcam_smtp_pass: ENC[AES256_GCM,data:D5kDTQP9/YV5BjAWeTn0G2zKe+s=,iv:7dZJprrs0S2ECtbDQuvhUWsU0Xzpld5/XZqdbyefg0o=,tag:uWe811KAisBJ5M1EhJjrcQ==,type:str]
virtualcam_google_OAuth_secret: ENC[AES256_GCM,data:kPJIP4iH2vFo+VRojvhjzrQhDWidFq7kjC7ZaFcwJ7dNHlo=,iv:qKWClwSIRPU2Xz50bGYqU+eMR/iFxqnaKkb18eL3aMU=,tag:78jGQgL1myDBMiZP8GZ80g==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1yd59qp5km4cxt99rlfjehnsucrjn9lmj0su4h3avhf6vrtjvnyjqstldl5
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBycGNTU1cvbDFhMXRmb2xj
Sm83dEdRV2Nsc28vWGwvWDRoYUxnaC9BVWdBClh0a1lLMEhIa0NGVmNQL0NTT05y
Y1g1eU1jUEo3bFg5OWw2a1JNdkozanMKLS0tIFVXL1hueGcwQ2ZuMXg0alVxSEZM
eDhBZ1UweEJ1UlZJdzB0RDhiRG1MeDgKLm2QHJAi++C5C86DDrl7dM0MSpYu11mn
PhD9ElDJO9dFVCh+X+CSJwKEslR1aAraE2iQSHHgWxbMw7MB6AA9uQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age15rhqdpwejyf3r6ww70qgv6hqmkpsqraakn26kc49wlauhaceaeqsmuwrdd
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPdUQ3aVYxWElRc3ZBVENn
L0dud1ViVi9uZHJ6NXcxN0VVd2VlNGZhUXdnClZpRWhJcC9sYjBxVk1kdkdGZmVE
QS9LL2ZtNnJIdmhpa2NUTlJqQk5jSUkKLS0tIER2emlCbkJ1ZFdHdmdPNndZT09I
MFhYTUpJaUhDVmlrVzNocWhRZ0t1Q00KV1rgDAOoqlzEuO7xoo2ZYL20dF3f1pCj
bNllMkJ0u+hSZin6aUIRV31ExCYtgivDVD3Jx09PKwuYqkINU6ui8A==
-----END AGE ENCRYPTED FILE-----
- recipient: age1xekdrkjfu82hkxltydm72tllzgayyvfavvudeks3xjuujm5wt5hq6g55v0
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuYWlFTTlHMWFaeGEvNGhz
YVhEaVdOWXAvNTZrMjhRUWo1WUtNRWlyTGd3CkkrYnFIYnJncDdQZzdPcWR2UDJB
TUw3ckF1eEdlTDA3SXVsdmVKMUpDamMKLS0tIGVQK2hsWUlwTUhzSG5keFROWEMz
MHJrVVpDYWdJNmxtUkozSzR4Nmt3R28KrhYi830HUFAPfg8WvPad7BAuNe1mYOWt
WEFIquuX/H/N+y/7uQcBDbvnBzyropE1hW8aNrxSKMeawvQZWNXkZA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-19T10:30:19Z"
mac: ENC[AES256_GCM,data:JOWFguh2iE4+OyU00l+2gn7GWv2yej37OLLhVVO6Yb7Kv4vgk4NhZKJLqopteQK1o6dPeWbeIydHG1Qmef0Sm0NmCyi7i6BCTplVizf/puaGjpkU5YAs90+HiEa9cKlNQ5brfNH3YecJeN9dK+d7o8bMO/xPv3raqFMBBoVtgu4=,iv:A7kCOzyWwiVaXKVMKXQ4j86zz2f65wAjOtaVEuLa81I=,tag:bcbPsmRRFRHX7qi4WdYb0g==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.8.1
+1 -1
View File
@@ -1 +1 @@
disabled = ["empty_pattern"] disabled = ["empty_pattern", "repeated_keys"]
+5 -3
View File
@@ -1,5 +1,8 @@
{ config, lib, pkgs, ... }: {
config,
lib,
...
}:
# Fingerprint scanner configuration # Fingerprint scanner configuration
# #
# Enable on laptops with a fingerprint reader: # Enable on laptops with a fingerprint reader:
@@ -10,7 +13,6 @@
# hardware.fingerprint.todDriver = pkgs.libfprint-2-tod1-goodix; # hardware.fingerprint.todDriver = pkgs.libfprint-2-tod1-goodix;
# #
# Reference: https://wiki.nixos.org/wiki/Fingerprint_scanner # Reference: https://wiki.nixos.org/wiki/Fingerprint_scanner
let let
cfg = config.hardware.fingerprint; cfg = config.hardware.fingerprint;
in { in {
+10
View File
@@ -19,6 +19,16 @@
in { in {
package = pkgs.lix; package = pkgs.lix;
gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 14d";
};
optimise = {
automatic = true;
};
# pin the registry to avoid downloading and evaling a new nixpkgs version every time # pin the registry to avoid downloading and evaling a new nixpkgs version every time
registry = lib.mapAttrs (_: v: {flake = v;}) flakeInputs; registry = lib.mapAttrs (_: v: {flake = v;}) flakeInputs;
+16 -11
View File
@@ -1,5 +1,9 @@
{ config, lib, pkgs, self, ... }: {
config,
lib,
pkgs,
...
}:
# Cisco Secure Client (proprietary VPN client, successor to AnyConnect) # Cisco Secure Client (proprietary VPN client, successor to AnyConnect)
# #
# Package: pkgs/cisco-secure-client — fetches the Linux pre-deployment .tgz # Package: pkgs/cisco-secure-client — fetches the Linux pre-deployment .tgz
@@ -11,14 +15,15 @@
# }); # });
# #
# Reference: https://github.com/NixOS/nixpkgs/issues/265443 # Reference: https://github.com/NixOS/nixpkgs/issues/265443
let let
cfg = config.work.cisco; cfg = config.work.cisco;
in { in {
options.work.cisco = { options.work.cisco = {
enable = lib.mkEnableOption "Cisco Secure Client" // { enable =
default = false; lib.mkEnableOption "Cisco Secure Client"
}; // {
default = false;
};
package = lib.mkOption { package = lib.mkOption {
description = "cisco-secure-client package to use"; description = "cisco-secure-client package to use";
@@ -41,14 +46,14 @@ in {
]; ];
# Load the tun module required by the VPN client # Load the tun module required by the VPN client
boot.kernelModules = [ "tun" ]; boot.kernelModules = ["tun"];
systemd.services.cisco-vpnagentd = { systemd.services.cisco-vpnagentd = {
description = "Cisco Secure Client VPN Agent Daemon"; description = "Cisco Secure Client VPN Agent Daemon";
# vpnagentd daemonizes itself; tracked via PID file. # vpnagentd daemonizes itself; tracked via PID file.
after = [ "network-online.target" "NetworkManager.service" ]; after = ["network-online.target" "NetworkManager.service"];
wants = [ "network-online.target" ]; wants = ["network-online.target"];
wantedBy = [ "multi-user.target" ]; wantedBy = ["multi-user.target"];
serviceConfig = { serviceConfig = {
Type = "forking"; Type = "forking";
@@ -81,7 +86,7 @@ in {
"CAP_CHOWN" "CAP_CHOWN"
"CAP_FOWNER" "CAP_FOWNER"
]; ];
DeviceAllow = [ "/dev/net/tun rw" ]; DeviceAllow = ["/dev/net/tun rw"];
PrivateTmp = true; PrivateTmp = true;
ProtectSystem = "full"; ProtectSystem = "full";
ProtectHome = false; ProtectHome = false;
+1 -1
View File
@@ -1,4 +1,4 @@
{ config, lib, ... }: { {...}: {
imports = [ imports = [
./overlay.nix ./overlay.nix
./cisco.nix ./cisco.nix
+13 -7
View File
@@ -1,5 +1,10 @@
{ config, lib, pkgs, inputs, ... }: {
config,
lib,
pkgs,
inputs,
...
}:
# Himmelblau: Microsoft Entra ID authentication for Linux # Himmelblau: Microsoft Entra ID authentication for Linux
# #
# Authenticates Linux users against the digistate.nl Entra ID tenant. # Authenticates Linux users against the digistate.nl Entra ID tenant.
@@ -14,7 +19,6 @@
# References: # References:
# - https://himmelblau-idm.org/docs/ # - https://himmelblau-idm.org/docs/
# - https://github.com/himmelblau-idm/himmelblau # - https://github.com/himmelblau-idm/himmelblau
let let
cfg = config.work.himmelblau; cfg = config.work.himmelblau;
in { in {
@@ -23,16 +27,18 @@ in {
]; ];
options.work.himmelblau = { options.work.himmelblau = {
enable = lib.mkEnableOption "Himmelblau Entra ID authentication" // { enable =
default = false; lib.mkEnableOption "Himmelblau Entra ID authentication"
}; // {
default = false;
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
services.himmelblau = { services.himmelblau = {
enable = true; enable = true;
settings = { settings = {
domain = [ "digistate.nl" ]; domain = ["digistate.nl"];
# Uncomment and set to Entra ID group Object IDs or names to # Uncomment and set to Entra ID group Object IDs or names to
# restrict which users can authenticate: # restrict which users can authenticate:
# pam_allow_groups = [ "ENTRA-GROUP-GUID-HERE" ]; # pam_allow_groups = [ "ENTRA-GROUP-GUID-HERE" ];
+6 -3
View File
@@ -1,10 +1,13 @@
{ config, lib, pkgs, inputs, ... }: {
config,
lib,
inputs,
...
}:
# Microsoft Defender for Endpoint # Microsoft Defender for Endpoint
# #
# References: # References:
# - https://github.com/epetousis/nix-mdatp # - https://github.com/epetousis/nix-mdatp
let let
cfg = config.work.mdatp; cfg = config.work.mdatp;
in { in {
+2 -3
View File
@@ -1,8 +1,7 @@
{ config, lib, pkgs, ... }: {...}: {
{
# Overlay to add cisco-secure-client to pkgs # Overlay to add cisco-secure-client to pkgs
nixpkgs.overlays = [ nixpkgs.overlays = [
(self: super: { (_self: super: {
cisco-secure-client = super.callPackage ./pkgs/cisco-secure-client {}; cisco-secure-client = super.callPackage ./pkgs/cisco-secure-client {};
}) })
]; ];