refactor: split server modules into smaller more manageable files

This commit is contained in:
Leyla Becker 2025-09-16 10:14:33 -05:00
parent b2e5ae1f98
commit cdeb4e108b
49 changed files with 1519 additions and 1270 deletions

View file

@ -1,56 +0,0 @@
{
lib,
config,
...
}: let
dataDirectory = "/var/lib/actual/";
in {
options.services.actual = {
subdomain = lib.mkOption {
type = lib.types.str;
default = "actual";
description = "subdomain of base domain that actual will be hosted at";
};
};
config = lib.mkIf config.services.actual.enable (lib.mkMerge [
{
systemd.tmpfiles.rules = [
"d ${dataDirectory} 2770 actual actual"
];
services.actual = {
settings = {
ACTUAL_DATA_DIR = dataDirectory;
};
};
}
(lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.${config.services.actual.subdomain} = {
target = "http://localhost:${toString config.services.actual.settings.port}";
};
};
})
(lib.mkIf config.services.fail2ban.enable {
# TODO: configuration for fail2ban for actual
})
(lib.mkIf config.host.impermanence.enable {
assertions = [
{
assertion = config.services.actual.settings.ACTUAL_DATA_DIR == dataDirectory;
message = "actual data location does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
directories = [
{
directory = dataDirectory;
user = "actual";
group = "actual";
}
];
};
})
]);
}

View file

@ -0,0 +1,3 @@
{
dataDirectory = "/var/lib/actual/";
}

View file

@ -0,0 +1,34 @@
{
lib,
config,
...
}: let
const = import ./const.nix;
dataDirectory = const.dataDirectory;
in {
imports = [
./proxy.nix
./fail2ban.nix
./impermanence.nix
];
options.services.actual = {
subdomain = lib.mkOption {
type = lib.types.str;
default = "actual";
description = "subdomain of base domain that actual will be hosted at";
};
};
config = lib.mkIf config.services.actual.enable {
systemd.tmpfiles.rules = [
"d ${dataDirectory} 2770 actual actual"
];
services.actual = {
settings = {
ACTUAL_DATA_DIR = dataDirectory;
};
};
};
}

View file

@ -0,0 +1,9 @@
{
lib,
config,
...
}: {
config = lib.mkIf (config.services.actual.enable && config.services.fail2ban.enable) {
# TODO: configuration for fail2ban for actual
};
}

View file

@ -0,0 +1,26 @@
{
lib,
config,
...
}: let
const = import ./const.nix;
dataDirectory = const.dataDirectory;
in {
config = lib.mkIf (config.services.actual.enable && config.host.impermanence.enable) {
assertions = [
{
assertion = config.services.actual.settings.ACTUAL_DATA_DIR == dataDirectory;
message = "actual data location does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
directories = [
{
directory = dataDirectory;
user = "actual";
group = "actual";
}
];
};
};
}

View file

@ -0,0 +1,13 @@
{
lib,
config,
...
}: {
config = lib.mkIf (config.services.actual.enable && config.host.reverse_proxy.enable) {
host = {
reverse_proxy.subdomains.${config.services.actual.subdomain} = {
target = "http://localhost:${toString config.services.actual.settings.port}";
};
};
};
}

View file

@ -1,19 +1,20 @@
{...}: {
imports = [
./fail2ban.nix
./network_storage
./reverse_proxy.nix
./fail2ban.nix
./postgres.nix
./network_storage
./podman.nix
./jellyfin.nix
./forgejo.nix
./searx.nix
./home-assistant.nix
./wyoming.nix
./immich.nix
./actual
./immich
./panoramax
./forgejo
./home-assistant
./jellyfin
./paperless
./searx
./qbittorent.nix
./paperless.nix
./actual.nix
./panoramax.nix
./wyoming.nix
];
}

View file

@ -1,128 +0,0 @@
{
lib,
config,
pkgs,
...
}: let
forgejoPort = 8081;
stateDir = "/var/lib/forgejo";
db_user = "forgejo";
sshPort = 22222;
in {
options.services.forgejo = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that forgejo will be hosted at";
default = "forgejo";
};
};
config = lib.mkIf config.services.forgejo.enable (lib.mkMerge [
{
assertions = [
{
assertion = config.services.forgejo.settings.server.BUILTIN_SSH_SERVER_USER == config.users.users.git.name;
message = "Forgejo BUILTIN_SSH_SERVER_USER hardcoded value does not match expected git user name";
}
];
host = {
postgres = {
enable = true;
extraUsers = {
${db_user} = {
isClient = true;
createUser = true;
};
};
extraDatabases = {
${db_user} = {
name = db_user;
};
};
};
};
services.forgejo = {
database = {
type = "postgres";
socket = "/run/postgresql";
};
lfs.enable = true;
settings = {
server = {
DOMAIN = "${config.services.forgejo.subdomain}.${config.host.reverse_proxy.hostname}";
HTTP_PORT = forgejoPort;
START_SSH_SERVER = true;
SSH_LISTEN_PORT = sshPort;
SSH_PORT = 22;
BUILTIN_SSH_SERVER_USER = "git";
ROOT_URL = "https://git.jan-leila.com";
};
service = {
DISABLE_REGISTRATION = true;
};
database = {
DB_TYPE = "postgres";
NAME = db_user;
USER = db_user;
};
};
};
networking.firewall.allowedTCPPorts = [
config.services.forgejo.settings.server.SSH_LISTEN_PORT
];
}
(lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.${config.services.forgejo.subdomain} = {
target = "http://localhost:${toString forgejoPort}";
};
};
})
(lib.mkIf config.services.fail2ban.enable {
environment.etc = {
"fail2ban/filter.d/forgejo.local".text = lib.mkIf config.services.forgejo.enable (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = ".*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from <HOST>"
'')
);
};
services.fail2ban = {
jails = {
forgejo-iptables.settings = lib.mkIf config.services.forgejo.enable {
enabled = true;
filter = "forgejo";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.forgejo.settings.log.ROOT_PATH}/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
})
(lib.mkIf config.host.impermanence.enable {
assertions = [
{
assertion = config.services.forgejo.stateDir == stateDir;
message = "forgejo state directory does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
enable = true;
hideMounts = true;
directories = [
{
directory = stateDir;
user = "forgejo";
group = "forgejo";
}
];
};
})
]);
}

View file

@ -0,0 +1,4 @@
{
httpPort = 8081;
sshPort = 22222;
}

View file

@ -0,0 +1,41 @@
{
lib,
config,
...
}: {
config = lib.mkIf config.services.forgejo.enable (
lib.mkMerge [
{
host = {
postgres = {
enable = true;
};
};
assertions = [
{
assertion = config.services.forgejo.settings.database.DB_TYPE == "postgres";
message = "Forgejo database type must be postgres";
}
];
}
(lib.mkIf config.host.postgres.enable {
host = {
postgres = {
extraUsers = {
forgejo = {
isClient = true;
createUser = true;
};
};
extraDatabases = {
forgejo = {
name = "forgejo";
};
};
};
};
})
]
);
}

View file

@ -0,0 +1,61 @@
{
lib,
config,
...
}: let
const = import ./const.nix;
httpPort = const.httpPort;
sshPort = const.sshPort;
db_user = "forgejo";
in {
imports = [
./proxy.nix
./database.nix
./fail2ban.nix
./impermanence.nix
];
options.services.forgejo = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that forgejo will be hosted at";
default = "forgejo";
};
};
config = lib.mkIf config.services.forgejo.enable {
assertions = [
{
assertion = config.services.forgejo.settings.server.BUILTIN_SSH_SERVER_USER == config.users.users.git.name;
message = "Forgejo BUILTIN_SSH_SERVER_USER hardcoded value does not match expected git user name";
}
];
services.forgejo = {
database = {
type = "postgres";
socket = "/run/postgresql";
};
lfs.enable = true;
settings = {
server = {
DOMAIN = "${config.services.forgejo.subdomain}.${config.host.reverse_proxy.hostname}";
HTTP_PORT = httpPort;
START_SSH_SERVER = true;
SSH_LISTEN_PORT = sshPort;
SSH_PORT = 22;
BUILTIN_SSH_SERVER_USER = "git";
ROOT_URL = "https://git.jan-leila.com";
};
service = {
DISABLE_REGISTRATION = true;
};
database = {
DB_TYPE = "postgres";
NAME = db_user;
USER = db_user;
};
};
};
};
}

View file

@ -0,0 +1,32 @@
{
lib,
config,
pkgs,
...
}: {
config = lib.mkIf (config.services.forgejo.enable && config.services.fail2ban.enable) {
environment.etc = {
"fail2ban/filter.d/forgejo.local".text = lib.mkIf config.services.forgejo.enable (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = ".*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from <HOST>"
'')
);
};
services.fail2ban = {
jails = {
forgejo-iptables.settings = lib.mkIf config.services.forgejo.enable {
enabled = true;
filter = "forgejo";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.forgejo.settings.log.ROOT_PATH}/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
};
}

View file

@ -0,0 +1,28 @@
{
lib,
config,
...
}: let
stateDir = "/var/lib/forgejo";
in {
config = lib.mkIf (config.services.forgejo.enable && config.host.impermanence.enable) {
assertions = [
{
assertion = config.services.forgejo.stateDir == stateDir;
message = "forgejo state directory does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
enable = true;
hideMounts = true;
directories = [
{
directory = stateDir;
user = "forgejo";
group = "forgejo";
}
];
};
};
}

View file

@ -0,0 +1,18 @@
{
lib,
config,
...
}: let
const = import ./const.nix;
httpPort = const.httpPort;
in {
config = lib.mkIf (config.services.forgejo.enable && config.host.reverse_proxy.enable) {
host.reverse_proxy.subdomains.${config.services.forgejo.subdomain} = {
target = "http://localhost:${toString httpPort}";
};
networking.firewall.allowedTCPPorts = [
config.services.forgejo.settings.server.SSH_LISTEN_PORT
];
};
}

View file

@ -1,230 +0,0 @@
{
lib,
pkgs,
config,
...
}: let
configDir = "/var/lib/hass";
dbUser = "hass";
in {
options.services.home-assistant = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that home-assistant will be hosted at";
default = "home-assistant";
};
database = lib.mkOption {
type = lib.types.enum [
"builtin"
"postgres"
];
description = "what database do we want to use";
default = "builtin";
};
extensions = {
sonos = {
enable = lib.mkEnableOption "enable the sonos plugin";
port = lib.mkOption {
type = lib.types.int;
default = 1400;
description = "what port to use for sonos discovery";
};
};
jellyfin = {
enable = lib.mkEnableOption "enable the jellyfin plugin";
};
wyoming = {
enable = lib.mkEnableOption "enable wyoming";
};
};
};
config = lib.mkIf config.services.home-assistant.enable (lib.mkMerge [
{
services.home-assistant = {
configDir = configDir;
extraComponents = [
"default_config"
"esphome"
"met"
"radio_browser"
"isal"
"zha"
"webostv"
"tailscale"
"syncthing"
"analytics_insights"
"unifi"
"openweathermap"
"ollama"
"mobile_app"
"logbook"
"ssdp"
"usb"
"webhook"
"bluetooth"
"dhcp"
"energy"
"history"
"backup"
"assist_pipeline"
"conversation"
"sun"
"zeroconf"
"cpuspeed"
];
config = {
http = {
server_port = 8123;
use_x_forwarded_for = true;
trusted_proxies = ["127.0.0.1" "::1"];
ip_ban_enabled = true;
login_attempts_threshold = 10;
};
homeassistant = {
external_url = "https://${config.services.home-assistant.subdomain}.${config.host.reverse_proxy.hostname}";
# internal_url = "http://192.168.1.2:8123";
};
recorder.db_url = "postgresql://@/${dbUser}";
"automation manual" = [];
"automation ui" = "!include automations.yaml";
mobile_app = {};
};
extraPackages = python3Packages:
with python3Packages; [
hassil
numpy
gtts
];
};
# TODO: configure /var/lib/hass/secrets.yaml via sops
networking.firewall.allowedUDPPorts = [
1900
];
systemd.tmpfiles.rules = [
"f ${config.services.home-assistant.configDir}/automations.yaml 0755 hass hass"
];
}
(lib.mkIf (config.services.home-assistant.extensions.sonos.enable) {
services.home-assistant.extraComponents = ["sonos"];
networking.firewall.allowedTCPPorts = [
config.services.home-assistant.extensions.sonos.port
];
})
(lib.mkIf (config.services.home-assistant.extensions.jellyfin.enable) {
services.home-assistant.extraComponents = ["jellyfin"];
# TODO: configure port, address, and login information here
})
(lib.mkIf (config.services.home-assistant.extensions.wyoming.enable) {
services.home-assistant.extraComponents = ["wyoming"];
services.wyoming.enable = true;
})
(lib.mkIf (config.services.home-assistant.database == "postgres") {
host = {
postgres = {
enable = true;
extraUsers = {
${dbUser} = {
isClient = true;
createUser = true;
};
};
extraDatabases = {
${dbUser} = {
name = dbUser;
};
};
};
};
services.home-assistant = {
extraPackages = python3Packages:
with python3Packages; [
psycopg2
];
};
systemd.services.home-assistant = {
requires = [
config.systemd.services.postgresql.name
];
};
})
(lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.${config.services.home-assistant.subdomain} = {
target = "http://localhost:${toString config.services.home-assistant.config.http.server_port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
add_header Upgrade $http_upgrade;
add_header Connection \"upgrade\";
proxy_buffering off;
proxy_read_timeout 90;
'';
};
};
})
(lib.mkIf config.services.fail2ban.enable {
environment.etc = {
"fail2ban/filter.d/hass.local".text = lib.mkIf config.services.home-assistant.enable (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[INCLUDES]
before = common.conf
[Definition]
failregex = ^%(__prefix_line)s.*Login attempt or request with invalid authentication from <HOST>.*$
ignoreregex =
[Init]
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
'')
);
};
services.fail2ban = {
jails = {
home-assistant-iptables.settings = lib.mkIf config.services.home-assistant.enable {
enabled = true;
filter = "hass";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.home-assistant.configDir}/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
})
(lib.mkIf config.host.impermanence.enable {
assertions = [
{
assertion = config.services.home-assistant.configDir == configDir;
message = "home assistant config directory does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
enable = true;
hideMounts = true;
directories = [
{
directory = configDir;
user = "hass";
group = "hass";
}
];
};
})
]);
}

View file

@ -0,0 +1,56 @@
{
lib,
config,
...
}: let
dbUser = "hass";
in {
config = lib.mkIf config.services.home-assistant.enable (
lib.mkMerge [
{
host = {
postgres = {
enable = true;
};
};
assertions = [
{
assertion = config.services.home-assistant.database == "postgres";
message = "Home Assistant database type must be postgres";
}
];
}
(lib.mkIf config.host.postgres.enable {
host = {
postgres = {
extraUsers = {
${dbUser} = {
isClient = true;
createUser = true;
};
};
extraDatabases = {
${dbUser} = {
name = dbUser;
};
};
};
};
services.home-assistant = {
extraPackages = python3Packages:
with python3Packages; [
psycopg2
];
};
systemd.services.home-assistant = {
requires = [
config.systemd.services.postgresql.name
];
};
})
]
);
}

View file

@ -0,0 +1,118 @@
{
lib,
config,
...
}: {
imports = [
./proxy.nix
./database.nix
./fail2ban.nix
./impermanence.nix
./extensions
];
options.services.home-assistant = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that home-assistant will be hosted at";
default = "home-assistant";
};
database = lib.mkOption {
type = lib.types.enum [
"builtin"
"postgres"
];
description = "what database do we want to use";
default = "builtin";
};
extensions = {
sonos = {
enable = lib.mkEnableOption "enable the sonos plugin";
port = lib.mkOption {
type = lib.types.int;
default = 1400;
description = "what port to use for sonos discovery";
};
};
jellyfin = {
enable = lib.mkEnableOption "enable the jellyfin plugin";
};
wyoming = {
enable = lib.mkEnableOption "enable wyoming";
};
};
};
config = lib.mkIf config.services.home-assistant.enable (lib.mkMerge [
{
services.home-assistant = {
configDir = "/var/lib/hass";
extraComponents = [
"default_config"
"esphome"
"met"
"radio_browser"
"isal"
"zha"
"webostv"
"tailscale"
"syncthing"
"analytics_insights"
"unifi"
"openweathermap"
"ollama"
"mobile_app"
"logbook"
"ssdp"
"usb"
"webhook"
"bluetooth"
"dhcp"
"energy"
"history"
"backup"
"assist_pipeline"
"conversation"
"sun"
"zeroconf"
"cpuspeed"
];
config = {
http = {
server_port = 8123;
use_x_forwarded_for = true;
trusted_proxies = ["127.0.0.1" "::1"];
ip_ban_enabled = true;
login_attempts_threshold = 10;
};
homeassistant = {
external_url = "https://${config.services.home-assistant.subdomain}.${config.host.reverse_proxy.hostname}";
# internal_url = "http://192.168.1.2:8123";
};
recorder.db_url = "postgresql://@/${config.services.home-assistant.configDir}";
"automation manual" = [];
"automation ui" = "!include automations.yaml";
mobile_app = {};
};
extraPackages = python3Packages:
with python3Packages; [
hassil
numpy
gtts
];
};
# TODO: configure /var/lib/hass/secrets.yaml via sops
networking.firewall.allowedUDPPorts = [
1900
];
systemd.tmpfiles.rules = [
"f ${config.services.home-assistant.configDir}/automations.yaml 0755 hass hass"
];
}
]);
}

View file

@ -0,0 +1,12 @@
{
config,
lib,
pkgs,
...
}: {
imports = [
./sonos.nix
./jellyfin.nix
./wyoming.nix
];
}

View file

@ -0,0 +1,9 @@
{
lib,
config,
...
}:
lib.mkIf (config.services.home-assistant.extensions.jellyfin.enable) {
services.home-assistant.extraComponents = ["jellyfin"];
# TODO: configure port, address, and login information here
}

View file

@ -0,0 +1,11 @@
{
lib,
config,
...
}:
lib.mkIf (config.services.home-assistant.extensions.sonos.enable) {
services.home-assistant.extraComponents = ["sonos"];
networking.firewall.allowedTCPPorts = [
config.services.home-assistant.extensions.sonos.port
];
}

View file

@ -0,0 +1,9 @@
{
lib,
config,
...
}:
lib.mkIf (config.services.home-assistant.extensions.wyoming.enable) {
services.home-assistant.extraComponents = ["wyoming"];
services.wyoming.enable = true;
}

View file

@ -0,0 +1,39 @@
{
lib,
pkgs,
config,
...
}:
lib.mkIf (config.services.fail2ban.enable && config.services.home-assistant.enable) {
environment.etc = {
"fail2ban/filter.d/hass.local".text = (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[INCLUDES]
before = common.conf
[Definition]
failregex = ^%(__prefix_line)s.*Login attempt or request with invalid authentication from <HOST>.*$
ignoreregex =
[Init]
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
'')
);
};
services.fail2ban = {
jails = {
home-assistant-iptables.settings = {
enabled = true;
filter = "hass";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.home-assistant.configDir}/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
}

View file

@ -0,0 +1,26 @@
{
lib,
config,
...
}: let
configDir = "/var/lib/hass";
in
lib.mkIf (config.host.impermanence.enable && config.services.home-assistant.enable) {
assertions = [
{
assertion = config.services.home-assistant.configDir == configDir;
message = "home assistant config directory does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
enable = true;
hideMounts = true;
directories = [
{
directory = configDir;
user = "hass";
group = "hass";
}
];
};
}

View file

@ -0,0 +1,24 @@
{
lib,
config,
...
}:
lib.mkIf (config.host.reverse_proxy.enable && config.services.home-assistant.enable) {
host = {
reverse_proxy.subdomains.${config.services.home-assistant.subdomain} = {
target = "http://localhost:${toString config.services.home-assistant.config.http.server_port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
add_header Upgrade $http_upgrade;
add_header Connection \"upgrade\";
proxy_buffering off;
proxy_read_timeout 90;
'';
};
};
}

View file

@ -1,99 +0,0 @@
{
lib,
config,
pkgs,
...
}: let
mediaLocation = "/var/lib/immich";
in {
options.services.immich = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that immich will be hosted at";
default = "immich";
};
};
config = lib.mkIf config.services.immich.enable (lib.mkMerge [
{
host = {
postgres = {
enable = true;
extraUsers = {
${config.services.immich.database.user} = {
isClient = true;
};
};
};
};
networking.firewall.interfaces.${config.services.tailscale.interfaceName} = {
allowedUDPPorts = [
config.services.immich.port
];
allowedTCPPorts = [
config.services.immich.port
];
};
}
(lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.${config.services.immich.subdomain} = {
target = "http://localhost:${toString config.services.immich.port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
# allow large file uploads
client_max_body_size 50000M;
# set timeout
proxy_read_timeout 600s;
proxy_send_timeout 600s;
send_timeout 600s;
proxy_redirect off;
'';
};
};
})
(lib.mkIf config.services.fail2ban.enable {
environment.etc = {
"fail2ban/filter.d/immich.local".text = lib.mkIf config.services.immich.enable (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = immich-server.*Failed login attempt for user.+from ip address\s?<ADDR>
journalmatch = CONTAINER_TAG=immich-server
'')
);
};
services.fail2ban = {
jails = {
immich-iptables.settings = lib.mkIf config.services.immich.enable {
enabled = true;
filter = "immich";
backend = "systemd";
};
};
};
})
(lib.mkIf config.host.impermanence.enable {
assertions = [
{
assertion = config.services.immich.mediaLocation == mediaLocation;
message = "immich media location does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
directories = [
{
directory = mediaLocation;
user = "immich";
group = "immich";
}
];
};
})
]);
}

View file

@ -0,0 +1,26 @@
{
lib,
config,
...
}: {
config = lib.mkIf config.services.immich.enable (lib.mkMerge [
{
host = {
postgres = {
enable = true;
};
};
}
(lib.mkIf config.host.postgres.enable {
host = {
postgres = {
extraUsers = {
${config.services.immich.database.user} = {
isClient = true;
};
};
};
};
})
]);
}

View file

@ -0,0 +1,28 @@
{lib, ...}: {
imports = [
./proxy.nix
./database.nix
./fail2ban.nix
./impermanence.nix
];
options.services.immich = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that immich will be hosted at";
default = "immich";
};
};
# NOTE: This shouldn't be needed now that we are out of testing
# config = lib.mkIf config.services.immich.enable {
# networking.firewall.interfaces.${config.services.tailscale.interfaceName} = {
# allowedUDPPorts = [
# config.services.immich.port
# ];
# allowedTCPPorts = [
# config.services.immich.port
# ];
# };
# };
}

View file

@ -0,0 +1,26 @@
{
lib,
config,
pkgs,
...
}: {
config = lib.mkIf (config.services.fail2ban.enable && config.services.immich.enable) {
environment.etc = {
"fail2ban/filter.d/immich.local".text = pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = immich-server.*Failed login attempt for user.+from ip address\s?<ADDR>
journalmatch = CONTAINER_TAG=immich-server
'');
};
services.fail2ban = {
jails = {
immich-iptables.settings = {
enabled = true;
filter = "immich";
backend = "systemd";
};
};
};
};
}

View file

@ -0,0 +1,25 @@
{
lib,
config,
...
}: let
mediaLocation = "/var/lib/immich";
in {
config = lib.mkIf (config.services.immich.enable && config.host.impermanence.enable) {
assertions = [
{
assertion = config.services.immich.mediaLocation == mediaLocation;
message = "immich media location does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
directories = [
{
directory = mediaLocation;
user = "immich";
group = "immich";
}
];
};
};
}

View file

@ -0,0 +1,27 @@
{
lib,
config,
...
}: {
config = lib.mkIf (config.services.immich.enable && config.host.reverse_proxy.enable) {
host = {
reverse_proxy.subdomains.${config.services.immich.subdomain} = {
target = "http://localhost:${toString config.services.immich.port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
# allow large file uploads
client_max_body_size 50000M;
# set timeout
proxy_read_timeout 600s;
proxy_send_timeout 600s;
send_timeout 600s;
proxy_redirect off;
'';
};
};
};
}

View file

@ -1,147 +0,0 @@
{
lib,
pkgs,
config,
...
}: let
jellyfinPort = 8096;
dlanPort = 1900;
jellyfin_data_directory = "/var/lib/jellyfin";
jellyfin_cache_directory = "/var/cache/jellyfin";
in {
options.services.jellyfin = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that jellyfin will be hosted at";
default = "jellyfin";
};
extraSubdomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "ex subdomain of base domain that jellyfin will be hosted at";
default = [];
};
media_directory = lib.mkOption {
type = lib.types.str;
description = "directory jellyfin media will be hosted at";
default = "/srv/jellyfin/media";
};
};
config = lib.mkIf config.services.jellyfin.enable (
lib.mkMerge [
{
environment.systemPackages = [
pkgs.jellyfin
pkgs.jellyfin-web
pkgs.jellyfin-ffmpeg
];
networking.firewall.allowedTCPPorts = [jellyfinPort dlanPort];
systemd.tmpfiles.rules = [
"d ${config.services.jellyfin.media_directory} 2770 jellyfin jellyfin_media"
"A ${config.services.jellyfin.media_directory} - - - - u:jellyfin:rwX,g:jellyfin_media:rwX,o::-"
];
}
(lib.mkIf config.host.reverse_proxy.enable {
host.reverse_proxy.subdomains.jellyfin = {
target = "http://localhost:${toString jellyfinPort}";
subdomain = config.services.jellyfin.subdomain;
extraSubdomains = config.services.jellyfin.extraSubdomains;
forwardHeaders.enable = true;
extraConfig = ''
client_max_body_size 20M;
add_header X-Content-Type-Options "nosniff";
proxy_buffering off;
'';
};
})
(lib.mkIf config.services.fail2ban.enable {
environment.etc = {
"fail2ban/filter.d/jellyfin.local".text = (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = "^.*Authentication request for .* has been denied \\\(IP: \"<ADDR>\"\\\)\\\."
'')
);
};
services.fail2ban = {
jails = {
jellyfin-iptables.settings = {
enabled = true;
filter = "jellyfin";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.jellyfin.dataDir}/log/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
})
(lib.mkIf config.host.impermanence.enable {
fileSystems."/persist/system/jellyfin".neededForBoot = true;
host.storage.pool.extraDatasets = {
# sops age key needs to be available to pre persist for user generation
"persist/system/jellyfin" = {
type = "zfs_fs";
mountpoint = "/persist/system/jellyfin";
options = {
atime = "off";
relatime = "off";
canmount = "on";
};
};
};
assertions = [
{
assertion = config.services.jellyfin.dataDir == jellyfin_data_directory;
message = "jellyfin data directory does not match persistence";
}
{
assertion = config.services.jellyfin.cacheDir == jellyfin_cache_directory;
message = "jellyfin cache directory does not match persistence";
}
];
environment.persistence = {
"/persist/system/root" = {
directories = [
{
directory = jellyfin_data_directory;
user = "jellyfin";
group = "jellyfin";
}
{
directory = jellyfin_cache_directory;
user = "jellyfin";
group = "jellyfin";
}
];
};
"/persist/system/jellyfin" = {
enable = true;
hideMounts = true;
directories = [
{
directory = config.services.jellyfin.media_directory;
user = "jellyfin";
group = "jellyfin_media";
mode = "1770";
}
];
};
};
})
]
);
}

View file

@ -0,0 +1,48 @@
{
lib,
pkgs,
config,
...
}: let
jellyfinPort = 8096;
dlanPort = 1900;
in {
imports = [
./proxy.nix
./fail2ban.nix
./impermanence.nix
];
options.services.jellyfin = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that jellyfin will be hosted at";
default = "jellyfin";
};
extraSubdomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "ex subdomain of base domain that jellyfin will be hosted at";
default = [];
};
media_directory = lib.mkOption {
type = lib.types.str;
description = "directory jellyfin media will be hosted at";
default = "/srv/jellyfin/media";
};
};
config = lib.mkIf config.services.jellyfin.enable {
environment.systemPackages = [
pkgs.jellyfin
pkgs.jellyfin-web
pkgs.jellyfin-ffmpeg
];
networking.firewall.allowedTCPPorts = [jellyfinPort dlanPort];
systemd.tmpfiles.rules = [
"d ${config.services.jellyfin.media_directory} 2770 jellyfin jellyfin_media"
"A ${config.services.jellyfin.media_directory} - - - - u:jellyfin:rwX,g:jellyfin_media:rwX,o::-"
];
};
}

View file

@ -0,0 +1,32 @@
{
lib,
pkgs,
config,
...
}: {
config = lib.mkIf (config.services.jellyfin.enable && config.services.fail2ban.enable) {
environment.etc = {
"fail2ban/filter.d/jellyfin.local".text = (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = "^.*Authentication request for .* has been denied \\\\\\(IP: \\\"<ADDR>\\\"\\\\\\)\\\\\\."
'')
);
};
services.fail2ban = {
jails = {
jellyfin-iptables.settings = {
enabled = true;
filter = "jellyfin";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.jellyfin.dataDir}/log/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
};
}

View file

@ -0,0 +1,66 @@
{
lib,
config,
...
}: let
jellyfin_data_directory = "/var/lib/jellyfin";
jellyfin_cache_directory = "/var/cache/jellyfin";
in {
config = lib.mkIf (config.services.jellyfin.enable && config.host.impermanence.enable) {
fileSystems."/persist/system/jellyfin".neededForBoot = true;
host.storage.pool.extraDatasets = {
# sops age key needs to be available to pre persist for user generation
"persist/system/jellyfin" = {
type = "zfs_fs";
mountpoint = "/persist/system/jellyfin";
options = {
atime = "off";
relatime = "off";
canmount = "on";
};
};
};
assertions = [
{
assertion = config.services.jellyfin.dataDir == jellyfin_data_directory;
message = "jellyfin data directory does not match persistence";
}
{
assertion = config.services.jellyfin.cacheDir == jellyfin_cache_directory;
message = "jellyfin cache directory does not match persistence";
}
];
environment.persistence = {
"/persist/system/root" = {
directories = [
{
directory = jellyfin_data_directory;
user = "jellyfin";
group = "jellyfin";
}
{
directory = jellyfin_cache_directory;
user = "jellyfin";
group = "jellyfin";
}
];
};
"/persist/system/jellyfin" = {
enable = true;
hideMounts = true;
directories = [
{
directory = config.services.jellyfin.media_directory;
user = "jellyfin";
group = "jellyfin_media";
mode = "1770";
}
];
};
};
};
}

View file

@ -0,0 +1,25 @@
{
lib,
config,
...
}: let
jellyfinPort = 8096;
in {
config = lib.mkIf (config.services.jellyfin.enable && config.host.reverse_proxy.enable) {
host.reverse_proxy.subdomains.jellyfin = {
target = "http://localhost:${toString jellyfinPort}";
subdomain = config.services.jellyfin.subdomain;
extraSubdomains = config.services.jellyfin.extraSubdomains;
forwardHeaders.enable = true;
extraConfig = ''
client_max_body_size 20M;
add_header X-Content-Type-Options "nosniff";
proxy_buffering off;
'';
};
};
}

View file

@ -1,408 +0,0 @@
{
config,
lib,
pkgs,
osConfig,
...
}:
with lib; let
# Database configuration assertions
dbUrlConfigured = config.services.panoramax.database.url != null;
individualDbConfigured = all (x: x != null) [
config.services.panoramax.database.host
config.services.panoramax.database.port
config.services.panoramax.database.username
config.services.panoramax.database.password
config.services.panoramax.database.name
];
envContent = ''
# Panoramax Configuration
FLASK_APP=geovisio
${
if dbUrlConfigured
then "DB_URL=${config.services.panoramax.database.url}"
else ''
DB_HOST=${config.services.panoramax.database.host}
DB_PORT=${toString config.services.panoramax.database.port}
DB_USERNAME=${config.services.panoramax.database.username}
DB_PASSWORD=${config.services.panoramax.database.password}
DB_NAME=${config.services.panoramax.database.name}
''
}
${optionalString (config.services.panoramax.storage.fsUrl != null) "FS_URL=${config.services.panoramax.storage.fsUrl}"}
${optionalString (config.services.panoramax.infrastructure.nbProxies != null) "INFRA_NB_PROXIES=${toString config.services.panoramax.infrastructure.nbProxies}"}
${optionalString (config.services.panoramax.flask.secretKey != null) "FLASK_SECRET_KEY=${config.services.panoramax.flask.secretKey}"}
${optionalString (config.services.panoramax.flask.sessionCookieDomain != null) "FLASK_SESSION_COOKIE_DOMAIN=${config.services.panoramax.flask.sessionCookieDomain}"}
${optionalString (config.services.panoramax.api.pictures.licenseSpdxId != null) "API_PICTURES_LICENSE_SPDX_ID=${config.services.panoramax.api.pictures.licenseSpdxId}"}
${optionalString (config.services.panoramax.api.pictures.licenseUrl != null) "API_PICTURES_LICENSE_URL=${config.services.panoramax.api.pictures.licenseUrl}"}
${optionalString (config.services.panoramax.port != null) "PORT=${toString config.services.panoramax.port}"}
${optionalString (config.services.panoramax.sgblur.enable) "SGBLUR_API_URL=${config.services.panoramax.sgblur.url}"}
${concatStringsSep "\n" (mapAttrsToList (name: value: "${name}=${value}") config.services.panoramax.extraEnvironment)}
'';
envFile = pkgs.writeText "panoramax.env" envContent;
in {
options.services.panoramax = {
enable = lib.mkEnableOption "panoramax";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.panoramax;
description = "The panoramax package to use";
};
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that panoramax will be hosted at";
default = "panoramax";
};
database = {
createDB = mkOption {
type = types.bool;
default = true;
description = "Whether to automatically create the database and user";
};
url = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Complete database URL connection string (e.g., "postgresql://user:password@host:port/dbname").
If provided, individual database options (host, port, username, password, name) are ignored.
'';
};
port = mkOption {
type = types.nullOr types.port;
default = 5432;
description = "Database port (ignored if database.url is set)";
};
host = mkOption {
type = types.nullOr types.str;
default = "localhost";
description = "Database host (ignored if database.url is set)";
};
username = mkOption {
type = types.nullOr types.str;
default = "panoramax";
description = "Database username (ignored if database.url is set)";
};
password = mkOption {
type = types.nullOr types.str;
default = null;
description = "Database password (ignored if database.url is set)";
};
name = mkOption {
type = types.str;
default = "panoramax";
description = "Database name (ignored if database.url is set)";
};
};
sgblur = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable sgblur integration for face and license plate blurring";
};
package = mkOption {
type = types.package;
default = pkgs.sgblur;
description = "The sgblur package to use";
};
port = mkOption {
type = types.port;
default = 8080;
description = "Port for the sgblur service";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Host to bind the sgblur service to";
};
url = mkOption {
type = types.str;
default = "http://127.0.0.1:8080";
description = "URL where sgblur service is accessible";
};
};
port = mkOption {
type = types.nullOr types.port;
default = 5000;
description = "Port for the Panoramax service";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Host to bind the Panoramax service to";
};
urlScheme = mkOption {
type = types.enum ["http" "https"];
default = "https";
description = "URL scheme for the application";
};
storage = {
fsUrl = mkOption {
type = types.nullOr types.str;
default = "/var/lib/panoramax/storage";
description = "File system URL for storage";
};
};
infrastructure = {
nbProxies = mkOption {
type = types.nullOr types.int;
default = 1;
description = "Number of proxies in front of the application";
};
};
flask = {
secretKey = mkOption {
type = types.nullOr types.str;
default = null;
description = "Flask secret key for session security";
};
sessionCookieDomain = mkOption {
type = types.nullOr types.str;
default = null;
description = "Flask session cookie domain";
};
};
api = {
pictures = {
licenseSpdxId = mkOption {
type = types.nullOr types.str;
default = null;
description = "SPDX license identifier for API pictures";
};
licenseUrl = mkOption {
type = types.nullOr types.str;
default = null;
description = "License URL for API pictures";
};
};
};
extraEnvironment = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Additional environment variables";
example = {
CUSTOM_SETTING = "value";
DEBUG = "true";
};
};
};
config = lib.mkIf config.services.panoramax.enable (
lib.mkMerge [
{
environment.systemPackages = with pkgs;
[
config.services.panoramax.package
python3Packages.waitress
]
++ optionals config.services.panoramax.sgblur.enable [
config.services.panoramax.sgblur.package
];
systemd.services.panoramax = {
description = "Panoramax Service";
after = ["network.target"];
wantedBy = ["multi-user.target"];
serviceConfig = {
ExecStart = "${pkgs.python3Packages.waitress}/bin/waitress-serve --env-file=${envFile} --host=${config.services.panoramax.host} --port=${toString config.services.panoramax.port} --url-scheme=${config.services.panoramax.urlScheme} --call geovisio:create_app";
Restart = "always";
User = "panoramax";
Group = "panoramax";
WorkingDirectory = "/var/lib/panoramax";
Environment = "PYTHONPATH=${config.services.panoramax.package}/lib/python3.11/site-packages";
};
};
users.users.panoramax = {
isSystemUser = true;
group = "panoramax";
home = "/var/lib/panoramax";
createHome = true;
};
users.groups.panoramax = {};
systemd.tmpfiles.rules = [
"d /var/lib/panoramax 0755 panoramax panoramax -"
"d ${config.services.panoramax.storage.fsUrl} 0755 panoramax panoramax -"
];
assertions = [
{
assertion = dbUrlConfigured || individualDbConfigured;
message = ''
Panoramax database configuration requires either:
- A complete database URL (services.panoramax.database.url), OR
- All individual database options (host, port, username, password, name)
Currently configured:
- database.url: ${
if dbUrlConfigured
then " configured"
else " not configured"
}
- individual options: ${
if individualDbConfigured
then " all configured"
else " some missing"
}
'';
}
{
assertion = !config.services.panoramax.database.createDB || config.services.panoramax.database.url == null || (lib.hasPrefix "/run/" config.services.panoramax.database.url || lib.hasPrefix "unix:" config.services.panoramax.database.url || lib.hasPrefix "/" config.services.panoramax.database.host);
message = ''
Panoramax createDB option can only be used with socket connections when a database URL is provided.
Socket connections are identified by:
- URLs starting with "unix:"
- URLs starting with "/run/"
- Host paths starting with "/"
Current configuration:
- createDB: ${lib.boolToString config.services.panoramax.database.createDB}
- database.url: ${
if config.services.panoramax.database.url != null
then config.services.panoramax.database.url
else "not set"
}
- database.host: ${config.services.panoramax.database.host}
'';
}
];
}
(
lib.mkIf config.services.panoramax.sgblur.enable {
systemd.services.sgblur = {
description = "SGBlur AI-powered face and license plate blurring service";
after = ["network.target"];
wantedBy = ["multi-user.target"];
serviceConfig = {
ExecStart = "${config.services.panoramax.sgblur.package}/bin/uvicorn sgblur.main:app --host ${config.services.panoramax.sgblur.host} --port ${toString config.services.panoramax.sgblur.port}";
Restart = "always";
User = "sgblur";
Group = "sgblur";
WorkingDirectory = "/var/lib/sgblur";
Environment = "PYTHONPATH=${config.services.panoramax.sgblur.package}/lib/python3.11/site-packages";
};
};
users.users.sgblur = {
isSystemUser = true;
group = "sgblur";
home = "/var/lib/sgblur";
createHome = true;
};
users.groups.sgblur = {};
systemd.tmpfiles.rules = [
"d /var/lib/sgblur 0755 sgblur sgblur -"
];
# Update panoramax service dependencies when sgblur is enabled
systemd.services.panoramax = {
after = ["sgblur.service"];
wants = ["sgblur.service"];
};
}
)
(
lib.mkIf config.services.panoramax.database.createDB {
services.postgresql = {
enable = true;
ensureDatabases = [config.services.panoramax.database.name];
ensureUsers = [
{
name = config.services.panoramax.database.username;
ensureDBOwnership = true;
ensureClauses.login = true;
}
];
extensions = ps: with ps; [postgis];
settings = {
shared_preload_libraries = ["postgis"];
};
};
systemd.services.postgresql.serviceConfig.ExecStartPost = let
sqlFile = pkgs.writeText "panoramax-postgis-setup.sql" ''
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;
ALTER SCHEMA public OWNER TO ${config.services.panoramax.database.username};
GRANT ALL ON SCHEMA public TO ${config.services.panoramax.database.username};
'';
in [
''
${lib.getExe' config.services.postgresql.package "psql"} -d "${config.services.panoramax.database.name}" -f "${sqlFile}"
''
];
systemd.services.panoramax = {
after = ["postgresql.service"];
requires = ["postgresql.service"];
};
}
)
(
lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.${config.services.panoramax.subdomain} = {
target = "http://localhost:${toString config.services.panoramax.port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
# allow large file uploads for panoramic images
client_max_body_size 100M;
# set timeout for image processing
proxy_read_timeout 300s;
proxy_send_timeout 300s;
send_timeout 300s;
proxy_redirect off;
'';
};
};
}
)
(
lib.mkIf config.services.fail2ban {
# TODO: configure options for fail2ban
}
)
(
lib.mkIf osConfig.host.impermanence.enable {
# TODO: configure impermanence for panoramax data
}
)
]
);
}

View file

@ -0,0 +1,340 @@
{
config,
lib,
pkgs,
...
}:
with lib; let
# Database configuration assertions
dbUrlConfigured = config.services.panoramax.database.url != null;
individualDbConfigured = all (x: x != null) [
config.services.panoramax.database.host
config.services.panoramax.database.port
config.services.panoramax.database.username
config.services.panoramax.database.password
config.services.panoramax.database.name
];
envContent = ''
# Panoramax Configuration
FLASK_APP=geovisio
${
if dbUrlConfigured
then "DB_URL=${config.services.panoramax.database.url}"
else ''
DB_HOST=${config.services.panoramax.database.host}
DB_PORT=${toString config.services.panoramax.database.port}
DB_USERNAME=${config.services.panoramax.database.username}
DB_PASSWORD=${config.services.panoramax.database.password}
DB_NAME=${config.services.panoramax.database.name}
''
}
${optionalString (config.services.panoramax.storage.fsUrl != null) "FS_URL=${config.services.panoramax.storage.fsUrl}"}
${optionalString (config.services.panoramax.infrastructure.nbProxies != null) "INFRA_NB_PROXIES=${toString config.services.panoramax.infrastructure.nbProxies}"}
${optionalString (config.services.panoramax.flask.secretKey != null) "FLASK_SECRET_KEY=${config.services.panoramax.flask.secretKey}"}
${optionalString (config.services.panoramax.flask.sessionCookieDomain != null) "FLASK_SESSION_COOKIE_DOMAIN=${config.services.panoramax.flask.sessionCookieDomain}"}
${optionalString (config.services.panoramax.api.pictures.licenseSpdxId != null) "API_PICTURES_LICENSE_SPDX_ID=${config.services.panoramax.api.pictures.licenseSpdxId}"}
${optionalString (config.services.panoramax.api.pictures.licenseUrl != null) "API_PICTURES_LICENSE_URL=${config.services.panoramax.api.pictures.licenseUrl}"}
${optionalString (config.services.panoramax.port != null) "PORT=${toString config.services.panoramax.port}"}
${optionalString (config.services.panoramax.sgblur.enable) "SGBLUR_API_URL=${config.services.panoramax.sgblur.url}"}
${concatStringsSep "\n" (mapAttrsToList (name: value: "${name}=${value}") config.services.panoramax.extraEnvironment)}
'';
envFile = pkgs.writeText "panoramax.env" envContent;
in {
imports = [
./proxy.nix
./fail2ban.nix
./impermanence.nix
];
options.services.panoramax = {
enable = lib.mkEnableOption "panoramax";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.panoramax;
description = "The panoramax package to use";
};
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that panoramax will be hosted at";
default = "panoramax";
};
database = {
createDB = mkOption {
type = types.bool;
default = true;
description = "Whether to automatically create the database and user";
};
url = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Complete database URL connection string (e.g., "postgresql://user:password@host:port/dbname").
If provided, individual database options (host, port, username, password, name) are ignored.
'';
};
port = mkOption {
type = types.nullOr types.port;
default = 5432;
description = "Database port (ignored if database.url is set)";
};
host = mkOption {
type = types.nullOr types.str;
default = "localhost";
description = "Database host (ignored if database.url is set)";
};
username = mkOption {
type = types.nullOr types.str;
default = "panoramax";
description = "Database username (ignored if database.url is set)";
};
password = mkOption {
type = types.nullOr types.str;
default = null;
description = "Database password (ignored if database.url is set)";
};
name = mkOption {
type = types.str;
default = "panoramax";
description = "Database name (ignored if database.url is set)";
};
};
sgblur = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable sgblur integration for face and license plate blurring";
};
package = mkOption {
type = types.package;
default = pkgs.sgblur;
description = "The sgblur package to use";
};
port = mkOption {
type = types.port;
default = 8080;
description = "Port for the sgblur service";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Host to bind the sgblur service to";
};
url = mkOption {
type = types.str;
default = "http://127.0.0.1:8080";
description = "URL where sgblur service is accessible";
};
};
port = mkOption {
type = types.nullOr types.port;
default = 5000;
description = "Port for the Panoramax service";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Host to bind the Panoramax service to";
};
urlScheme = mkOption {
type = types.enum ["http" "https"];
default = "https";
description = "URL scheme for the application";
};
storage = {
fsUrl = mkOption {
type = types.nullOr types.str;
default = "/var/lib/panoramax/storage";
description = "File system URL for storage";
};
};
infrastructure = {
nbProxies = mkOption {
type = types.nullOr types.int;
default = 1;
description = "Number of proxies in front of the application";
};
};
flask = {
secretKey = mkOption {
type = types.nullOr types.str;
default = null;
description = "Flask secret key for session security";
};
sessionCookieDomain = mkOption {
type = types.nullOr types.str;
default = null;
description = "Flask session cookie domain";
};
};
api = {
pictures = {
licenseSpdxId = mkOption {
type = types.nullOr types.str;
default = null;
description = "SPDX license identifier for API pictures";
};
licenseUrl = mkOption {
type = types.nullOr types.str;
default = null;
description = "License URL for API pictures";
};
};
};
extraEnvironment = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Additional environment variables";
example = {
CUSTOM_SETTING = "value";
DEBUG = "true";
};
};
};
config = lib.mkIf config.services.panoramax.enable (lib.mkMerge [
{
environment.systemPackages = with pkgs;
[
config.services.panoramax.package
python3Packages.waitress
]
++ optionals config.services.panoramax.sgblur.enable [
config.services.panoramax.sgblur.package
];
systemd.services.panoramax = {
description = "Panoramax Service";
after = ["network.target"];
wantedBy = ["multi-user.target"];
serviceConfig = {
ExecStart = "${pkgs.python3Packages.waitress}/bin/waitress-serve --env-file=${envFile} --host=${config.services.panoramax.host} --port=${toString config.services.panoramax.port} --url-scheme=${config.services.panoramax.urlScheme} --call geovisio:create_app";
Restart = "always";
User = "panoramax";
Group = "panoramax";
WorkingDirectory = "/var/lib/panoramax";
Environment = "PYTHONPATH=${config.services.panoramax.package}/lib/python3.11/site-packages";
};
};
users.users.panoramax = {
isSystemUser = true;
group = "panoramax";
home = "/var/lib/panoramax";
createHome = true;
};
users.groups.panoramax = {};
systemd.tmpfiles.rules = [
"d /var/lib/panoramax 0755 panoramax panoramax -"
"d ${config.services.panoramax.storage.fsUrl} 0755 panoramax panoramax -"
];
assertions = [
{
assertion = dbUrlConfigured || individualDbConfigured;
message = ''
Panoramax database configuration requires either:
- A complete database URL (services.panoramax.database.url), OR
- All individual database options (host, port, username, password, name)
Currently configured:
- database.url: ${
if dbUrlConfigured
then " configured"
else " not configured"
}
- individual options: ${
if individualDbConfigured
then " all configured"
else " some missing"
}
'';
}
{
assertion = !config.services.panoramax.database.createDB || config.services.panoramax.database.url == null || (lib.hasPrefix "/run/" config.services.panoramax.database.url || lib.hasPrefix "unix:" config.services.panoramax.database.url || lib.hasPrefix "/" config.services.panoramax.database.host);
message = ''
Panoramax createDB option can only be used with socket connections when a database URL is provided.
Socket connections are identified by:
- URLs starting with "unix:"
- URLs starting with "/run/"
- Host paths starting with "/"
Current configuration:
- createDB: ${lib.boolToString config.services.panoramax.database.createDB}
- database.url: ${
if config.services.panoramax.database.url != null
then config.services.panoramax.database.url
else "not set"
}
- database.host: ${config.services.panoramax.database.host}
'';
}
];
}
(lib.mkIf config.services.panoramax.database.createDB {
systemd.services.panoramax = {
after = ["postgresql.service"];
requires = ["postgresql.service"];
};
services.postgresql = {
enable = true;
ensureDatabases = [config.services.panoramax.database.name];
ensureUsers = [
{
name = config.services.panoramax.database.username;
ensureDBOwnership = true;
ensureClauses.login = true;
}
];
extensions = ps: with ps; [postgis];
settings = {
shared_preload_libraries = ["postgis"];
};
};
systemd.services.postgresql.serviceConfig.ExecStartPost = let
sqlFile = pkgs.writeText "panoramax-postgis-setup.sql" ''
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;
ALTER SCHEMA public OWNER TO ${config.services.panoramax.database.username};
GRANT ALL ON SCHEMA public TO ${config.services.panoramax.database.username};
'';
in [
''
${lib.getExe' config.services.postgresql.package "psql"} -d "${config.services.panoramax.database.name}" -f "${sqlFile}"
''
];
})
]);
}

View file

@ -0,0 +1,11 @@
{
lib,
config,
...
}: {
config = lib.mkIf (config.services.panoramax.enable && config.services.fail2ban.enable) {
# TODO: configure options for fail2ban
# This is a placeholder - panoramax fail2ban configuration would need to be defined
# based on the specific log patterns and security requirements
};
}

View file

@ -0,0 +1,14 @@
{
lib,
config,
osConfig,
...
}: {
config = lib.mkIf (config.services.panoramax.enable && osConfig.host.impermanence.enable) {
# TODO: configure impermanence for panoramax data
# This would typically include directories like:
# - /var/lib/panoramax
# - panoramax storage directories
# - any cache or temporary directories that need to persist
};
}

View file

@ -0,0 +1,27 @@
{
lib,
config,
...
}: {
config = lib.mkIf (config.services.panoramax.enable && config.host.reverse_proxy.enable) {
host = {
reverse_proxy.subdomains.${config.services.panoramax.subdomain} = {
target = "http://localhost:${toString config.services.panoramax.port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
# allow large file uploads for panoramic images
client_max_body_size 100M;
# set timeout for image processing
proxy_read_timeout 300s;
proxy_send_timeout 300s;
send_timeout 300s;
proxy_redirect off;
'';
};
};
};
}

View file

@ -1,113 +0,0 @@
{
config,
lib,
pkgs,
...
}: let
dataDir = "/var/lib/paperless";
in {
options.services.paperless = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that paperless will be hosted at";
default = "paperless";
};
database = {
user = lib.mkOption {
type = lib.types.str;
description = "what is the user and database that we are going to use for paperless";
default = "paperless";
};
};
};
config = lib.mkIf config.services.paperless.enable (lib.mkMerge [
{
host = {
postgres = {
enable = true;
extraUsers = {
${config.services.paperless.database.user} = {
isClient = true;
createUser = true;
};
};
extraDatabases = {
${config.services.paperless.database.user} = {
name = config.services.paperless.database.user;
};
};
};
};
services.paperless = {
domain = "${config.services.paperless.subdomain}.${config.host.reverse_proxy.hostname}";
configureTika = true;
settings = {
PAPERLESS_DBENGINE = "postgresql";
PAPERLESS_DBHOST = "/run/postgresql";
PAPERLESS_DBNAME = config.services.paperless.database.user;
PAPERLESS_DBUSER = config.services.paperless.database.user;
};
};
}
(lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.${config.services.paperless.subdomain} = {
target = "http://${config.services.paperless.address}:${toString config.services.paperless.port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
# allow large file uploads
client_max_body_size 50000M;
'';
};
};
})
(lib.mkIf config.services.fail2ban.enable {
environment.etc = {
"fail2ban/filter.d/paperless.local".text = (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = Login failed for user `.*` from (?:IP|private IP) `<HOST>`\.$
ignoreregex =
'')
);
};
services.fail2ban = {
jails = {
paperless.settings = {
enabled = true;
filter = "paperless";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.paperless.dataDir}/log/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
})
(lib.mkIf config.host.impermanence.enable {
assertions = [
{
assertion = config.services.paperless.dataDir == dataDir;
message = "paperless data location does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
directories = [
{
directory = dataDir;
user = "paperless";
group = "paperless";
}
];
};
})
]);
}

View file

@ -0,0 +1,34 @@
{
config,
lib,
...
}: {
config = lib.mkIf config.services.paperless.enable (lib.mkMerge [
{
host = {
postgres = {
enable = true;
};
};
}
(
lib.mkIf config.host.postgres.enable {
host = {
postgres = {
extraUsers = {
${config.services.paperless.database.user} = {
isClient = true;
createUser = true;
};
};
extraDatabases = {
${config.services.paperless.database.user} = {
name = config.services.paperless.database.user;
};
};
};
};
}
)
]);
}

View file

@ -0,0 +1,40 @@
{
config,
lib,
...
}: {
imports = [
./proxy.nix
./database.nix
./fail2ban.nix
./impermanence.nix
];
options.services.paperless = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that paperless will be hosted at";
default = "paperless";
};
database = {
user = lib.mkOption {
type = lib.types.str;
description = "what is the user and database that we are going to use for paperless";
default = "paperless";
};
};
};
config = lib.mkIf config.services.paperless.enable {
services.paperless = {
domain = "${config.services.paperless.subdomain}.${config.host.reverse_proxy.hostname}";
configureTika = true;
settings = {
PAPERLESS_DBENGINE = "postgresql";
PAPERLESS_DBHOST = "/run/postgresql";
PAPERLESS_DBNAME = config.services.paperless.database.user;
PAPERLESS_DBUSER = config.services.paperless.database.user;
};
};
};
}

View file

@ -0,0 +1,34 @@
{
config,
lib,
pkgs,
...
}: {
config = lib.mkIf (config.services.paperless.enable && config.services.fail2ban.enable) {
environment.etc = {
"fail2ban/filter.d/paperless.local".text = (
pkgs.lib.mkDefault (pkgs.lib.mkAfter ''
[Definition]
failregex = Login failed for user `.*` from (?:IP|private IP) `<HOST>`\.$
ignoreregex =
'')
);
};
services.fail2ban = {
jails = {
paperless.settings = {
enabled = true;
filter = "paperless";
action = ''iptables-multiport[name=HTTP, port="http,https"]'';
logpath = "${config.services.paperless.dataDir}/log/*.log";
backend = "auto";
findtime = 600;
bantime = 600;
maxretry = 5;
};
};
};
};
}

View file

@ -0,0 +1,25 @@
{
config,
lib,
...
}: let
dataDir = "/var/lib/paperless";
in {
config = lib.mkIf (config.services.paperless.enable && config.host.impermanence.enable) {
assertions = [
{
assertion = config.services.paperless.dataDir == dataDir;
message = "paperless data location does not match persistence";
}
];
environment.persistence."/persist/system/root" = {
directories = [
{
directory = dataDir;
user = "paperless";
group = "paperless";
}
];
};
};
}

View file

@ -0,0 +1,21 @@
{
config,
lib,
...
}: {
config = lib.mkIf (config.services.paperless.enable && config.host.reverse_proxy.enable) {
host = {
reverse_proxy.subdomains.${config.services.paperless.subdomain} = {
target = "http://${config.services.paperless.address}:${toString config.services.paperless.port}";
websockets.enable = true;
forwardHeaders.enable = true;
extraConfig = ''
# allow large file uploads
client_max_body_size 50000M;
'';
};
};
};
}

View file

@ -1,78 +0,0 @@
{
config,
lib,
inputs,
...
}: {
options.services.searx = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that searx will be hosted at";
default = "searx";
};
};
config = lib.mkIf config.services.searx.enable (
lib.mkMerge [
{
sops.secrets = {
"services/searx" = {
sopsFile = "${inputs.secrets}/defiant-services.yaml";
};
};
services.searx = {
environmentFile = config.sops.secrets."services/searx".path;
# Rate limiting
limiterSettings = {
real_ip = {
x_for = 1;
ipv4_prefix = 32;
ipv6_prefix = 56;
};
botdetection = {
ip_limit = {
filter_link_local = true;
link_token = true;
};
};
};
settings = {
server = {
port = 8083;
secret_key = "@SEARXNG_SECRET@";
};
# Search engine settings
search = {
safe_search = 2;
autocomplete_min = 2;
autocomplete = "duckduckgo";
};
# Enabled plugins
enabled_plugins = [
"Basic Calculator"
"Hash plugin"
"Tor check plugin"
"Open Access DOI rewrite"
"Hostnames plugin"
"Unit converter plugin"
"Tracker URL remover"
];
};
};
}
(lib.mkIf config.host.reverse_proxy.enable {
host = {
reverse_proxy.subdomains.searx = {
subdomain = config.services.searx.subdomain;
target = "http://localhost:${toString config.services.searx.settings.server.port}";
};
};
})
]
);
}

View file

@ -0,0 +1,71 @@
{
config,
lib,
inputs,
...
}: {
imports = [
./proxy.nix
];
options.services.searx = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "subdomain of base domain that searx will be hosted at";
default = "searx";
};
};
config = lib.mkIf config.services.searx.enable {
sops.secrets = {
"services/searx" = {
sopsFile = "${inputs.secrets}/defiant-services.yaml";
};
};
services.searx = {
environmentFile = config.sops.secrets."services/searx".path;
# Rate limiting
limiterSettings = {
real_ip = {
x_for = 1;
ipv4_prefix = 32;
ipv6_prefix = 56;
};
botdetection = {
ip_limit = {
filter_link_local = true;
link_token = true;
};
};
};
settings = {
server = {
port = 8083;
secret_key = "@SEARXNG_SECRET@";
};
# Search engine settings
search = {
safe_search = 2;
autocomplete_min = 2;
autocomplete = "duckduckgo";
};
# Enabled plugins
enabled_plugins = [
"Basic Calculator"
"Hash plugin"
"Tor check plugin"
"Open Access DOI rewrite"
"Hostnames plugin"
"Unit converter plugin"
"Tracker URL remover"
];
};
};
};
}

View file

@ -0,0 +1,14 @@
{
config,
lib,
...
}: {
config = lib.mkIf (config.services.searx.enable && config.host.reverse_proxy.enable) {
host = {
reverse_proxy.subdomains.searx = {
subdomain = config.services.searx.subdomain;
target = "http://localhost:${toString config.services.searx.settings.server.port}";
};
};
};
}