Nix-Vibe public snapshot (squashed history)

This commit is contained in:
2026-09-19 13:56:12 +01:00
commit aee8fb1e9b
119 changed files with 18895 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
{ config
, pkgs
, lib
, ...
}:
{
imports = [
./settings.nix
./fonts.nix
./sops.nix
./users.nix
];
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
programs.zsh.enable = true;
# SSH is key-only on all hosts. petere (the only SSH user) authenticates with
# an authorized key; GUI users keep their local password for console/GUI login
# but cannot use it over SSH. Root SSH is fully disabled.
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "no";
};
};
# Passwordless sudo for the admin/agent user only. Other wheel members
# (e.g. caitlin, mary) must enter their password for sudo.
security.sudo.extraRules = [
{
users = [ "petere" ];
commands = [
{
command = "ALL";
options = [ "NOPASSWD" ];
}
];
}
];
system.activationScripts.exportAgeKey = {
text = ''
if [ -f /root/.config/sops/age/keys.txt ]; then
${pkgs.age}/bin/age-keygen -y < /root/.config/sops/age/keys.txt > /tmp/age-public-key.txt
chmod 644 /tmp/age-public-key.txt
fi
'';
deps = [ ];
};
# Automatic Nix store garbage collection and optimisation to prevent disk
# creep. Runs every 2 hours; persistent ensures missed runs are caught up on boot.
nix.gc = {
automatic = true;
dates = "*-*-* 0/2:00:00";
persistent = true;
};
# Keep the last 5 system generations (regardless of age), then collect garbage.
# nix.gc.options only accepts nix-collect-garbage flags (age-based), so we
# override the service to add count-based generation pruning.
systemd.services.nix-gc.script = lib.mkForce ''
${pkgs.nix}/bin/nix-env --profile /nix/var/nix/profiles/system --delete-generations +5
exec ${pkgs.nix}/bin/nix-collect-garbage
'';
nix.optimise.automatic = true;
environment.systemPackages = with pkgs; [
# Antigravity removed - no longer used
];
system.stateVersion = "23.11";
}
+13
View File
@@ -0,0 +1,13 @@
{ pkgs, ... }:
{
# nixfmt is the standard Nix formatter (faster, stricter, future-proof).
# The flake formatter is also set to nixfmt.
environment.systemPackages = with pkgs; [
nixfmt
python3
flutter
uv
sops
];
}
+17
View File
@@ -0,0 +1,17 @@
{ config, pkgs, ... }:
{
fonts.fontconfig.enable = true;
fonts.fontDir.enable = true;
fonts.packages = with pkgs; [
fira-code
fira-code-symbols
nerd-fonts.fira-code
corefonts
vista-fonts
];
systemd.tmpfiles.rules = [
"L+ /usr/share/fonts - - - - /run/current-system/sw/share/X11/fonts"
];
}
+22
View File
@@ -0,0 +1,22 @@
{ config, lib, ... }:
let
cfg = config.my.knownHosts;
in
{
options.my.knownHosts = {
mcfServer = lib.mkEnableOption "mcf-server SSH host key";
richmondServer = lib.mkEnableOption "richmond-server SSH host key";
};
config = lib.mkMerge [
(lib.mkIf cfg.mcfServer {
services.openssh.knownHosts."mcf-server".publicKey =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOeOgD+GNQw5Isw/AumZcDFzdzO6YnKJEFWcuUcKPI2";
})
(lib.mkIf cfg.richmondServer {
services.openssh.knownHosts."richmond-server".publicKey =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEqDXL9w8QwUcqxtW3kyHW/LUDqCGqf6JQ3ZZw52vRQY";
})
];
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs, ... }:
{
# Common admin/monitoring tools available on every host.
environment.systemPackages = with pkgs; [
htop
bottom
iotop
ncdu
ripgrep
fd
jq
lsof
tmux
];
# Firmware updates for all machines (laptops AND servers).
services.fwupd.enable = true;
}
+17
View File
@@ -0,0 +1,17 @@
{ pkgs, config, ... }:
{
virtualisation.podman = {
enable = true;
dockerCompat = true;
dockerSocket.enable = true;
defaultNetwork.settings.dns_enabled = false;
};
virtualisation.oci-containers.backend = "podman";
environment.systemPackages = with pkgs; [
podman-compose
];
}
+34
View File
@@ -0,0 +1,34 @@
{ config
, pkgs
, lib
, ...
}:
{
# Set your time zone
time.timeZone = "Europe/London";
# Configure console keyboard layout
console.keyMap = "uk";
# Configure X server keyboard layout if X server is enabled
services.xserver.xkb.layout = lib.mkIf config.services.xserver.enable "gb";
services.tailscale.enable = true;
users.mutableUsers = false;
nix.settings = {
accept-flake-config = true;
trusted-users = [ "petere" ];
experimental-features = [
"nix-command"
"flakes"
];
};
environment.systemPackages = with pkgs; [
# gemini-cli
kitty.terminfo
];
}
+20
View File
@@ -0,0 +1,20 @@
{ config
, pkgs
, inputs
, ...
}:
{
sops = {
age = {
keyFile = "/root/.config/sops/age/keys.txt";
generateKey = false;
};
defaultSopsFile = ../../secrets.yaml;
secrets."gemini-api-key" = {
owner = "petere";
group = "users";
mode = "0440";
};
};
}
+67
View File
@@ -0,0 +1,67 @@
{ config
, pkgs
, lib
, ...
}:
let
cfg = config.my.users.petere;
in
{
options.my.users.petere = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to create the petere admin user.";
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional GECOS description for petere.";
};
hashedPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Path to the hashed password file, or null for SSH-key-only access.";
};
subUidStart = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Start UID for petere's rootless subuid range, or null to disable.";
};
subGidStart = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Start GID for petere's rootless subgid range, or null to disable.";
};
};
config = lib.mkIf cfg.enable {
users.users.petere = {
isNormalUser = true;
shell = pkgs.zsh;
extraGroups = [ "wheel" ];
description = lib.mkIf (cfg.description != null) cfg.description;
hashedPasswordFile = lib.mkIf (cfg.hashedPasswordFile != null) cfg.hashedPasswordFile;
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJiCtkYDBfieK3i4TbVomeyXa185yCFZUvrbMamR4bqs petere@x1carbon"
];
subUidRanges = lib.mkIf (cfg.subUidStart != null) [
{
startUid = cfg.subUidStart;
count = 65536;
}
];
subGidRanges = lib.mkIf (cfg.subGidStart != null) [
{
startGid = cfg.subGidStart;
count = 65536;
}
];
};
};
}
+8
View File
@@ -0,0 +1,8 @@
{ pkgs, ... }:
# Carla — audio plugin host / JACK & PipeWire patchbay
{
environment.systemPackages = [
pkgs.carla
];
}
+12
View File
@@ -0,0 +1,12 @@
{ pkgs, ... }:
# DVD authoring and burning tools for mcf-stream
{
environment.systemPackages = with pkgs; [
brasero # GNOME disc burning (data & video DVD)
dvdstyler # DVD authoring with menus
dvdauthor # CLI tooling for authoring DVD video
libdvdcss # Play/rip encrypted DVDs
gnome-multi-writer # USB/optical image writer
];
}
+42
View File
@@ -0,0 +1,42 @@
{ config, pkgs, ... }:
let
freeshowAppImage = pkgs.stdenv.mkDerivation {
pname = "freeshow-appimage";
version = "1.5.6";
src = pkgs.fetchurl {
url = "https://github.com/ChurchApps/FreeShow/releases/download/v1.5.6/FreeShow-1.5.6-x86_64.AppImage";
sha256 = "10z1xgqzwfbs4api12myyx368yp8ahzbmjpfmwnh8c340f8b22f4";
};
dontUnpack = true;
dontBuild = true;
installPhase = ''
mkdir -p $out/bin
ln -s ${pkgs.appimage-run}/bin/appimage-run $out/bin/freeshow
mkdir -p $out/share/applications
cat > $out/share/applications/freeshow.desktop << EOF
[Desktop Entry]
Name=FreeShow
Exec=${pkgs.appimage-run}/bin/appimage-run $src
Icon=freeshow
Type=Application
Categories=Multimedia;
EOF
'';
meta = with pkgs.lib; {
description = "FreeShow AppImage wrapper";
homepage = "https://github.com/ChurchApps/FreeShow";
license = licenses.gpl3Only; # Assuming GPLv3 based on GitHub repo
platforms = platforms.linux;
};
};
in
{
environment.systemPackages = [
freeshowAppImage
];
}
+66
View File
@@ -0,0 +1,66 @@
{ pkgs, ... }:
let
mixingStation = pkgs.stdenv.mkDerivation rec {
pname = "mixing-station";
version = "3.1.4";
src = pkgs.fetchurl {
url = "https://mixingstation.app/backend/api/web/download/update/mixing-station-pc/release";
sha256 = "sha256-8d+rGfcOMz8ZoaTy4wd9SEExyc/IaUDysf1WC8xyTC0=";
};
nativeBuildInputs = [
pkgs.unzip
pkgs.makeWrapper
];
buildInputs = with pkgs; [
libGL
libx11
libxext
libxcursor
libxrandr
libxxf86vm
libxi
libpulseaudio
];
unpackPhase = ''
unzip $src
'';
installPhase = ''
mkdir -p $out/bin
mkdir -p $out/share/mixing-station
cp -r * $out/share/mixing-station/
makeWrapper ${pkgs.openjdk21}/bin/java $out/bin/mixing-station \
--add-flags "-jar $out/share/mixing-station/mixing-station-desktop.jar" \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath buildInputs}
mkdir -p $out/share/applications
cat > $out/share/applications/mixing-station.desktop << EOF
[Desktop Entry]
Name=Mixing Station
Exec=$out/bin/mixing-station
Icon=audio-mixer
Type=Application
Categories=AudioVideo;Audio;
Comment=Remote control for digital mixers
EOF
'';
meta = with pkgs.lib; {
description = "Remote control for digital mixers";
homepage = "https://mixingstation.app/";
license = licenses.unfree;
platforms = platforms.linux;
};
};
in
{
environment.systemPackages = [
mixingStation
];
}
+20
View File
@@ -0,0 +1,20 @@
{ pkgs, ... }:
# OBS Studio for live streaming/recording on mcf-stream
{
environment.systemPackages = [
(
(pkgs.wrapOBS.override {
# Enable NVIDIA NVENC GPU encoding
obs-studio = pkgs.obs-studio.override { cudaSupport = true; };
})
{
plugins = with pkgs.obs-studio-plugins; [
distroav
obs-backgroundremoval
obs-teleport
];
}
)
];
}
+8
View File
@@ -0,0 +1,8 @@
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [
opencode
opencode-desktop
];
}
+42
View File
@@ -0,0 +1,42 @@
{ pkgs, ... }:
{
# Install OpenLP from official flatpak bundle
# Update the version in the URL when new releases are available
systemd.services.openlp-flatpak-install = {
description = "Install OpenLP from official flatpak bundle";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "install-openlp" ''
set -e
OPENLP_VERSION="3.1.7"
OPENLP_URL="https://get.openlp.org/$OPENLP_VERSION/openlp-$OPENLP_VERSION-1.flatpak"
OPENLP_ID="org.openlp.OpenLP"
# Check if already installed
if ${pkgs.flatpak}/bin/flatpak list --app | grep -q "$OPENLP_ID"; then
echo "OpenLP is already installed"
exit 0
fi
# Download and install the bundle
echo "Downloading OpenLP $OPENLP_VERSION..."
TEMP_FILE=$(${pkgs.coreutils}/bin/mktemp)
${pkgs.curl}/bin/curl -L -o "$TEMP_FILE" "$OPENLP_URL"
echo "Installing OpenLP from bundle..."
${pkgs.flatpak}/bin/flatpak install --system --bundle --noninteractive -y "$TEMP_FILE"
# Cleanup
${pkgs.coreutils}/bin/rm -f "$TEMP_FILE"
echo "OpenLP installed successfully"
'';
};
};
}
+5
View File
@@ -0,0 +1,5 @@
{
services.flatpak.packages = [
"io.github.Soundux"
];
}
+22
View File
@@ -0,0 +1,22 @@
{ pkgs, ... }:
let
x32editDesktop = pkgs.makeDesktopItem {
name = "X32-Edit";
desktopName = "X32-Edit";
genericName = "Digital Mixer Editor";
exec = "x32-edit";
icon = "audio-mixer"; # Using a generic audio mixer icon as a fallback
categories = [
"AudioVideo"
"Audio"
];
comment = "Editor for the Behringer X32 digital mixer";
};
in
{
environment.systemPackages = [
pkgs.x32edit
x32editDesktop
];
}
+14
View File
@@ -0,0 +1,14 @@
{ config, pkgs, ... }:
{
services.xserver.enable = true;
services.displayManager.gdm.enable = true;
services.desktopManager.gnome.enable = true;
# Enable XDG Desktop Portal for Flatpak integration
xdg.portal.enable = true;
xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
# Enable dconf system service
programs.dconf.enable = true;
}
+138
View File
@@ -0,0 +1,138 @@
{
pkgs,
...
}:
let
onlyoffice-with-fonts = pkgs.buildFHSEnv {
name = "onlyoffice-desktopeditors";
targetPkgs =
pkgs': with pkgs'; [
onlyoffice-desktopeditors
corefonts
vista-fonts
fira-code
fira-code-symbols
nerd-fonts.fira-code
dejavu_fonts
liberation_ttf
];
runScript = "onlyoffice-desktopeditors";
extraInstallCommands = ''
mkdir -p $out/share/applications
cp -r ${pkgs.onlyoffice-desktopeditors}/share/icons $out/share/
cp -r ${pkgs.onlyoffice-desktopeditors}/share/applications/* $out/share/applications/
substituteInPlace $out/share/applications/onlyoffice-desktopeditors.desktop \
--replace-fail "${pkgs.onlyoffice-desktopeditors}/bin/onlyoffice-desktopeditors" "$out/bin/onlyoffice-desktopeditors"
'';
};
in
{
networking.networkmanager.enable = true;
hardware.bluetooth.enable = true;
# Allow the desktop user to pair/connect devices through bluez without root
# (bluetoothctl's device flows surface the org.bluez.agent polkit action).
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if (action.id === "org.bluez.agent" && subject.user === "petere")
return polkit.Result.YES;
});
'';
# GNOME Keyring: credential store for desktop apps (Nextcloud OAuth tokens,
# Element session keys, etc.). The PAM module auto-unlocks the keyring at
# login so apps get seamless access to stored secrets across reboots.
services.gnome.gnome-keyring.enable = true;
services.flatpak = {
enable = true;
packages = [
"it.mijorus.gearlever"
"com.github.tchx84.Flatseal"
];
# Pin Flathub remote explicitly with GPG verification.
remotes = [
{
name = "flathub";
location = "https://dl.flathub.org/repo/flathub.flatpakrepo";
}
];
};
# Weekly Flatpak garbage collection: remove unused runtimes and orphaned refs.
systemd.services.flatpak-gc = {
description = "Flatpak garbage collection (remove unused runtimes)";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.flatpak}/bin/flatpak uninstall --unused -y";
};
};
systemd.timers.flatpak-gc = {
description = "Run Flatpak garbage collection weekly";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "weekly";
Persistent = true;
};
};
# Enable CUPS to print documents.
services.printing.enable = true;
# Enable Avahi for network printer discovery.
services.avahi = {
enable = true;
nssmdns4 = true;
openFirewall = true;
publish = {
enable = true;
addresses = true;
userServices = true;
};
};
# avahi requires /run/avahi-daemon to be owned by the avahi user, and
# restarts during `nixos-rebuild switch` leave a stale pid file that makes
# the next start fail ("Failed to create PID file: File exists"). Without
# both fixes avahi-daemon fails on every rebuild, which also breaks
# `nixos-rebuild switch` (switch-to-configuration exits non-zero).
systemd.services.avahi-daemon.preStart = "rm -f /run/avahi-daemon/pid";
systemd.tmpfiles.rules = [
"d /run/avahi-daemon 0755 avahi avahi -"
];
services.printing.drivers = [
pkgs.brlaser
pkgs.brgenml1lpr
pkgs.brgenml1cupswrapper
];
hardware.printers.ensurePrinters = [
{
name = "Brother_MFC_L2710DW";
deviceUri = "implicitclass://Brother_MFC_L2710DW_series/";
model = "drv:///brlaser.drv/brl2710w.ppd"; # Let CUPS determine the PPD from installed drivers
}
];
programs.firefox.enable = true;
environment.systemPackages = with pkgs; [
vim
git
wget
appimage-run
bitwarden-desktop
chromium
vlc
python3Packages.python-vlc
stable.element-desktop
pkgs.kdePackages.kdenlive
thunderbird
nextcloud-client
nautilus-python
onlyoffice-with-fonts
];
}
+107
View File
@@ -0,0 +1,107 @@
{ config, pkgs, ... }:
# Hyprland Wayland compositor + QuickShell desktop shell.
# This is the Hyprland counterpart to modules/desktop/gnome.nix — import this
# INSTEAD of gnome.nix when a host should run Hyprland + QuickShell.
{
# Hyprland compositor with XWayland (for legacy apps)
programs.hyprland = {
enable = true;
xwayland.enable = true;
};
# hyprlock PAM service — required for password authentication to work.
programs.hyprlock.enable = true;
# Minimal display manager: greetd + tuigreet (TTY greeter).
# The greetd NixOS module creates the "greeter" system user/group for us.
services.greetd = {
enable = true;
useTextGreeter = true;
settings = {
default_session = {
# Launch via start-hyprland (not the raw Hyprland binary) so the session
# env (XDG_CURRENT_DESKTOP, portals, etc.) is set up and the
# "started without start-hyprland" watchdog warning is avoided.
command = "${pkgs.tuigreet}/bin/tuigreet --time --remember --cmd start-hyprland";
user = "greeter";
};
};
};
# Enable XDG Desktop Portal (Flatpak/file pickers/screen sharing) via Hyprland
xdg.portal = {
enable = true;
extraPortals = [ pkgs.xdg-desktop-portal-hyprland ];
# Explicit portal backend config (xdg-desktop-portal >= 1.17 behavior).
config.common.default = "hyprland";
};
# dconf is required by many GTK apps regardless of desktop environment
programs.dconf.enable = true;
# NixOS grants all NetworkManager polkit actions to members of the
# "networkmanager" group. Membership is required here because greetd starts
# the graphical session inside the greetd.service cgroup instead of an
# *active* logind session scope, so NetworkManager's default
# allow_active-only policy would reject connection changes from the
# QuickShell wifi menu / nmcli with "insufficient privileges".
users.groups.networkmanager.members = [ "petere" ];
# Audio via PipeWire (GNOME pulls this in automatically; Hyprland does not)
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
wireplumber.enable = true;
};
security.rtkit.enable = true;
# UPower daemon — the QuickShell bar reads battery state through
# Quickshell.Services.UPower.
services.upower.enable = true;
# Power profiles (power-saver / balanced / performance) over DBus — exposed
# in the QuickShell quick-settings panel via PowerProfiles.profile.
services.power-profiles-daemon.enable = true;
# Explicit lid policy: closing the lid always locks then suspends — on
# battery AND on external power. hypridle's `before_sleep_cmd =
# loginctl lock-session` fires before logind sleeps (lock_cmd -> hyprlock),
# so reopening the lid resumes to a locked screen. Docked stays on logind's
# default "ignore" so external displays keep working with the lid shut.
services.logind.settings.Login = {
HandleLidSwitch = "suspend";
HandleLidSwitchExternalPower = "suspend";
};
# Core Wayland / Hyprland ecosystem + QuickShell shell
environment.systemPackages = with pkgs; [
quickshell
hyprlock
hypridle
hyprpaper
hyprpicker
wlogout
wayland-utils
wl-clipboard
grim
slurp
grimblast
wofi
# Screen backlight control (QuickShell quick-settings brightness slider)
brightnessctl
# Media keys: volume (PipeWire via Pulse compat), MPRIS playback control
pamixer
playerctl
# notify-send for QuickShell battery-low toasts (native NotificationServer)
libnotify
# Blue-light filter toggled from Hyprland (SUPER+SHIFT+N)
hyprsunset
# File manager (lightweight GTK; gvfs adds trash/mount support)
thunar
gvfs
];
}
+186
View File
@@ -0,0 +1,186 @@
# HP battery charge limiter (HP ENVY x360 / similar, board 81AD).
#
# HP firmware exposes no percentage-based charge threshold (no
# charge_control_end_threshold sysfs on this machine). Instead the DSDT
# exposes ACPI charge-mode methods in the root scope (\):
#
# \SBCC <n> -> miscellaneous battery charge control register writes
# \SBCO <n> -> set battery charge operation:
# 0x0500 = inhibit charge (stop charging / hold level)
# 0x0000 = auto (normal charging) <-- restores on 81AD
# 0x0200 = force discharge (drain while on AC)
# \GBCC -> read back charge control
# \GBCO -> read back charge operation mode
#
# These are driven from userspace through the `acpi_call` kernel module
# (/proc/acpi/call). The "limit to 80%" is implemented as a policy on top:
# a root systemd poller inhibits charge once capacity >= 80% and re-enables
# auto charging below ~75%. This mirrors what the HP kernel-driver RFC
# (platform/x86: hp-wmi charge behaviour support) describes leaving to
# userspace while only the mode toggles live in the firmware.
#
# Control from the shell / CLI:
# battery-charge-limit on|off|status
# `on`/`off` persist the choice to /var/lib/hp-battery-charge-limit/state
# and are applied immediately; the systemd service re-applies the policy
# on a poll tick. petere has NOPASSWD sudo so the QuickShell cog can call
# `sudo battery-charge-limit on/off` directly.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.hardware.hpBatteryLimit;
stateDir = "/var/lib/hp-battery-charge-limit";
stateFile = "${stateDir}/state";
# charge mode constants read from Arg0 by SBCO (high byte).
acpiInhibit = ''\\SBCO 0x0500'';
acpiAuto = ''\\SBCO 0x0000'';
controlScript = pkgs.writeShellScriptBin "battery-charge-limit" ''
set -eu
state_dir=${stateDir}
state_file=${stateFile}
battery=/sys/class/power_supply/BAT0
acpi_proc=/proc/acpi/call
stop_at=${toString cfg.stopAt}
resume_at=${toString cfg.resumeAt}
acpi() {
# $1 = ACPI method + args, e.g. "\SBCO 0x0500"
if [ ! -e "$acpi_proc" ]; then
echo "acpi_call module not loaded" >&2
return 1
fi
printf '%s\n' "$1" > "$acpi_proc" || return 1
cat "$acpi_proc" || true
}
read_capacity() {
cat "$battery/capacity" 2>/dev/null | tr -d '\n' || echo 0
}
ensure_state() {
mkdir -p "$state_dir"
if [ ! -f "$state_file" ]; then
echo "${if cfg.defaultOn then "on" else "off"}" > "$state_file"
fi
}
get_state() {
ensure_state
cat "$state_file"
}
apply_policy() {
local state capacity
state=$(get_state)
capacity=$(read_capacity)
case "$state" in
on)
if [ "$capacity" -ge "$stop_at" ]; then
acpi "${acpiInhibit}" || true
elif [ "$capacity" -le "$resume_at" ]; then
acpi "${acpiAuto}" || true
fi
;;
*)
# off (or unknown) -> normal charging
acpi "${acpiAuto}" || true
;;
esac
}
case "$1" in
on)
ensure_state
echo on > "$state_file"
apply_policy
echo on
;;
off)
ensure_state
echo off > "$state_file"
apply_policy
echo off
;;
status)
get_state
;;
apply)
apply_policy
;;
*)
echo "usage: battery-charge-limit {on|off|status|apply}" >&2
exit 2
;;
esac
'';
in
{
options.my.hardware.hpBatteryLimit = {
enable = lib.mkEnableOption "HP battery charge limiter (stop charging at stopAt%, re-enable below resumeAt).";
stopAt = lib.mkOption {
type = lib.types.int;
default = 80;
description = "Charge capacity % at which charging is inhibited.";
};
resumeAt = lib.mkOption {
type = lib.types.int;
default = 75;
description = "Charge capacity % below which auto charging is re-enabled.";
};
defaultOn = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Start with limiting active on first boot (state file not present yet).";
};
pollInterval = lib.mkOption {
type = lib.types.int;
default = 120;
description = "Seconds between policy re-applications by the systemd poller.";
};
};
config = lib.mkIf cfg.enable {
# acpi_call -> /proc/acpi/call so userspace can invoke SBCC/SBCO.
boot.extraModulePackages = [ config.boot.kernelPackages.acpi_call ];
boot.kernelModules = [ "acpi_call" ];
environment.systemPackages = [
controlScript
# Compatibility alias: older QuickShell configs referenced the
# hp-prefixed binary name; keep it working until all hosts redeploy.
(pkgs.writeShellScriptBin "hp-battery-charge-limit" ''
exec ${controlScript}/bin/battery-charge-limit "$@"
'')
];
systemd.services.battery-charge-limit = {
description = "HP battery charge limiter (${toString cfg.stopAt}% cap)";
wantedBy = [ "multi-user.target" ];
after = [ "systemd-modules-load.service" ];
wants = [ "systemd-modules-load.service" ];
serviceConfig = {
Type = "simple";
ExecStart = pkgs.writeShellScript "battery-charge-limit-loop" ''
set -eu
trap 'exit 0' TERM INT
while true; do
${controlScript}/bin/battery-charge-limit apply >/dev/null || true
sleep ${toString cfg.pollInterval}
done
'';
Restart = "on-failure";
RemainAfterExit = false;
};
};
};
}
+16
View File
@@ -0,0 +1,16 @@
{ config, lib, ... }:
let
cfg = config.my.hardware.laptop;
in
{
options.my.hardware.laptop = {
enable = lib.mkEnableOption "Laptop-specific hardware support (fingerprint reader).";
};
config = lib.mkIf cfg.enable {
# Fingerprint authentication - laptops only.
# (fwupd is enabled globally via modules/core/management.nix)
services.fprintd.enable = true;
};
}
+51
View File
@@ -0,0 +1,51 @@
{ config, lib, ... }:
let
cfg = config.my.hardware.nvidia;
in
{
options.my.hardware.nvidia = {
enable = lib.mkEnableOption "NVIDIA proprietary driver configuration";
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "NVIDIA driver package, or null to use the kernel's default.";
};
nvidiaSettings = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the nvidia-settings GUI tool.";
};
powerManagement = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable NVIDIA power management (persistence mode).";
};
enable32Bit = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable 32-bit OpenGL/Vulkan libraries.";
};
};
config = lib.mkIf cfg.enable {
hardware.graphics = {
enable = true;
enable32Bit = cfg.enable32Bit;
};
services.xserver.videoDrivers = [ "nvidia" ];
hardware.nvidia = {
modesetting.enable = true;
open = false; # Proprietary driver (pre-Turing/legacy architectures)
nvidiaSettings = cfg.nvidiaSettings;
powerManagement.enable = cfg.powerManagement;
package = lib.mkIf (cfg.package != null) cfg.package;
};
};
}
+57
View File
@@ -0,0 +1,57 @@
# Tablet mode support for 2-in-1 convertibles (currently wired to the
# ThinkPad X1 Yoga Gen 6).
#
# In tablet mode (screen folded flat behind the keyboard) the display should
# follow the device orientation and an on-screen keyboard should appear when a
# text field is focused. This module provides that in three pieces:
#
# 1. The NixOS half (here): grants petere membership of the `input` group so
# the user-level daemon can read the tablet-mode evdev switch
# (/dev/input/event17, "Intel HID switches", SW_TABLET_MODE), and wires
# in the home-manager module (hyprland-tablet.nix) via sharedModules.
#
# 2. A user-level Python daemon (`hyprland-tablet` daemon) that:
# - watches SW_TABLET_MODE via python-evdev;
# - parses `monitor-sensor` output for accelerometer orientation
# (iio-sensor-proxy, already running on this host);
# - applies `transform` to the eDP-1 monitor (and matching per-device
# transforms for touch/tablet inputs) via hyprctl;
# - starts/stops `wvkbd --auto` so a virtual keyboard shows when a text
# input is focused while in tablet mode.
#
# 3. A `hyprland-tablet` CLI (status / keyboard toggle) used by the
# QuickShell bar chip and the SUPER+CTRL+K keybind.
#
# Why not iio-hyprland? It rotates on every accelerometer reading regardless
# of tablet mode; this host's iio-sensor-proxy build has no hinge support, so
# orientation must be gated on the SW_TABLET_MODE switch ourselves.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.hardware.tabletMode;
in
{
options.my.hardware.tabletMode = {
enable = lib.mkEnableOption "tablet-mode auto-rotation + on-screen keyboard for 2-in-1 convertibles";
};
config = lib.mkIf cfg.enable {
# petere needs to read /dev/input/event17 (root:input 660) for the
# SW_TABLET_MODE switch. The Intel HID switch device is unique to this
# Yoga, so we match it explicitly and grant it to petere. This works for
# the current session immediately -- no re-login needed to pick up a group.
users.users.petere.extraGroups = lib.mkBefore [ "input" ];
services.udev.extraRules = ''
KERNEL=="event*", SUBSYSTEM=="input", ATTRS{name}=="Intel HID switches", OWNER="petere", MODE="0660", TAG+="uaccess"
'';
home-manager.sharedModules = [
../../home-manager/modules/hyprland-tablet.nix
];
};
}
+143
View File
@@ -0,0 +1,143 @@
# ThinkPad battery charge limiter (native sysfs thresholds).
#
# Unlike the HP module (modules/hardware/hp-battery-limit.nix), ThinkPad
# firmware exposes the battery charge thresholds directly via sysfs:
#
# /sys/class/power_supply/BAT0/charge_control_start_threshold (resume point)
# /sys/class/power_supply/BAT0/charge_control_end_threshold (stop point)
#
# Writing a value < 100 to the end threshold stops charging at that capacity;
# charging resumes once capacity drops to/below the start threshold. The EC
# persists these across reboots (firmware NVRAM), so unlike the HP /proc magic
# no periodic poller is required — a one-shot `apply` at boot re-asserts the
# persisted choice in case the firmware was reset.
#
# Control from the shell / CLI (same interface as the HP module, so the
# QuickShell cog can drive either host):
# battery-charge-limit on|off|status|apply
# `on`/`off` persist the choice to /var/lib/battery-charge-limit/state and are
# applied immediately. petere has NOPASSWD sudo so the QuickShell cog can call
# `sudo battery-charge-limit on/off` directly.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.hardware.thinkpadBatteryLimit;
stateDir = "/var/lib/battery-charge-limit";
stateFile = "${stateDir}/state";
sysfs = "/sys/class/power_supply/BAT0";
controlScript = pkgs.writeShellScriptBin "battery-charge-limit" ''
set -eu
state_dir=${stateDir}
state_file=${stateFile}
start_threshold=${sysfs}/charge_control_start_threshold
end_threshold=${sysfs}/charge_control_end_threshold
capacity=${sysfs}/capacity
stop_at=${toString cfg.stopAt}
resume_at=${toString cfg.resumeAt}
read_end_threshold() {
cat "$end_threshold" 2>/dev/null || echo 100
}
ensure_state() {
mkdir -p "$state_dir"
if [ ! -f "$state_file" ]; then
# Infer the current firmware state so the toggle reflects reality on
# first run (e.g. a limit set from BIOS/Vantage persists in the EC).
if [ "$(read_end_threshold)" -lt "$stop_at" ]; then
echo on > "$state_file"
else
echo off > "$state_file"
fi
fi
}
get_state() {
ensure_state
cat "$state_file"
}
apply_policy() {
local state
state=$(get_state)
case "$state" in
on)
# Enable the limit: resume charging below resume_at, stop at stop_at.
echo "$resume_at" > "$start_threshold"
echo "$stop_at" > "$end_threshold"
;;
*)
# off (or unknown) -> full charge range (0..100).
echo 0 > "$start_threshold"
echo 100 > "$end_threshold"
;;
esac
}
case "$1" in
on)
ensure_state
echo on > "$state_file"
apply_policy
echo on
;;
off)
ensure_state
echo off > "$state_file"
apply_policy
echo off
;;
status)
get_state
;;
apply)
apply_policy
;;
*)
echo "usage: battery-charge-limit {on|off|status|apply}" >&2
exit 2
;;
esac
'';
in
{
options.my.hardware.thinkpadBatteryLimit = {
enable = lib.mkEnableOption "ThinkPad battery charge limiter (stop charging at stopAt%, re-enable below resumeAt%).";
stopAt = lib.mkOption {
type = lib.types.int;
default = 80;
description = "Charge capacity % at which charging is inhibited (charge_control_end_threshold).";
};
resumeAt = lib.mkOption {
type = lib.types.int;
default = 75;
description = "Charge capacity % below which auto charging is re-enabled (charge_control_start_threshold).";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ controlScript ];
# One-shot re-assert at boot (ThinkPad EC persists thresholds, so no
# poller is needed; this only recovers from firmware resets).
systemd.services.battery-charge-limit = {
description = "ThinkPad battery charge limiter (${toString cfg.stopAt}% cap)";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${controlScript}/bin/battery-charge-limit apply";
};
};
};
}
+72
View File
@@ -0,0 +1,72 @@
{ config
, pkgs
, lib
, ...
}:
{
options.services.backrest = {
enable = lib.mkEnableOption "Backrest web UI for restic backup";
port = lib.mkOption {
type = lib.types.port;
default = 9898;
description = "Port for the Backrest web UI";
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address to bind Backrest to";
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/backrest";
description = "Directory for Backrest config and data";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the firewall for the Backrest web UI";
};
};
config = lib.mkIf config.services.backrest.enable {
systemd.tmpfiles.rules = [
"d ${config.services.backrest.dataDir} 0700 backrest backrest -"
];
users.users.backrest = {
isSystemUser = true;
group = "backrest";
home = config.services.backrest.dataDir;
createHome = true;
};
users.groups.backrest = { };
systemd.services.backrest = {
description = "Backrest web UI for restic backup";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "simple";
User = "backrest";
Group = "backrest";
ExecStart = ''
${pkgs.backrest}/bin/backrest \
-bind-address ${config.services.backrest.host}:${toString config.services.backrest.port} \
-data-dir ${config.services.backrest.dataDir} \
-restic-cmd ${pkgs.restic}/bin/restic
'';
Restart = "on-failure";
};
};
networking.firewall.allowedTCPPorts = lib.mkIf config.services.backrest.openFirewall [
config.services.backrest.port
];
};
}
+107
View File
@@ -0,0 +1,107 @@
{ config
, pkgs
, lib
, ...
}:
# Gitea git server, publicly exposed through an external reverse proxy.
#
# Topology:
# internet -> gitea.edley.me -> external reverse proxy (TLS terminator)
# -> Tailscale -> homeserver-1:port (Gitea, plain HTTP behind the proxy)
#
# Hardening posture (public instance):
# - HTTPS only (DISABLE_SSH) so there is no second public port to forward.
# - Invite-only: registration disabled, everything hidden behind sign-in.
# - The firewall admits ONLY the proxy's Tailscale IP; no other tailnet
# node (or the LAN) can reach Gitea directly.
# - X-Forwarded-* headers are trusted only from that proxy IP.
# - PUBLIC_URL_DETECTION = never pins all generated URLs to ROOT_URL
# (no Host-header hijacking).
# - OpenID disabled, migrations disabled (SSRF reduction), argon2 password
# hashing, 12-char minimum password, git hooks disabled.
{
options.services.gitea-server = {
enable = lib.mkEnableOption "Gitea git server";
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port Gitea listens on. Firewalled to the reverse proxy only.";
};
domain = lib.mkOption {
type = lib.types.str;
default = "gitea.edley.me";
description = "Public domain name of the instance.";
};
proxyIp = lib.mkOption {
type = lib.types.str;
description = "Tailscale IP of the external reverse proxy allowed to reach Gitea.";
};
};
config = lib.mkIf config.services.gitea-server.enable {
services.gitea = {
enable = true;
stateDir = "/data/gitea";
appName = "gitea: Git with a cup of tea";
database = {
type = "postgres";
createDatabase = true;
name = "gitea";
user = "gitea";
};
dump = {
enable = true;
interval = "daily";
type = "tar.zst";
};
settings = {
server = {
DOMAIN = config.services.gitea-server.domain;
ROOT_URL = "https://${config.services.gitea-server.domain}/";
HTTP_ADDR = "0.0.0.0";
HTTP_PORT = config.services.gitea-server.port;
DISABLE_SSH = true;
PUBLIC_URL_DETECTION = "never";
MINIMUM_KEY_SIZE_CHECK = true;
};
session = {
COOKIE_SECURE = true;
};
service = {
DISABLE_REGISTRATION = true;
REQUIRE_SIGNIN_VIEW = true;
SHOW_REGISTRATION_BUTTON = false;
};
security = {
REVERSE_PROXY_TRUSTED_PROXIES = config.services.gitea-server.proxyIp;
REVERSE_PROXY_LIMIT = 1;
MIN_PASSWORD_LENGTH = 12;
PASSWORD_HASH_ALGO = "argon2";
DISABLE_GIT_HOOKS = true;
};
openid = {
ENABLE_OPENID_SIGNIN = false;
ENABLE_OPENID_SIGNUP = false;
};
repository = {
DISABLE_MIGRATIONS = true;
};
other = {
SHOW_FOOTER_VERSION = false;
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false;
};
};
};
# The external proxy (TLS terminator for gitea.edley.me) connects over
# Tailscale. Admit ONLY its address on the Gitea port; the default input
# policy drops everything else. Update proxyIp when the proxy changes.
networking.firewall.extraInputRules = ''
ip saddr ${config.services.gitea-server.proxyIp} tcp dport ${toString config.services.gitea-server.port} accept
'';
};
}
+96
View File
@@ -0,0 +1,96 @@
{ config
, pkgs
, lib
, ...
}:
let
cfg = config.services.homepage;
in
{
options.services.homepage = {
enable = lib.mkEnableOption "Homepage dashboard (gethomepage.dev)";
port = lib.mkOption {
type = lib.types.port;
default = 8082;
description = "Port for the Homepage dashboard";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the firewall for the Homepage dashboard";
};
allowedHosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"localhost"
"127.0.0.1"
];
description = "Host header values Homepage is allowed to respond to";
};
settings = lib.mkOption {
type = lib.types.anything;
default = { };
description = "Homepage settings (settings.yaml). See https://gethomepage.dev/configs/settings/";
};
services = lib.mkOption {
type = lib.types.anything;
default = [ ];
description = "Homepage services (services.yaml). See https://gethomepage.dev/configs/services/";
};
widgets = lib.mkOption {
type = lib.types.anything;
default = [ ];
description = "Homepage widgets (widgets.yaml). See https://gethomepage.dev/configs/info-widgets/";
};
bookmarks = lib.mkOption {
type = lib.types.anything;
default = [ ];
description = "Homepage bookmarks (bookmarks.yaml). See https://gethomepage.dev/configs/bookmarks/";
};
customCSS = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Custom CSS for Homepage";
};
customJS = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Custom JavaScript for Homepage";
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
description = "Environment files passed to the Homepage service (for API keys etc.)";
};
};
config = lib.mkIf cfg.enable {
services.homepage-dashboard = {
enable = true;
listenPort = cfg.port;
openFirewall = cfg.openFirewall;
allowedHosts = lib.concatStringsSep "," (
map (host: "${host}:${toString cfg.port}") cfg.allowedHosts
);
inherit (cfg)
settings
services
widgets
bookmarks
customCSS
customJS
environmentFiles
;
};
};
}
+55
View File
@@ -0,0 +1,55 @@
{ config
, pkgs
, lib
, ...
}:
{
options.services.immich-server = {
enable = lib.mkEnableOption "Immich photo management server";
port = lib.mkOption {
type = lib.types.port;
default = 2283;
description = "Port for Immich server";
};
mediaLocation = lib.mkOption {
type = lib.types.path;
default = "/data/immich";
description = "Directory for storing uploaded photos and videos";
};
};
config = lib.mkIf config.services.immich-server.enable {
services.immich = {
enable = true;
host = "0.0.0.0";
port = config.services.immich-server.port;
mediaLocation = config.services.immich-server.mediaLocation;
# Firewall is managed by the host (homeserver-1 restricts to tailscale).
openFirewall = false;
# Restrict hardware-acceleration access to the explicit NVIDIA device
# nodes needed for NVENC/CUDA transcoding (GTX 960). null would grant
# access to ALL devices; an explicit list is the secure alternative.
accelerationDevices = [
"/dev/nvidia0"
"/dev/nvidiactl"
"/dev/nvidia-modeset"
"/dev/nvidia-uvm"
"/dev/nvidia-uvm-tools"
];
};
# The upstream services.immich module creates a tmpfiles `e` rule that
# sets the media directory to 0700 on every rebuild/switch. Setting that
# mode clobbers the POSIX ACL mask (to `---`), which in turn nullifies the
# `user:backrest` read ACL that lets the backup service read this directory.
# Override it to keep the directory group-readable/traversable so the ACL
# mask stays effective (matches the rule just above).
systemd.tmpfiles.settings.immich.${config.services.immich-server.mediaLocation}.e.mode =
lib.mkForce "0770";
systemd.tmpfiles.rules = [
"d ${config.services.immich-server.mediaLocation} 0770 immich immich -"
];
};
}
+125
View File
@@ -0,0 +1,125 @@
{ config
, pkgs
, lib
, ...
}:
let
# Jellyfin SSO plugin built from upstream release zip.
# Not in nixpkgs, so we package it here for use in a bind mount.
ssoPlugin = pkgs.stdenvNoCC.mkDerivation {
pname = "jellyfin-plugin-sso";
version = "4.0.0.4";
src = pkgs.fetchzip {
url = "https://github.com/9p4/jellyfin-plugin-sso/releases/download/v4.0.0.4/sso-authentication_4.0.0.4.zip";
hash = "sha256-MJTyE6CeVLk7mlugauJ/F6bpi1kYwNtzNmQeH3+CFeQ=";
stripRoot = false;
};
dontBuild = true;
installPhase = ''
mkdir -p $out
cp -r ./* $out/
'';
};
# Plugin directory name expected by Jellyfin (version-specific).
pluginDirName = "SSO-Auth_4.0.0.4";
in
{
options.services.jellyfin-server = {
enable = lib.mkEnableOption "Jellyfin media server";
port = lib.mkOption {
type = lib.types.port;
default = 8096;
description = "Port for Jellyfin web interface";
};
mediaLocation = lib.mkOption {
type = lib.types.path;
default = "/data/jellyfin";
description = "Directory for storing media files";
};
};
config = lib.mkIf config.services.jellyfin-server.enable {
services.jellyfin = {
enable = true;
openFirewall = true;
group = "jellyfin";
# Hardware acceleration for NVIDIA NVENC transcoding (GTX 960 on homeserver-1).
# Requires the legacy_580 driver branch for Maxwell GPUs.
hardwareAcceleration = {
enable = true;
type = "nvenc";
device = "/dev/nvidia0";
};
# Force encoding config so NVENC settings are applied on every restart.
# Without this, changes made in the web UI persist, but NixOS settings are ignored.
forceEncodingConfig = true;
transcoding = {
# Enable hardware encoding for H.264 and H.265 (HEVC).
enableHardwareEncoding = true;
# Optional: enable tone mapping for HDR → SDR conversion.
enableToneMapping = false;
};
};
# Media dir: petere writes via Samba (forced to group jellyfin), Jellyfin
# reads as group jellyfin. Not world-writable.
# Also ensure the plugins directory exists for the SSO plugin bind mount.
systemd.tmpfiles.rules = [
"d ${config.services.jellyfin-server.mediaLocation} 0775 root jellyfin -"
"d /var/lib/jellyfin/plugins 0755 jellyfin jellyfin -"
];
# Samba share is authenticated and restricted to petere (the only writer).
# No guest access; the Samba password is managed via SOPS (see
# samba-petere-password secret and the samba-set-password service below).
services.samba = {
enable = true;
openFirewall = true;
settings = {
global = {
"server role" = "standalone server";
security = "user";
};
jellyfin = {
path = "${config.services.jellyfin-server.mediaLocation}";
browseable = "yes";
"read only" = "no";
"guest ok" = "no";
"valid users" = "petere";
"force group" = "jellyfin";
"force create mode" = "0664";
"force directory mode" = "0775";
};
};
};
# Install the SSO plugin by copying from the Nix store.
# Jellyfin needs to write plugin metadata (meta.json), so we can't use
# a read-only bind mount from /nix/store. This oneshot service is
# idempotent: it only copies if the plugin dir doesn't exist or differs.
systemd.services.jellyfin-install-plugin-sso = {
description = "Install Jellyfin SSO Auth plugin";
wantedBy = [ "multi-user.target" ];
before = [ "jellyfin.service" ];
serviceConfig.Type = "oneshot";
script = ''
PLUGIN_DIR="/var/lib/jellyfin/plugins/${pluginDirName}"
SOURCE="${ssoPlugin}"
# Only copy if the plugin doesn't exist or source changed
if [ ! -d "$PLUGIN_DIR" ] || ! diff -rq "$SOURCE" "$PLUGIN_DIR" >/dev/null 2>&1; then
rm -rf "$PLUGIN_DIR"
cp -r "$SOURCE" "$PLUGIN_DIR"
chown -R jellyfin:jellyfin "$PLUGIN_DIR"
chmod -R u+w "$PLUGIN_DIR"
fi
'';
};
systemd.services.jellyfin = {
after = [ "jellyfin-install-plugin-sso.service" ];
wants = [ "jellyfin-install-plugin-sso.service" ];
};
};
}
+49
View File
@@ -0,0 +1,49 @@
{ config
, pkgs
, lib
, ...
}:
{
options.services.ntfy-container = {
enable = lib.mkEnableOption "ntfy service";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Host port to map to ntfy";
};
};
config = lib.mkIf config.services.ntfy-container.enable {
services.ntfy-sh = {
enable = true;
settings = {
base-url = "https://ntfy.edley.me";
# Bind to all interfaces - nginx proxy (in Docker container) needs to reach it.
# Security: firewall restricts access to nginx proxy machine only.
listen-http = ":${toString config.services.ntfy-container.port}";
cache-file = "/var/lib/ntfy-sh/cache.db";
auth-file = "/var/lib/ntfy-sh/user.db";
behind-proxy = true; # Explicitly enable proxy support (X-Forwarded-*)
# Security: Deny all access by default, require login
auth-default-access = "deny-all";
enable-signup = false;
enable-login = true;
# Require login for ALL web app actions - hides the public UI
# behind authentication so anonymous visitors can't even browse
# the web app.
require-login = true;
};
};
# Firewall: allow access only from nginx proxy machine (adjust IP as needed).
# Example: allow from 10.0.0.x (Tailscale IP of nginx proxy machine)
# networking.firewall.allowedTCPPorts = [ config.services.ntfy-container.port ];
# networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ config.services.ntfy-container.port ];
# Ensure state directory exists (ntfy-sh service usually creates it, but good to ensure permissions if needed)
# The systemd service for ntfy-sh usually handles DynamicUser/StateDirectory or similar.
# We can trust the upstream module for basic setup.
};
}
+44
View File
@@ -0,0 +1,44 @@
{ config
, pkgs
, lib
, ...
}:
{
options.services.paperless-service = {
enable = lib.mkEnableOption "Paperless-ngx service";
passwordFile = lib.mkOption {
type = lib.types.path;
default = "/etc/nixos/paperless-password";
description = "Path to the file containing the admin password";
};
port = lib.mkOption {
type = lib.types.port;
default = 28981;
description = "Port to listen on";
};
};
config = lib.mkIf config.services.paperless-service.enable {
services.paperless = {
enable = true;
passwordFile = config.services.paperless-service.passwordFile;
# Listening on 0.0.0.0 but the firewall only opens the port on tailscale0,
# so Paperless is reachable over Tailscale only (not the LAN).
address = "0.0.0.0";
port = config.services.paperless-service.port;
settings = {
PAPERLESS_OCR_LANGUAGE = "eng";
# Set the URL to the Tailscale hostname
PAPERLESS_URL = "http://x1carbon:${toString config.services.paperless-service.port}";
# Allow requests from the Tailscale hostname
PAPERLESS_ALLOWED_HOSTS = "x1carbon,x1carbon.tailscale.net";
};
};
# Tailscale-only access: open the port on the tailscale0 interface only.
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [
config.services.paperless-service.port
];
};
}
+11
View File
@@ -0,0 +1,11 @@
{ config
, lib
, inputs
, diskoConfigPath
, ...
}:
{
imports = [ inputs.disko.nixosModules.default ];
disko.devices = import diskoConfigPath;
}