parent
9b6223d1fa
commit
8514800c42
@ -0,0 +1,560 @@ |
||||
{ lib, pkgs, config, ... }: |
||||
|
||||
with lib; |
||||
|
||||
let |
||||
cfg = config.services.public-inbox; |
||||
stateDir = "/var/lib/public-inbox"; |
||||
|
||||
manref = name: vol: "<citerefentry><refentrytitle>${name}</refentrytitle><manvolnum>${toString vol}</manvolnum></citerefentry>"; |
||||
|
||||
gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; }; |
||||
iniAtom = elemAt gitIni.type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped/*either*/.functor.wrapped 0; |
||||
|
||||
useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" || |
||||
cfg.settings.publicinboxwatch.spamcheck == "spamc"; |
||||
|
||||
publicInboxDaemonOptions = proto: defaultPort: { |
||||
args = mkOption { |
||||
type = with types; listOf str; |
||||
default = []; |
||||
description = "Command-line arguments to pass to ${manref "public-inbox-${proto}d" 1}."; |
||||
}; |
||||
port = mkOption { |
||||
type = with types; nullOr (either str port); |
||||
default = defaultPort; |
||||
description = '' |
||||
Listening port. |
||||
Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not. |
||||
Set to null and use <code>systemd.sockets.public-inbox-${proto}d.listenStreams</code> |
||||
if you need a more advanced listening. |
||||
''; |
||||
}; |
||||
cert = mkOption { |
||||
type = with types; nullOr str; |
||||
default = null; |
||||
example = "/path/to/fullchain.pem"; |
||||
description = "Path to TLS certificate to use for connections to ${manref "public-inbox-${proto}d" 1}."; |
||||
}; |
||||
key = mkOption { |
||||
type = with types; nullOr str; |
||||
default = null; |
||||
example = "/path/to/key.pem"; |
||||
description = "Path to TLS key to use for connections to ${manref "public-inbox-${proto}d" 1}."; |
||||
}; |
||||
}; |
||||
|
||||
serviceConfig = srv: |
||||
let proto = removeSuffix "d" srv; |
||||
needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null; |
||||
in { |
||||
# Enable JIT-compiled C (via Inline::C) |
||||
Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ]; |
||||
# NonBlocking is REQUIRED to avoid a race condition |
||||
# if running simultaneous services. |
||||
NonBlocking = true; |
||||
#LimitNOFILE = 30000; |
||||
User = config.users.users."public-inbox".name; |
||||
Group = config.users.groups."public-inbox".name; |
||||
RuntimeDirectory = [ |
||||
"public-inbox-${srv}/perl-inline" |
||||
# Create RootDirectory= in the host's mount namespace. |
||||
"public-inbox-${srv}/root" |
||||
]; |
||||
RuntimeDirectoryMode = "700"; |
||||
# Avoid mounting RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. |
||||
InaccessiblePaths = ["-+/run/public-inbox-${srv}/root"]; |
||||
# This is for BindPaths= and BindReadOnlyPaths= |
||||
# to allow traversal of directories they create in RootDirectory=. |
||||
UMask = "0066"; |
||||
RootDirectory = "/run/public-inbox-${srv}/root"; |
||||
RootDirectoryStartOnly = true; |
||||
WorkingDirectory = stateDir; |
||||
MountAPIVFS = true; |
||||
BindReadOnlyPaths = [ |
||||
builtins.storeDir |
||||
"/etc" |
||||
"/run" |
||||
# For Inline::C |
||||
"/bin/sh" |
||||
]; |
||||
BindPaths = [ |
||||
stateDir |
||||
]; |
||||
# The following options are only for optimizing: |
||||
# systemd-analyze security public-inbox-'*' |
||||
AmbientCapabilities = ""; |
||||
CapabilityBoundingSet = ""; |
||||
# ProtectClock= adds DeviceAllow=char-rtc r |
||||
DeviceAllow = ""; |
||||
LockPersonality = true; |
||||
MemoryDenyWriteExecute = true; |
||||
NoNewPrivileges = true; |
||||
PrivateDevices = true; |
||||
PrivateMounts = true; |
||||
PrivateNetwork = mkDefault (!needNetwork); |
||||
PrivateTmp = true; |
||||
PrivateUsers = true; |
||||
ProcSubset = "pid"; |
||||
ProtectClock = true; |
||||
ProtectControlGroups = true; |
||||
ProtectHome = mkDefault true; |
||||
ProtectHostname = true; |
||||
ProtectKernelLogs = true; |
||||
ProtectKernelModules = true; |
||||
ProtectKernelTunables = true; |
||||
ProtectProc = "invisible"; |
||||
ProtectSystem = "strict"; |
||||
RemoveIPC = true; |
||||
RestrictAddressFamilies = [ "AF_UNIX" ] |
||||
++ optionals needNetwork [ "AF_INET" "AF_INET6" ]; |
||||
RestrictNamespaces = true; |
||||
RestrictRealtime = true; |
||||
RestrictSUIDSGID = true; |
||||
SystemCallFilter = [ |
||||
"@system-service" |
||||
"~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources" |
||||
# Not removing @setuid and @privileged |
||||
# because Inline::C needs them. |
||||
# Not removing @timer |
||||
# because git upload-pack needs it. |
||||
]; |
||||
SystemCallArchitectures = "native"; |
||||
}; |
||||
in |
||||
|
||||
{ |
||||
options.services.public-inbox = { |
||||
enable = mkEnableOption "the public-inbox mail archiver"; |
||||
package = mkOption { |
||||
type = types.package; |
||||
default = pkgs.public-inbox; |
||||
defaultText = literalExpression "pkgs.public-inbox"; |
||||
description = "public-inbox package to use."; |
||||
}; |
||||
path = mkOption { |
||||
type = with types; listOf package; |
||||
default = []; |
||||
example = literalExpression "with pkgs; [ spamassassin ]"; |
||||
description = '' |
||||
Additional packages to place in the path of public-inbox-mda, |
||||
public-inbox-watch, etc. |
||||
''; |
||||
}; |
||||
inboxes = mkOption { |
||||
description = '' |
||||
Inboxes to configure, where attribute names are inbox names. |
||||
''; |
||||
default = {}; |
||||
type = types.attrsOf (types.submodule ({name, ...}: { |
||||
freeformType = types.attrsOf iniAtom; |
||||
options.inboxdir = mkOption { |
||||
type = types.str; |
||||
default = "${stateDir}/inboxes/${name}"; |
||||
description = "The absolute path to the directory which hosts the public-inbox."; |
||||
}; |
||||
options.address = mkOption { |
||||
type = with types; listOf str; |
||||
example = "example-discuss@example.org"; |
||||
description = "The email addresses of the public-inbox."; |
||||
}; |
||||
options.url = mkOption { |
||||
type = with types; nullOr str; |
||||
default = null; |
||||
example = "https://example.org/lists/example-discuss"; |
||||
description = "URL where this inbox can be accessed over HTTP."; |
||||
}; |
||||
options.description = mkOption { |
||||
type = types.str; |
||||
example = "user/dev discussion of public-inbox itself"; |
||||
description = "User-visible description for the repository."; |
||||
}; |
||||
options.newsgroup = mkOption { |
||||
type = with types; nullOr str; |
||||
default = null; |
||||
description = "NNTP group name for the inbox."; |
||||
}; |
||||
options.watch = mkOption { |
||||
type = with types; listOf str; |
||||
default = []; |
||||
description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail."; |
||||
example = [ "maildir:/path/to/test.example.com.git" ]; |
||||
}; |
||||
options.watchheader = mkOption { |
||||
type = with types; nullOr str; |
||||
default = null; |
||||
example = "List-Id:<test@example.com>"; |
||||
description = '' |
||||
If specified, ${manref "public-inbox-watch" 1} will only process |
||||
mail containing a matching header. |
||||
''; |
||||
}; |
||||
options.coderepo = mkOption { |
||||
type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // { |
||||
description = "list of coderepo names"; |
||||
}; |
||||
default = []; |
||||
description = "Nicknames of a 'coderepo' section associated with the inbox."; |
||||
}; |
||||
})); |
||||
}; |
||||
imap = { |
||||
enable = mkEnableOption "the public-inbox IMAP server"; |
||||
} // publicInboxDaemonOptions "imap" 993; |
||||
http = { |
||||
enable = mkEnableOption "the public-inbox HTTP server"; |
||||
mounts = mkOption { |
||||
type = with types; listOf str; |
||||
default = [ "/" ]; |
||||
example = [ "/lists/archives" ]; |
||||
description = '' |
||||
Root paths or URLs that public-inbox will be served on. |
||||
If domain parts are present, only requests to those |
||||
domains will be accepted. |
||||
''; |
||||
}; |
||||
args = (publicInboxDaemonOptions "http" 80).args; |
||||
port = mkOption { |
||||
type = with types; nullOr (either str port); |
||||
default = 80; |
||||
example = "/run/public-inbox-httpd.sock"; |
||||
description = '' |
||||
Listening port or systemd's ListenStream= entry |
||||
to be used as a reverse proxy, eg. in nginx: |
||||
<code>locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";</code> |
||||
Set to null and use <code>systemd.sockets.public-inbox-httpd.listenStreams</code> |
||||
if you need a more advanced listening. |
||||
''; |
||||
}; |
||||
}; |
||||
mda = { |
||||
enable = mkEnableOption "the public-inbox Mail Delivery Agent"; |
||||
args = mkOption { |
||||
type = with types; listOf str; |
||||
default = []; |
||||
description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}."; |
||||
}; |
||||
}; |
||||
postfix.enable = mkEnableOption "the integration into Postfix"; |
||||
nntp = { |
||||
enable = mkEnableOption "the public-inbox NNTP server"; |
||||
} // publicInboxDaemonOptions "nntp" 563; |
||||
spamAssassinRules = mkOption { |
||||
type = with types; nullOr path; |
||||
default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs"; |
||||
defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs"; |
||||
description = "SpamAssassin configuration specific to public-inbox."; |
||||
}; |
||||
settings = mkOption { |
||||
description = '' |
||||
Settings for the <link xlink:href="https://public-inbox.org/public-inbox-config.html">public-inbox config file</link>. |
||||
''; |
||||
default = {}; |
||||
type = types.submodule { |
||||
freeformType = gitIni.type; |
||||
options.publicinbox = mkOption { |
||||
default = {}; |
||||
description = "public inboxes"; |
||||
type = types.submodule { |
||||
freeformType = with types; /*inbox name*/attrsOf (/*inbox option name*/attrsOf /*inbox option value*/iniAtom); |
||||
options.css = mkOption { |
||||
type = with types; listOf str; |
||||
default = []; |
||||
description = "The local path name of a CSS file for the PSGI web interface."; |
||||
}; |
||||
options.nntpserver = mkOption { |
||||
type = with types; listOf str; |
||||
default = []; |
||||
example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ]; |
||||
description = "NNTP URLs to this public-inbox instance"; |
||||
}; |
||||
options.wwwlisting = mkOption { |
||||
type = with types; enum [ "all" "404" "match=domain" ]; |
||||
default = "404"; |
||||
description = '' |
||||
Controls which lists (if any) are listed for when the root |
||||
public-inbox URL is accessed over HTTP. |
||||
''; |
||||
}; |
||||
}; |
||||
}; |
||||
options.publicinboxmda.spamcheck = mkOption { |
||||
type = with types; enum [ "spamc" "none" ]; |
||||
default = "none"; |
||||
description = '' |
||||
If set to spamc, ${manref "public-inbox-watch" 1} will filter spam |
||||
using SpamAssassin. |
||||
''; |
||||
}; |
||||
options.publicinboxwatch.spamcheck = mkOption { |
||||
type = with types; enum [ "spamc" "none" ]; |
||||
default = "none"; |
||||
description = '' |
||||
If set to spamc, ${manref "public-inbox-watch" 1} will filter spam |
||||
using SpamAssassin. |
||||
''; |
||||
}; |
||||
options.publicinboxwatch.watchspam = mkOption { |
||||
type = with types; nullOr str; |
||||
default = null; |
||||
example = "maildir:/path/to/spam"; |
||||
description = '' |
||||
If set, mail in this maildir will be trained as spam and |
||||
deleted from all watched inboxes |
||||
''; |
||||
}; |
||||
options.coderepo = mkOption { |
||||
default = {}; |
||||
description = "code repositories"; |
||||
type = types.attrsOf (types.submodule { |
||||
freeformType = types.attrsOf iniAtom; |
||||
options.cgitUrl = mkOption { |
||||
type = types.str; |
||||
description = "URL of a cgit instance"; |
||||
}; |
||||
options.dir = mkOption { |
||||
type = types.str; |
||||
description = "Path to a git repository"; |
||||
}; |
||||
}); |
||||
}; |
||||
}; |
||||
}; |
||||
openFirewall = mkEnableOption "opening the firewall when using a port option"; |
||||
}; |
||||
config = mkIf cfg.enable { |
||||
assertions = [ |
||||
{ assertion = config.services.spamassassin.enable || !useSpamAssassin; |
||||
message = '' |
||||
public-inbox is configured to use SpamAssassin, but |
||||
services.spamassassin.enable is false. If you don't need |
||||
spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and |
||||
`services.public-inbox.settings.publicinboxwatch.spamcheck' to null. |
||||
''; |
||||
} |
||||
{ assertion = cfg.path != [] || !useSpamAssassin; |
||||
message = '' |
||||
public-inbox is configured to use SpamAssassin, but there is |
||||
no spamc executable in services.public-inbox.path. If you |
||||
don't need spam checking, set |
||||
`services.public-inbox.settings.publicinboxmda.spamcheck' and |
||||
`services.public-inbox.settings.publicinboxwatch.spamcheck' to null. |
||||
''; |
||||
} |
||||
]; |
||||
services.public-inbox.settings = |
||||
filterAttrsRecursive (n: v: v != null) { |
||||
publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes; |
||||
}; |
||||
users = { |
||||
users.public-inbox = { |
||||
home = stateDir; |
||||
group = "public-inbox"; |
||||
isSystemUser = true; |
||||
}; |
||||
groups.public-inbox = {}; |
||||
}; |
||||
networking.firewall = mkIf cfg.openFirewall |
||||
{ allowedTCPPorts = mkMerge (map (proto: |
||||
(mkIf (cfg.${proto}.enable && types.port.check cfg.proto.port) [ cfg.proto.port ]) |
||||
["imap" "http" "nntp"])); |
||||
}; |
||||
services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) { |
||||
# Not sure limiting to 1 is necessary, but better safe than sorry. |
||||
config.public-inbox_destination_recipient_limit = "1"; |
||||
|
||||
# Register the addresses as existing |
||||
virtual = |
||||
concatStringsSep "\n" (mapAttrsToList (_: inbox: |
||||
concatMapStringsSep "\n" (address: |
||||
"${address} ${address}" |
||||
) inbox.address |
||||
) cfg.inboxes); |
||||
|
||||
# Deliver the addresses with the public-inbox transport |
||||
transport = |
||||
concatStringsSep "\n" (mapAttrsToList (_: inbox: |
||||
concatMapStringsSep "\n" (address: |
||||
"${address} public-inbox:${address}" |
||||
) inbox.address |
||||
) cfg.inboxes); |
||||
|
||||
# The public-inbox transport |
||||
masterConfig.public-inbox = { |
||||
type = "unix"; |
||||
privileged = true; # Required for user= |
||||
command = "pipe"; |
||||
args = [ |
||||
"flags=X" # Report as a final delivery |
||||
"user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}" |
||||
# Specifying a nexthop when using the transport |
||||
# (eg. test public-inbox:test) allows to |
||||
# receive mails with an extension (eg. test+foo). |
||||
"argv=${pkgs.writeShellScript "public-inbox-transport" '' |
||||
export HOME="${stateDir}" |
||||
export ORIGINAL_RECIPIENT="''${2:-1}" |
||||
export PATH="${makeBinPath cfg.path}:$PATH" |
||||
exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args} |
||||
''} \${original_recipient} \${nexthop}" |
||||
]; |
||||
}; |
||||
}; |
||||
systemd.sockets = mkMerge (map (proto: |
||||
mkIf (cfg.${proto}.enable && cfg.${proto}.port != null) |
||||
{ "public-inbox-${proto}d" = { |
||||
listenStreams = [ (toString cfg.${proto}.port) ]; |
||||
wantedBy = [ "sockets.target" ]; |
||||
}; |
||||
} |
||||
) [ "imap" "http" "nntp" ]); |
||||
systemd.services = mkMerge [ |
||||
(mkIf cfg.imap.enable |
||||
{ public-inbox-imapd = { |
||||
after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; |
||||
requires = [ "public-inbox-init.service" ]; |
||||
serviceConfig = mkMerge [(serviceConfig "imapd") { |
||||
ExecStart = escapeShellArgs ( |
||||
[ "${cfg.package}/bin/public-inbox-imapd" ] ++ |
||||
cfg.imap.args ++ |
||||
optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++ |
||||
optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ] |
||||
); |
||||
}]; |
||||
}; |
||||
}) |
||||
(mkIf cfg.http.enable |
||||
{ public-inbox-httpd = { |
||||
after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; |
||||
requires = [ "public-inbox-init.service" ]; |
||||
serviceConfig = mkMerge [(serviceConfig "httpd") { |
||||
ExecStart = escapeShellArgs ( |
||||
[ "${cfg.package}/bin/public-inbox-httpd" ] ++ |
||||
cfg.http.args ++ |
||||
# See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi |
||||
# for upstream's example. |
||||
[ (pkgs.writeText "public-inbox.psgi" '' |
||||
#!${cfg.package.fullperl} -w |
||||
use strict; |
||||
use warnings; |
||||
use Plack::Builder; |
||||
use PublicInbox::WWW; |
||||
|
||||
my $www = PublicInbox::WWW->new; |
||||
$www->preload; |
||||
|
||||
builder { |
||||
# If reached through a reverse proxy, |
||||
# make it transparent by resetting some HTTP headers |
||||
# used by public-inbox to generate URIs. |
||||
enable 'ReverseProxy'; |
||||
|
||||
# No need to send a response body if it's an HTTP HEAD requests. |
||||
enable 'Head'; |
||||
|
||||
# Route according to configured domains and root paths. |
||||
${concatMapStrings (path: '' |
||||
mount q(${path}) => sub { $www->call(@_); }; |
||||
'') cfg.http.mounts} |
||||
} |
||||
'') ] |
||||
); |
||||
}]; |
||||
}; |
||||
}) |
||||
(mkIf cfg.nntp.enable |
||||
{ public-inbox-nntpd = { |
||||
after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; |
||||
requires = [ "public-inbox-init.service" ]; |
||||
serviceConfig = mkMerge [(serviceConfig "nntpd") { |
||||
ExecStart = escapeShellArgs ( |
||||
[ "${cfg.package}/bin/public-inbox-nntpd" ] ++ |
||||
cfg.nntp.args ++ |
||||
optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++ |
||||
optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ] |
||||
); |
||||
}]; |
||||
}; |
||||
}) |
||||
(mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes) |
||||
|| cfg.settings.publicinboxwatch.watchspam != null) |
||||
{ public-inbox-watch = { |
||||
inherit (cfg) path; |
||||
wants = [ "public-inbox-init.service" ]; |
||||
requires = [ "public-inbox-init.service" ] ++ |
||||
optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service"; |
||||
wantedBy = [ "multi-user.target" ]; |
||||
serviceConfig = mkMerge [(serviceConfig "watch") { |
||||
ExecStart = "${cfg.package}/bin/public-inbox-watch"; |
||||
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; |
||||
}]; |
||||
}; |
||||
}) |
||||
({ public-inbox-init = let |
||||
PI_CONFIG = gitIni.generate "public-inbox.ini" |
||||
(filterAttrsRecursive (n: v: v != null) cfg.settings); |
||||
in { |
||||
wantedBy = [ "multi-user.target" ]; |
||||
restartIfChanged = true; |
||||
restartTriggers = [ PI_CONFIG ]; |
||||
script = '' |
||||
set -ux |
||||
install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config |
||||
'' + optionalString useSpamAssassin '' |
||||
install -m 0700 -o spamd -d ${stateDir}/.spamassassin |
||||
${optionalString (cfg.spamAssassinRules != null) '' |
||||
ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs |
||||
''} |
||||
'' + concatStrings (mapAttrsToList (name: inbox: '' |
||||
if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then |
||||
# public-inbox-init creates an inbox and adds it to a config file. |
||||
# It tries to atomically write the config file by creating |
||||
# another file in the same directory, and renaming it. |
||||
# This has the sad consequence that we can't use |
||||
# /dev/null, or it would try to create a file in /dev. |
||||
conf_dir="$(mktemp -d)" |
||||
|
||||
PI_CONFIG=$conf_dir/conf \ |
||||
${cfg.package}/bin/public-inbox-init -V2 \ |
||||
${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)} |
||||
|
||||
rm -rf $conf_dir |
||||
fi |
||||
|
||||
ln -sf ${pkgs.writeText "description" inbox.description} \ |
||||
${stateDir}/inboxes/${escapeShellArg name}/description |
||||
|
||||
export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git |
||||
if test -d "$GIT_DIR"; then |
||||
# Config is inherited by each epoch repository, |
||||
# so just needs to be set for all.git. |
||||
${pkgs.git}/bin/git config core.sharedRepository 0640 |
||||
fi |
||||
'') cfg.inboxes |
||||
) + '' |
||||
shopt -s nullglob |
||||
for inbox in ${stateDir}/inboxes/*/; do |
||||
# This should be idempotent, but only do it for new |
||||
# inboxes anyway because it's only needed once, and could |
||||
# be slow for large pre-existing inboxes. |
||||
ls -1 "$inbox" | grep -q '^xap' || |
||||
${cfg.package}/bin/public-inbox-index "$inbox" |
||||
done |
||||
''; |
||||
serviceConfig = mkMerge [(serviceConfig "init") { |
||||
Type = "oneshot"; |
||||
RemainAfterExit = true; |
||||
StateDirectory = [ |
||||
"public-inbox" |
||||
"public-inbox/.public-inbox" |
||||
"public-inbox/.public-inbox/emergency" |
||||
"public-inbox/inboxes" |
||||
]; |
||||
StateDirectoryMode = "0750"; |
||||
}]; |
||||
}; |
||||
}) |
||||
]; |
||||
environment.systemPackages = with pkgs; [ cfg.package ]; |
||||
}; |
||||
meta.maintainers = with lib.maintainers; [ julm qyliss ]; |
||||
} |
Loading…
Reference in new issue