Nix-Vibe public snapshot (squashed history)
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
# Carla — audio plugin host / JACK & PipeWire patchbay
|
||||
{
|
||||
environment.systemPackages = [
|
||||
pkgs.carla
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
)
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
environment.systemPackages = with pkgs; [
|
||||
opencode
|
||||
opencode-desktop
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
services.flatpak.packages = [
|
||||
"io.github.Soundux"
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 -"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{ config
|
||||
, lib
|
||||
, inputs
|
||||
, diskoConfigPath
|
||||
, ...
|
||||
}:
|
||||
|
||||
{
|
||||
imports = [ inputs.disko.nixosModules.default ];
|
||||
disko.devices = import diskoConfigPath;
|
||||
}
|
||||
Reference in New Issue
Block a user