navidrome
Some checks failed
ci/woodpecker/push/build-cache Pipeline failed
ci/woodpecker/cron/dependency-pr Pipeline was successful

This commit is contained in:
Lucy Hochkamp 2025-12-04 00:21:41 +01:00
parent 1408470a53
commit 28dc0896b9
No known key found for this signature in database
16 changed files with 969 additions and 171 deletions

View file

@ -5,13 +5,59 @@
...
}:
let
inherit (lib) mkEnableOption mkIf mkOption;
inherit (lib.types) str nullOr pathWith;
inherit (lib)
mkEnableOption
mkIf
mkOption
converge
mkMerge
filterAttrsRecursive
literalExpression
mapAttrsToList
concatStringsSep
attrNames
;
inherit (lib.types)
str
nullOr
pathWith
submodule
listOf
functionTo
attrsOf
lines
;
absPath = pathWith {
inStore = false;
absolute = true;
};
cfg = config.xyno.services.kanidm;
tlsDir = "/run/generated/kanidm-tls";
package = pkgs.kanidmWithSecretProvisioning_1_8.overrideAttrs (old: {
doCheck = false;
patches = old.patches ++ [
(pkgs.writeText "patch-kanidm-name" ''
diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs
index 86b5a74c1..c83b2f93d 100644
--- a/server/lib/src/value.rs
+++ b/server/lib/src/value.rs
@@ -64,7 +64,7 @@ lazy_static! {
/// Only lowercase+numbers, with limited chars.
pub static ref INAME_RE: Regex = {
#[allow(clippy::expect_used)]
- Regex::new("^[a-z][a-z0-9-_\\.]{0,63}$").expect("Invalid Iname regex found")
+ Regex::new("^[a-z0-9-_\\.]{0,64}$").expect("Invalid Iname regex found")
};
/// Only alpha-numeric with limited special chars and space
'')
];
});
templatePlaceholders = {
clientId = ''\($get.attrs.name[0])'';
basicSecret = ''\($secret.secret)'';
env = v: ''\(env.${v})'';
};
in
{
options.xyno.services.kanidm.enable = mkEnableOption "enables kanidm";
@ -19,8 +65,75 @@ in
default = "idm.xyno.systems";
type = str;
};
options.xyno.services.kanidm.isReplica = mkEnableOption "replica";
options.xyno.services.kanidm.isReplica = mkEnableOption "replica"; # TODO
options.xyno.services.kanidm.setupTraefik = mkEnableOption "traefik";
options.xyno.services.kanidm.templates = mkOption {
type = attrsOf (
submodule (
{ name, ... }:
{
options = {
path = mkOption {
type = absPath;
default = "/run/generated/kanidmTemplates/${name}";
};
user = mkOption {
type = str;
default = "root";
};
group = mkOption {
type = str;
default = "kanidm";
};
chmod = mkOption {
type = str;
default = "440";
};
wantedBy = mkOption {
type = listOf str;
default = [ ];
example = [ "traccar.service" ];
};
text = mkOption {
type = functionTo lines;
description = ''
jq templated string
current placeholders: ${concatStringsSep ", " (attrNames templatePlaceholders)}
'';
example = literalExpression ''
p: ${"''"}
OAUTH2_PROXY_CLIENT_ID=''${p.clientId}
OAUTH2_PROXY_CLIENT_SECRET=''${p.clientSecret}
${"''"}
'';
};
environmentFiles = mkOption {
type = listOf absPath;
default = [ ];
description = ''
add environment variables to the template file.
the environment variable BANANA would be accessible as
```
COOKIE_SECRET=''${p.env "BANANA"}
```
in the template
'';
};
};
}
)
);
example = {
traccar.text = p: ''
OPENID_CLIENTID=${p.clientId}
OPENID_SECRET=${p.basicSecret}
'';
};
};
options.xyno.services.kanidm.tls = {
keyPem = mkOption {
@ -29,60 +142,144 @@ in
description = "autogenerated if unset";
};
certPem = mkOption {
default = "/run/generated/kanidm-tls/cert.pem";
default = "${tlsDir}/cert.pem";
type = absPath;
};
};
config = mkIf cfg.enable {
services.kanidm = {
enableServer = true;
enableClient = true;
adminPasswordFile = config.sops.secrets."kanidm.password".path;
provision = {
adminPasswordFile = config.sops.secrets."kanidm.password".path;
};
serverSettings = {
tls_key = if cfg.tls.keyPem != null then cfg.tls.keyPem else "/run/generated/key.pem";
tls_chain = cfg.tls.certPem;
bindaddress = "127.0.0.3:8443";
};
};
xyno.services.traefik.simpleProxy = mkIf cfg.setupTraefik {
host = cfg.domain;
internal = "https://127.0.0.3:8443";
transport = "kanidm-https";
};
services.traefik.dynamicConfigOptions.http = mkIf cfg.setupTraefik {
serversTransports."kanidm-https" = {
serverName = cfg.domain;
certificates = [
cfg.certPem
];
};
};
config = mkMerge [
(mkIf cfg.enable {
services.kanidm = {
enableServer = true;
enableClient = true;
inherit package;
clientSettings.uri = "https://${cfg.domain}";
systemd.services.generate-kanidm-tls = mkIf (cfg.tls.keyPem == null) {
serviceConfig = {
User = "root";
Group = "kanidm";
provision = {
enable = true;
adminPasswordFile = config.sops.secrets."kanidm/adminPassword".path;
idmAdminPasswordFile = config.sops.secrets."kanidm/idmAdminPassword".path;
instanceUrl = "https://127.0.0.3:8443";
acceptInvalidCerts = true;
autoRemove = true;
groups.application_admins = {};
};
serverSettings = {
trust_x_forward_for = true;
tls_key = if cfg.tls.keyPem != null then cfg.tls.keyPem else "${tlsDir}/key.pem";
tls_chain = cfg.tls.certPem;
bindaddress = "127.0.0.3:8443";
origin = "https://${cfg.domain}";
domain = cfg.domain;
};
};
wantedBy = [
"kanidm.service"
"traefik.service"
];
script = ''
mkdir -p /run/generated/kanidm-tls
${pkgs.openssl}/bin/openssl req -x509 -newkey ed25519 -noenc -subj "/CN=generated.${cfg.domain}" -addext "subjectAltName=DNS:${cfg.domain}" -keyout /run/generated/key.pem -out /run/generated/cert.pem
'';
};
sops.secrets."kanidm.password" = {
sopsFile = ../../instances/${config.networking.hostName}/secrets/kanidm.yaml;
};
# sops.templates."kanidm.env".content = ''
# DESEC_TOKEN=${config.sops.placeholder.desec_token}
# DESEC_PROPAGATION_TIMEOUT=1200
# '';
# sops.templates."kanidm.env".reloadUnits = [ "kanidm.service" ];
systemd.tmpfiles.rules = [ "d /run/generated/kanidmTemplates 1755 root kanidm -" ];
systemd.services = mkMerge (
(mapAttrsToList (n: v: {
"generate-kanidm-template-${n}" = {
serviceConfig = {
User = "root";
Group = "kanidm";
Type = "oneshot";
PrivateTmp = true;
EnvironmentFile = v.environmentFiles;
};
requires = [ "kanidm.service" ] ++ (lib.optional cfg.setupTraefik "traefik.service");
after = [
"kanidm.service"
"systemd-tmpfiles-setup.service"
]
++ (lib.optional cfg.setupTraefik "traefik.service");
before = v.wantedBy;
partOf = v.wantedBy;
wantedBy = if (builtins.length v.wantedBy) == 0 then [ "multi-user.target" ] else v.wantedBy;
enableStrictShellChecks = true;
path = [
package
pkgs.jq
];
environment.KANIDM_TOKEN_CACHE_PATH = "/tmp/kanidm-token-cache";
script =
let
templateText = v.text templatePlaceholders;
in
''
KANIDM_PASSWORD=$(cat "${
config.sops.secrets."kanidm/idmAdminPassword".path
}") kanidm login -D idm_admin
jq -r -s \
-f "${pkgs.writeText "kanidm-template-${n}" ''"${templateText}"''}" \
--argjson get "$(kanidm system oauth2 get --output json "${n}")" \
--argjson secret "$(kanidm system oauth2 show-basic-secret --output json "${n}")" \
> "${v.path}"
chown "${v.user}:${v.group}" "${v.path}"
chmod "${v.chmod}" "${v.path}"
'';
};
}) cfg.templates)
++ [
(mkIf (cfg.tls.keyPem == null) {
generate-kanidm-tls =
let
units = [
"kanidm.service"
]
++ (lib.optional cfg.setupTraefik "traefik.service");
in
{
serviceConfig = {
User = "root";
Group = "kanidm";
Type = "oneshot";
};
wantedBy = units;
before = units;
script = ''
mkdir -p ${tlsDir}
cd ${tlsDir}
${config.services.kanidm.package}/bin/kanidmd cert-generate -c ${
let
toml = pkgs.formats.toml { };
filterConfig = converge (filterAttrsRecursive (_: v: v != null));
in
toml.generate "kanidm-tls.conf" (filterConfig (config.services.kanidm.serverSettings))
}
chmod +g ${tlsDir}/*
'';
};
})
]
);
sops.secrets."kanidm/adminPassword" = {
sopsFile = ../../instances/${config.networking.hostName}/secrets/kanidm.yaml;
reloadUnits = [ "kanidm.service" ];
owner = "kanidm";
};
sops.secrets."kanidm/idmAdminPassword" = {
sopsFile = ../../instances/${config.networking.hostName}/secrets/kanidm.yaml;
reloadUnits = [ "kanidm.service" ];
owner = "kanidm";
};
xyno.impermanence.directories = [ "/var/lib/kanidm" ];
})
(mkIf (cfg.enable && cfg.setupTraefik) {
xyno.services.traefik.simpleProxy.kanidm = {
host = cfg.domain;
internal = "https://127.0.0.3:8443";
transport = "kanidm-https";
};
services.traefik.dynamicConfigOptions.http = mkIf (cfg.tls.keyPem == null) {
serversTransports."kanidm-https" = {
serverName = cfg.domain;
rootcas = [
"${tlsDir}/ca.pem"
];
};
};
})
];
};
}

View file

@ -0,0 +1,85 @@
{
pkgs,
lib,
config,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkOption
getExe
;
inherit (lib.types)
pathWith
listOf
;
cfg = config.xyno.services.oauth2Proxy;
settingsFormat = pkgs.formats.toml { };
configFile = settingsFormat.generate "oauth2-proxy.conf" cfg.settings;
absPath = pathWith {
inStore = false;
absolute = true;
};
in
{
options.xyno.services.oauth2Proxy = {
enable = mkEnableOption "oauth2-proxy";
package = lib.mkPackageOption pkgs "oauth2-proxy" { };
settings = mkOption {
type = settingsFormat.type;
description = "what to add to the config toml file";
};
environmentFiles = mkOption {
type = listOf absPath;
default = [ ];
example = [ "/run/secrets/oauth2Proxy" ];
};
};
config = mkIf cfg.enable {
systemd.services.oauth2-proxy = {
wantedBy = [ "multi-user.target" ];
description = "OAuth2 Proxy (66642's less weird version)";
confinement.enable = true;
after = [ "network.target" ];
serviceConfig = {
BindReadOnlyPaths = [
"-/etc/resolv.conf"
"-/run/systemd"
"/etc/hosts"
"${config.security.pki.caBundle}:/etc/ssl/certs/ca-certificates.crt"
];
ExecStart = "${getExe cfg.package} --config=${configFile}";
EnvironmentFile = cfg.environmentFiles;
DynamicUser = true;
CapabilityBoundingSet = [ "" ];
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
PrivateDevices = true;
UMask = "0022";
SystemCallFilter = [ "@system-service" ];
SystemCallErrorNumber = "EPERM";
LockPersonality = true;
PrivateTmp = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
};
};
};
}

View file

@ -0,0 +1,208 @@
{
lib,
config,
...
}:
let
inherit (lib)
mkIf
mkOption
mkMerge
attrNames
mapAttrsToList
;
inherit (lib.types)
str
nullOr
submodule
listOf
attrsOf
;
cfg = config.xyno.services.oauth2Proxy;
kanidmCfg = config.xyno.services.kanidm;
oauth2ProxyInternalHostPort = "127.0.0.4:4180";
oauth2ProxyInternalHostPortMetrics = "127.0.0.4:4181";
in
{
options.xyno.services.oauth2Proxy = {
domain = mkOption {
default = "oauth.xyno.systems";
type = str;
};
hosts = mkOption {
type = attrsOf (
submodule (
{ name, ... }:
{
options.allowedGroups = mkOption {
type = nullOr (listOf str);
default = null;
};
options.allowed_email_domains = mkOption {
type = nullOr (listOf str);
default = null;
};
options.allowed_emails = mkOption {
type = nullOr (listOf str);
default = null;
};
options.middlewares = mkOption {
type = listOf str;
description = "add to your service";
default = [
"oauth-errors"
"oauth-host-${name}"
];
};
}
)
);
example = {
"navidrome.xyno.systems" = {
allowedGroups = [ "navidrome_access@idm.xyno.systems" ];
};
};
default = { };
};
};
config = mkIf (cfg.enable && config.xyno.services.kanidm.enable) {
services.kanidm.provision = {
groups = {
proxy_users.members = [ "application_admins" ];
};
systems.oauth2.oauth2_proxy = {
displayName = "oauth2 proxy";
originUrl = [
"https://${cfg.domain}/oauth2/callback"
]
++ (mapAttrsToList (n: v: "https://${n}/oauth2/callback") cfg.hosts);
originLanding = "https://${cfg.domain}/oauth2/sign_in";
preferShortUsername = true;
claimMaps = {
"proxy_group" = {
joinType = "array";
valuesByGroup = {
"proxy_users" = [
"proxy_users"
];
};
};
};
scopeMaps."proxy_users" = [
"email"
"openid"
];
};
};
xyno.services.kanidm.templates.oauth2_proxy = {
wantedBy = [
"oauth2-proxy.service"
];
text = p: ''
OAUTH2_PROXY_CLIENT_ID=${p.clientId}
OAUTH2_PROXY_CLIENT_SECRET=${p.basicSecret}
OAUTH2_PROXY_COOKIE_SECRET=${p.env "COOKIE_SECRET"}
OAUTH2_PROXY_OIDC_ISSUER_URL=https://${kanidmCfg.domain}/oauth2/openid/${p.clientId}
'';
environmentFiles = [ config.sops.templates.oauth2ProxyEnv.path ];
};
sops.secrets."oauth2Proxy/cookieSecret" = {
sopsFile = ../../../instances/${config.networking.hostName}/secrets/kanidm.yaml;
};
sops.templates."oauth2ProxyEnv" = {
restartUnits = [ "generate-kanidm-template-oauth2_proxy.service" ];
content = ''
COOKIE_SECRET=${config.sops.placeholder."oauth2Proxy/cookieSecret"}
'';
};
xyno.services.monitoring.exporters.oauth2Proxy = "http://${oauth2ProxyInternalHostPortMetrics}";
systemd.services.oauth2Proxy.after = [ "traefik.service" ];
xyno.services.oauth2Proxy = {
environmentFiles = [ kanidmCfg.templates.oauth2_proxy.path ];
settings = mkMerge [
{
provider = "oidc";
scope = "openid email";
oidc_groups_claim = "proxy_group";
allowed_groups = [ "proxy_users" ];
http_address = "${oauth2ProxyInternalHostPort}";
https_address = "";
whitelist_domains = attrNames cfg.hosts;
email_domains = "*";
skip_provider_button = true;
code_challenge_method = "S256";
set_xauthrequest = true;
}
(mkIf config.xyno.services.monitoring.enable {
metrics_address = "http://${oauth2ProxyInternalHostPortMetrics}";
})
];
};
xyno.services.traefik.simpleProxy = mkMerge (
[
{
oauth = {
rule = "Host(`${cfg.domain}`) && PathPrefix(`/oauth2`)";
internal = "http://${oauth2ProxyInternalHostPort}";
middlewares = [ "auth-headers" ];
host = cfg.domain;
};
}
]
++ (mapAttrsToList (n: v: {
"oauth-host-${n}" = {
rule = "Host(`${n}`) && PathPrefix(`/oauth2`)";
internal = "http://${oauth2ProxyInternalHostPort}";
middlewares = [ "auth-headers" ];
host = n;
};
}) cfg.hosts)
);
services.traefik.dynamicConfigOptions.http.middlewares = mkMerge (
(mapAttrsToList (n: v: {
"oauth-host-${n}" =
let
maybeQueryArg =
name: value:
if name == "middlewares" || value == null then
null
else
"${name}=${lib.concatStringsSep "," (builtins.map lib.escapeURL value)}";
allArgs = lib.mapAttrsToList maybeQueryArg v;
cleanArgs = builtins.filter (x: x != null) allArgs;
cleanArgsStr = lib.concatStringsSep "&" cleanArgs;
in
{
forwardAuth = {
address = "https://${cfg.domain}/oauth2/auth?${cleanArgsStr}";
authResponseHeaders = [
"X-Auth-Request-User"
"X-Auth-Request-Groups"
"X-Auth-Request-Email"
"X-Auth-Request-Preferred-Username"
];
trustForwardHeader = true;
};
};
}) cfg.hosts)
++ [
{
auth-headers.headers = {
frameDeny = true;
contentTypeNosniff = true;
};
oauth-errors.errors = {
status = [ "401-403" ];
service = config.xyno.services.traefik.simpleProxy.oauth.serviceName;
query = "/oauth2/sign_in?rd={url}";
};
}
]
);
};
}

View file

@ -12,7 +12,9 @@ in
config = lib.mkIf cfg.enable {
services.postgresql = {
enable = true;
package = pkgs.postgresql_17_jit;
package = pkgs.postgresql_18;
enableJIT = true;
extensions = ps: with ps; [ timescaledb-apache ];
identMap = ''
# ArbitraryMapName systemUser DBUser
superuser_map root postgres
@ -26,7 +28,7 @@ in
};
xyno.services.monitoring.exporters.postgres = config.services.prometheus.exporters.postgres.port;
xyno.impermanence.extraDirectories = [ "/var/lib/postgresql" ];
xyno.impermanence.directories = [ "/var/lib/postgresql" ];
services.borgmatic.settings.postgresql_databases = [
{

View file

@ -5,61 +5,82 @@
...
}:
let
inherit (lib)
mapAttrsToList
mkMerge
splitString
concatStringsSep
mkIf
mkOption
mkEnableOption
;
inherit (lib.types)
str
bool
nullOr
anything
attrsOf
submodule
listOf
;
cfg = config.xyno.services.traefik;
simpleProxyOpts = lib.mapAttrsToList (
simpleProxyOpts = mapAttrsToList (
n: v:
let
router = "simpleproxy-${n}-router";
service = "simpleproxy-${n}-service";
spl = lib.splitString "." v.host;
certDomain =
if (builtins.length spl) > 2 then lib.concatStringsSep "." (builtins.tail spl) else spl;
router = v.routerName;
service = v.serviceName;
spl = splitString "." v.host;
certDomain = if (builtins.length spl) > 2 then concatStringsSep "." (builtins.tail spl) else spl;
in
{
routers."${router}-robotstxt" = {
service = "robotstxt";
rule = "Host(`${v.host}`) && Path(`/robots.txt`)";
tls.certResolver = "letsencrypt";
tls.domains = [
{
main = certDomain;
sans = [ "*.${certDomain}" ];
}
];
};
routers.${router} = {
inherit service;
rule = "Host(`${v.host}`)";
tls.certResolver = "letsencrypt";
tls.domains = [
{
main = certDomain;
sans = [ "*.${certDomain}" ];
}
];
};
services.${service} = {
loadBalancer.servers = [
{ url = v.internal; }
];
loadBalancer.serverTransport = lib.mkIf (v.transport != null) v.transport;
};
services.robotstxt = {
loadBalancer.servers = [
{ url = "http://127.0.0.2:8080"; }
];
};
}
mkMerge [
(mkIf v.robotProtection {
routers."${router}-robotstxt" = {
service = "robotstxt";
rule = "Host(`${v.host}`) && Path(`/robots.txt`)";
tls.certResolver = "letsencrypt";
tls.domains = [
{
main = certDomain;
sans = [ "*.${certDomain}" ];
}
];
};
services.robotstxt = {
loadBalancer.servers = [
{ url = "http://127.0.0.2:8080"; }
];
};
})
{
routers.${router} = {
inherit service;
inherit (v) middlewares;
rule = if v.rule != null then v.rule else "Host(`${v.host}`)";
tls.certResolver = "letsencrypt";
tls.domains = [
{
main = certDomain;
sans = [ "*.${certDomain}" ];
}
];
};
services.${service} = {
loadBalancer.servers = [
{ url = v.internal; }
];
loadBalancer.serversTransport = mkIf (v.transport != null) v.transport;
};
}
]
) cfg.simpleProxy;
in
{
options.xyno.services.traefik.enable = lib.mkEnableOption "enables traefik";
options.xyno.services.traefik.noBots = lib.mkOption {
type = lib.types.bool;
options.xyno.services.traefik.enable = mkEnableOption "enables traefik";
options.xyno.services.traefik.noBots = mkOption {
type = bool;
default = true;
};
options.xyno.services.traefik.simpleProxy = lib.mkOption {
options.xyno.services.traefik.simpleProxy = mkOption {
example = {
"example" = {
host = "example.org";
@ -68,38 +89,63 @@ in
};
};
default = { };
type = lib.types.attrsOf (
lib.types.submodule {
options = {
middlewares = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
};
internal = lib.mkOption {
type = lib.types.str;
};
host = lib.mkOption {
type = lib.types.str;
};
transport = lib.mkOption {
type = lib.types.nullOr lib.types.anything;
default = null;
};
type = attrsOf (
submodule (
{ config, name,... }:
{
options = {
middlewares = mkOption {
type = listOf str;
default = [];
};
}
};
internal = mkOption {
type = str;
description = "where to proxy to";
};
host = mkOption {
type = str;
description = "used for the route and tls";
};
routerName = mkOption {
type = str;
default = "simpleproxy-${name}-router";
};
serviceName = mkOption {
type = str;
default = "simpleproxy-${name}-service";
};
robotProtection = mkOption {
type = bool;
default = true;
description = "robots.txt and (soon) iocane";
};
rule = mkOption {
type = str;
default = "Host(`${config.host}`)";
description = "overrides the Host(`\${host}`) rule with something custom if set";
};
transport = mkOption {
type = nullOr anything;
default = null;
};
};
}
)
);
};
config = lib.mkIf cfg.enable {
config = mkIf cfg.enable {
services.nginx = {
enable = lib.mkIf cfg.noBots true;
defaultListen = lib.mkIf cfg.noBots [
enable = mkIf cfg.noBots true;
defaultListen = mkIf cfg.noBots [
{
addr = "127.0.0.2";
port = 8080;
}
];
virtualHosts._.default = true;
virtualHosts._.default = true;
virtualHosts._.locations."/".root = pkgs.writeTextFile {
name = "robots.txt";
destination = "/robots.txt";
@ -115,7 +161,8 @@ in
config.sops.templates."traefik.env".path
];
staticConfigOptions = {
metrics = lib.mkIf config.xyno.services.monitoring.enable {
accessLog = {};
metrics = mkIf config.xyno.services.monitoring.enable {
otlp.http.endpoint = "http://localhost:8429/v1/metrics";
};
entryponits.web = {
@ -137,13 +184,16 @@ in
email = "ssl@xyno.systems";
caServer = "https://acme-v02.api.letsencrypt.org/directory";
dnsChallenge = {
resolvers = [ "8.8.8.8" "1.1.1.1" ];
resolvers = [
"8.8.8.8"
"1.1.1.1"
];
provider = "desec";
};
};
};
dynamicConfigOptions = {
http = lib.mkMerge simpleProxyOpts;
http = mkMerge simpleProxyOpts;
# tls.options.default = {
# # mozilla modern
# minVersion = "VersionTLS13";