push sheeet
Some checks failed
Periodic Merges (6h) / master → staging-nixos (push) Failing after 12m50s
Periodic Merges (6h) / master → staging-next (push) Failing after 12m54s
Periodic Merges (24h) / merge-base(master,staging) → haskell-updates (push) Failing after 11m54s
Periodic Merges (6h) / staging-next → staging (push) Failing after 12m13s
Periodic Merges (24h) / staging-next-25.05 → staging-25.05 (push) Failing after 13m24s
Periodic Merges (24h) / release-25.05 → staging-next-25.05 (push) Failing after 14m28s

This commit is contained in:
Dark Steveneq
2025-10-09 14:15:47 +02:00
commit 646b892680
49168 changed files with 5897842 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
{
lib,
pkgs,
config,
...
}:
let
inherit (lib)
getExe
mkDefault
mkEnableOption
mkIf
mkOption
mkPackageOption
types
;
cfg = config.services.actual;
configFile = formatType.generate "config.json" cfg.settings;
dataDir = "/var/lib/actual";
formatType = pkgs.formats.json { };
in
{
options.services.actual = {
enable = mkEnableOption "actual, a privacy focused app for managing your finances";
package = mkPackageOption pkgs "actual-server" { };
openFirewall = mkOption {
default = false;
type = types.bool;
description = "Whether to open the firewall for the specified port.";
};
settings = mkOption {
default = { };
description = "Server settings, refer to [the documentation](https://actualbudget.org/docs/config/) for available options.";
type = types.submodule {
freeformType = formatType.type;
options = {
hostname = mkOption {
type = types.str;
description = "The address to listen on";
default = "::";
};
port = mkOption {
type = types.port;
description = "The port to listen on";
default = 3000;
};
};
config = {
serverFiles = mkDefault "${dataDir}/server-files";
userFiles = mkDefault "${dataDir}/user-files";
dataDir = mkDefault dataDir;
};
};
};
};
config = mkIf cfg.enable {
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.settings.port ];
systemd.services.actual = {
description = "Actual server, a local-first personal finance app";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment.ACTUAL_CONFIG_PATH = configFile;
serviceConfig = {
ExecStart = getExe cfg.package;
DynamicUser = true;
User = "actual";
Group = "actual";
StateDirectory = "actual";
WorkingDirectory = dataDir;
LimitNOFILE = "1048576";
PrivateTmp = true;
PrivateDevices = true;
StateDirectoryMode = "0700";
Restart = "always";
# Hardening
CapabilityBoundingSet = "";
LockPersonality = true;
#MemoryDenyWriteExecute = true; # Leads to coredump because V8 does JIT
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"@pkey"
];
UMask = "0077";
};
};
};
meta.maintainers = [
lib.maintainers.oddlama
lib.maintainers.patrickdag
];
}

View File

@@ -0,0 +1,487 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.agorakit;
agorakit = pkgs.agorakit.override { dataDir = cfg.dataDir; };
db = cfg.database;
mail = cfg.mail;
user = cfg.user;
group = cfg.group;
php = lib.getExe cfg.phpPackage;
# shell script for local administration
artisan = pkgs.writeScriptBin "agorakit" ''
#! ${pkgs.runtimeShell}
cd ${agorakit}
sudo() {
if [[ "$USER" != ${user} ]]; then
exec /run/wrappers/bin/sudo -u ${user} "$@"
else
exec "$@"
fi
}
sudo ${php} artisan "$@"
'';
tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
in
{
options.services.agorakit = {
enable = mkEnableOption "agorakit";
phpPackage = mkPackageOption pkgs "php82" { };
user = mkOption {
default = "agorakit";
description = "User agorakit runs as.";
type = types.str;
};
group = mkOption {
default = "agorakit";
description = "Group agorakit runs as.";
type = types.str;
};
appKeyFile = mkOption {
description = ''
A file containing the Laravel APP_KEY - a 32 character long,
base64 encoded key used for encryption where needed. Can be
generated with <code>head -c 32 /dev/urandom | base64</code>.
'';
example = "/run/keys/agorakit-appkey";
type = types.path;
};
hostName = lib.mkOption {
type = lib.types.str;
default =
if config.networking.domain != null then config.networking.fqdn else config.networking.hostName;
defaultText = lib.literalExpression "config.networking.fqdn";
example = "agorakit.example.com";
description = ''
The hostname to serve agorakit on.
'';
};
appURL = mkOption {
description = ''
The root URL that you want to host agorakit on. All URLs in agorakit will be generated using this value.
If you change this in the future you may need to run a command to update stored URLs in the database.
Command example: <code>php artisan agorakit:update-url https://old.example.com https://new.example.com</code>
'';
default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostName}";
defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostName}'';
example = "https://example.com";
type = types.str;
};
dataDir = mkOption {
description = "agorakit data directory";
default = "/var/lib/agorakit";
type = types.path;
};
database = {
host = mkOption {
type = types.str;
default = "localhost";
description = "Database host address.";
};
port = mkOption {
type = types.port;
default = 3306;
description = "Database host port.";
};
name = mkOption {
type = types.str;
default = "agorakit";
description = "Database name.";
};
user = mkOption {
type = types.str;
default = user;
defaultText = lib.literalExpression "user";
description = "Database username.";
};
passwordFile = mkOption {
type = with types; nullOr path;
default = null;
example = "/run/keys/agorakit-dbpassword";
description = ''
A file containing the password corresponding to
<option>database.user</option>.
'';
};
createLocally = mkOption {
type = types.bool;
default = true;
description = "Create the database and database user locally.";
};
};
mail = {
driver = mkOption {
type = types.enum [
"smtp"
"sendmail"
];
default = "smtp";
description = "Mail driver to use.";
};
host = mkOption {
type = types.str;
default = "localhost";
description = "Mail host address.";
};
port = mkOption {
type = types.port;
default = 1025;
description = "Mail host port.";
};
fromName = mkOption {
type = types.str;
default = "agorakit";
description = "Mail \"from\" name.";
};
from = mkOption {
type = types.str;
default = "mail@agorakit.com";
description = "Mail \"from\" email.";
};
user = mkOption {
type = with types; nullOr str;
default = null;
example = "agorakit";
description = "Mail username.";
};
passwordFile = mkOption {
type = with types; nullOr path;
default = null;
example = "/run/keys/agorakit-mailpassword";
description = ''
A file containing the password corresponding to
<option>mail.user</option>.
'';
};
encryption = mkOption {
type = with types; nullOr (enum [ "tls" ]);
default = null;
description = "SMTP encryption mechanism to use.";
};
};
maxUploadSize = mkOption {
type = types.str;
default = "18M";
example = "1G";
description = "The maximum size for uploads (e.g. images).";
};
poolConfig = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for the agorakit PHP pool. See the documentation on <literal>php-fpm.conf</literal>
for details on configuration directives.
'';
};
nginx = mkOption {
type = types.submodule (
recursiveUpdate (import ../web-servers/nginx/vhost-options.nix {
inherit config lib;
}) { }
);
default = { };
example = ''
{
serverAliases = [
"agorakit.''${config.networking.domain}"
];
# To enable encryption and let let's encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
description = ''
With this option, you can customize the nginx virtualHost settings.
'';
};
config = mkOption {
type =
with types;
attrsOf (
nullOr (
either
(oneOf [
bool
int
port
path
str
])
(submodule {
options = {
_secret = mkOption {
type = nullOr str;
description = ''
The path to a file containing the value the
option should be set to in the final
configuration file.
'';
};
};
})
)
);
default = { };
example = ''
{
ALLOWED_IFRAME_HOSTS = "https://example.com";
AUTH_METHOD = "oidc";
OIDC_NAME = "MyLogin";
OIDC_DISPLAY_NAME_CLAIMS = "name";
OIDC_CLIENT_ID = "agorakit";
OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
OIDC_ISSUER_DISCOVER = true;
}
'';
description = ''
Agorakit configuration options to set in the
<filename>.env</filename> file.
Refer to <link xlink:href="https://github.com/agorakit/agorakit"/>
for details on supported values.
Settings containing secret data should be set to an attribute
set containing the attribute <literal>_secret</literal> - a
string pointing to a file containing the value the option
should be set to. See the example to get a better picture of
this: in the resulting <filename>.env</filename> file, the
<literal>OIDC_CLIENT_SECRET</literal> key will be set to the
contents of the <filename>/run/keys/oidc_secret</filename>
file.
'';
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = db.createLocally -> db.user == user;
message = "services.agorakit.database.user must be set to ${user} if services.agorakit.database.createLocally is set true.";
}
{
assertion = db.createLocally -> db.passwordFile == null;
message = "services.agorakit.database.passwordFile cannot be specified if services.agorakit.database.createLocally is set to true.";
}
];
services.agorakit.config = {
APP_ENV = "production";
APP_KEY._secret = cfg.appKeyFile;
APP_URL = cfg.appURL;
DB_HOST = db.host;
DB_PORT = db.port;
DB_DATABASE = db.name;
DB_USERNAME = db.user;
MAIL_DRIVER = mail.driver;
MAIL_FROM_NAME = mail.fromName;
MAIL_FROM = mail.from;
MAIL_HOST = mail.host;
MAIL_PORT = mail.port;
MAIL_USERNAME = mail.user;
MAIL_ENCRYPTION = mail.encryption;
DB_PASSWORD._secret = db.passwordFile;
MAIL_PASSWORD._secret = mail.passwordFile;
APP_SERVICES_CACHE = "/run/agorakit/cache/services.php";
APP_PACKAGES_CACHE = "/run/agorakit/cache/packages.php";
APP_CONFIG_CACHE = "/run/agorakit/cache/config.php";
APP_ROUTES_CACHE = "/run/agorakit/cache/routes-v7.php";
APP_EVENTS_CACHE = "/run/agorakit/cache/events.php";
SESSION_SECURE_COOKIE = tlsEnabled;
};
environment.systemPackages = [ artisan ];
services.mysql = mkIf db.createLocally {
enable = true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ db.name ];
ensureUsers = [
{
name = db.user;
ensurePermissions = {
"${db.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.phpfpm.pools.agorakit = {
inherit user group;
phpPackage = cfg.phpPackage;
phpOptions = ''
log_errors = on
post_max_size = ${cfg.maxUploadSize}
upload_max_filesize = ${cfg.maxUploadSize}
'';
settings = {
"listen.mode" = "0660";
"listen.owner" = user;
"listen.group" = group;
}
// cfg.poolConfig;
};
services.nginx = {
enable = mkDefault true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedBrotliSettings = true;
recommendedProxySettings = true;
virtualHosts.${cfg.hostName} = mkMerge [
cfg.nginx
{
root = mkForce "${agorakit}/public";
locations = {
"/" = {
index = "index.php";
tryFiles = "$uri $uri/ /index.php?$query_string";
};
"~ \\.php$".extraConfig = ''
fastcgi_pass unix:${config.services.phpfpm.pools."agorakit".socket};
'';
"~ \\.(js|css|gif|png|ico|jpg|jpeg)$" = {
extraConfig = "expires 365d;";
};
};
}
];
};
systemd.services.agorakit-setup = {
description = "Preparation tasks for agorakit";
before = [ "phpfpm-agorakit.service" ];
after = optional db.createLocally "mysql.service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = user;
UMask = 77;
WorkingDirectory = "${agorakit}";
RuntimeDirectory = "agorakit/cache";
RuntimeDirectoryMode = 700;
};
path = [ pkgs.replace-secret ];
script =
let
isSecret = v: isAttrs v && v ? _secret && isString v._secret;
agorakitEnvVars = lib.generators.toKeyValue {
mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
mkValueString =
v:
with builtins;
if isInt v then
toString v
else if isString v then
v
else if true == v then
"true"
else if false == v then
"false"
else if isSecret v then
hashString "sha256" v._secret
else
throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}";
};
};
secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
mkSecretReplacement = file: ''
replace-secret ${
escapeShellArgs [
(builtins.hashString "sha256" file)
file
"${cfg.dataDir}/.env"
]
}
'';
secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
filteredConfig = lib.converge (lib.filterAttrsRecursive (
_: v:
!elem v [
{ }
null
]
)) cfg.config;
agorakitEnv = pkgs.writeText "agorakit.env" (agorakitEnvVars filteredConfig);
in
''
# error handling
set -euo pipefail
# create .env file
install -T -m 0600 -o ${user} ${agorakitEnv} "${cfg.dataDir}/.env"
${secretReplacements}
if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
fi
# migrate & seed db
${php} artisan key:generate --force
${php} artisan migrate --force
${php} artisan config:cache
'';
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0710 ${user} ${group} - -"
"d ${cfg.dataDir}/public 0750 ${user} ${group} - -"
"d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -"
"d ${cfg.dataDir}/storage 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -"
];
users = {
users = mkIf (user == "agorakit") {
agorakit = {
inherit group;
isSystemUser = true;
};
"${config.services.nginx.user}".extraGroups = [ group ];
};
groups = mkIf (group == "agorakit") { agorakit = { }; };
};
};
}

View File

@@ -0,0 +1,352 @@
# Akkoma {#module-services-akkoma}
[Akkoma](https://akkoma.dev/) is a lightweight ActivityPub microblogging server forked from Pleroma.
## Service configuration {#modules-services-akkoma-service-configuration}
The Elixir configuration file required by Akkoma is generated automatically from
[{option}`services.akkoma.config`](options.html#opt-services.akkoma.config). Secrets must be
included from external files outside of the Nix store by setting the configuration option to
an attribute set containing the attribute {option}`_secret` a string pointing to the file
containing the actual value of the option.
For the mandatory configuration settings these secrets will be generated automatically if the
referenced file does not exist during startup, unless disabled through
[{option}`services.akkoma.initSecrets`](options.html#opt-services.akkoma.initSecrets).
The following configuration binds Akkoma to the Unix socket `/run/akkoma/socket`, expecting to
be run behind a HTTP proxy on `fediverse.example.com`.
```nix
{
services.akkoma.enable = true;
services.akkoma.config = {
":pleroma" = {
":instance" = {
name = "My Akkoma instance";
description = "More detailed description";
email = "admin@example.com";
registration_open = false;
};
"Pleroma.Web.Endpoint" = {
url.host = "fediverse.example.com";
};
};
};
}
```
Please refer to the [configuration cheat sheet](https://docs.akkoma.dev/stable/configuration/cheatsheet/)
for additional configuration options.
## User management {#modules-services-akkoma-user-management}
After the Akkoma service is running, the administration utility can be used to
[manage users](https://docs.akkoma.dev/stable/administration/CLI_tasks/user/). In particular an
administrative user can be created with
```ShellSession
$ pleroma_ctl user new <nickname> <email> --admin --moderator --password <password>
```
## Proxy configuration {#modules-services-akkoma-proxy-configuration}
Although it is possible to expose Akkoma directly, it is common practice to operate it behind an
HTTP reverse proxy such as nginx.
```nix
{
services.akkoma.nginx = {
enableACME = true;
forceSSL = true;
};
services.nginx = {
enable = true;
clientMaxBodySize = "16m";
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
};
}
```
Please refer to [](#module-security-acme) for details on how to provision an SSL/TLS certificate.
### Media proxy {#modules-services-akkoma-media-proxy}
Without the media proxy function, Akkoma does not store any remote media like pictures or video
locally, and clients have to fetch them directly from the source server.
```nix
{
# Enable nginx slice module distributed with Tengine
services.nginx.package = pkgs.tengine;
# Enable media proxy
services.akkoma.config.":pleroma".":media_proxy" = {
enabled = true;
proxy_opts.redirect_on_failure = true;
};
# Adjust the persistent cache size as needed:
# Assuming an average object size of 128 KiB, around 1 MiB
# of memory is required for the key zone per GiB of cache.
# Ensure that the cache directory exists and is writable by nginx.
services.nginx.commonHttpConfig = ''
proxy_cache_path /var/cache/nginx/cache/akkoma-media-cache
levels= keys_zone=akkoma_media_cache:16m max_size=16g
inactive=1y use_temp_path=off;
'';
services.akkoma.nginx = {
locations."/proxy" = {
proxyPass = "http://unix:/run/akkoma/socket";
extraConfig = ''
proxy_cache akkoma_media_cache;
# Cache objects in slices of 1 MiB
slice 1m;
proxy_cache_key $host$uri$is_args$args$slice_range;
proxy_set_header Range $slice_range;
# Decouple proxy and upstream responses
proxy_buffering on;
proxy_cache_lock on;
proxy_ignore_client_abort on;
# Default cache times for various responses
proxy_cache_valid 200 1y;
proxy_cache_valid 206 301 304 1h;
# Allow serving of stale items
proxy_cache_use_stale error timeout invalid_header updating;
'';
};
};
}
```
#### Prefetch remote media {#modules-services-akkoma-prefetch-remote-media}
The following example enables the `MediaProxyWarmingPolicy` MRF policy which automatically
fetches all media associated with a post through the media proxy, as soon as the post is
received by the instance.
```nix
{
services.akkoma.config.":pleroma".":mrf".policies = map (pkgs.formats.elixirConf { }).lib.mkRaw [
"Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy"
];
}
```
#### Media previews {#modules-services-akkoma-media-previews}
Akkoma can generate previews for media.
```nix
{
services.akkoma.config.":pleroma".":media_preview_proxy" = {
enabled = true;
thumbnail_max_width = 1920;
thumbnail_max_height = 1080;
};
}
```
## Frontend management {#modules-services-akkoma-frontend-management}
Akkoma will be deployed with the `akkoma-fe` and `admin-fe` frontends by default. These can be
modified by setting
[{option}`services.akkoma.frontends`](options.html#opt-services.akkoma.frontends).
The following example overrides the primary frontends default configuration using a custom
derivation.
```nix
{
services.akkoma.frontends.primary.package =
pkgs.runCommand "akkoma-fe"
{
config = builtins.toJSON {
expertLevel = 1;
collapseMessageWithSubject = false;
stopGifs = false;
replyVisibility = "following";
webPushHideIfCW = true;
hideScopeNotice = true;
renderMisskeyMarkdown = false;
hideSiteFavicon = true;
postContentType = "text/markdown";
showNavShortcuts = false;
};
nativeBuildInputs = with pkgs; [
jq
xorg.lndir
];
passAsFile = [ "config" ];
}
''
mkdir $out
lndir ${pkgs.akkoma-frontends.akkoma-fe} $out
rm $out/static/config.json
jq -s add ${pkgs.akkoma-frontends.akkoma-fe}/static/config.json ${config} \
>$out/static/config.json
'';
}
```
## Federation policies {#modules-services-akkoma-federation-policies}
Akkoma comes with a number of modules to police federation with other ActivityPub instances.
The most valuable for typical users is the
[`:mrf_simple`](https://docs.akkoma.dev/stable/configuration/cheatsheet/#mrf_simple) module
which allows limiting federation based on instance hostnames.
This configuration snippet provides an example on how these can be used. Choosing an adequate
federation policy is not trivial and entails finding a balance between connectivity to the rest
of the fediverse and providing a pleasant experience to the users of an instance.
```nix
{
services.akkoma.config.":pleroma" = with (pkgs.formats.elixirConf { }).lib; {
":mrf".policies = map mkRaw [ "Pleroma.Web.ActivityPub.MRF.SimplePolicy" ];
":mrf_simple" = {
# Tag all media as sensitive
media_nsfw = mkMap { "nsfw.weird.kinky" = "Untagged NSFW content"; };
# Reject all activities except deletes
reject = mkMap {
"kiwifarms.cc" = "Persistent harassment of users, no moderation";
};
# Force posts to be visible by followers only
followers_only = mkMap {
"beta.birdsite.live" = "Avoid polluting timelines with Twitter posts";
};
};
};
}
```
## Upload filters {#modules-services-akkoma-upload-filters}
This example strips GPS and location metadata from uploads, deduplicates them and anonymises the
the file name.
```nix
{
services.akkoma.config.":pleroma"."Pleroma.Upload".filters =
map (pkgs.formats.elixirConf { }).lib.mkRaw
[
"Pleroma.Upload.Filter.Exiftool"
"Pleroma.Upload.Filter.Dedupe"
"Pleroma.Upload.Filter.AnonymizeFilename"
];
}
```
## Migration from Pleroma {#modules-services-akkoma-migration-pleroma}
Pleroma instances can be migrated to Akkoma either by copying the database and upload data or by
pointing Akkoma to the existing data. The necessary database migrations are run automatically
during startup of the service.
The configuration has to be copyedited manually.
Depending on the size of the database, the initial migration may take a long time and exceed the
startup timeout of the system manager. To work around this issue one may adjust the startup timeout
{option}`systemd.services.akkoma.serviceConfig.TimeoutStartSec` or simply run the migrations
manually:
```ShellSession
pleroma_ctl migrate
```
### Copying data {#modules-services-akkoma-migration-pleroma-copy}
Copying the Pleroma data instead of reusing it in place may permit easier reversion to Pleroma,
but allows the two data sets to diverge.
First disable Pleroma and then copy its database and upload data:
```ShellSession
# Create a copy of the database
nix-shell -p postgresql --run 'createdb -T pleroma akkoma'
# Copy upload data
mkdir /var/lib/akkoma
cp -R --reflink=auto /var/lib/pleroma/uploads /var/lib/akkoma/
```
After the data has been copied, enable the Akkoma service and verify that the migration has been
successful. If no longer required, the original data may then be deleted:
```ShellSession
# Delete original database
nix-shell -p postgresql --run 'dropdb pleroma'
# Delete original Pleroma state
rm -r /var/lib/pleroma
```
### Reusing data {#modules-services-akkoma-migration-pleroma-reuse}
To reuse the Pleroma data in place, disable Pleroma and enable Akkoma, pointing it to the
Pleroma database and upload directory.
```nix
{
# Adjust these settings according to the database name and upload directory path used by Pleroma
services.akkoma.config.":pleroma"."Pleroma.Repo".database = "pleroma";
services.akkoma.config.":pleroma".":instance".upload_dir = "/var/lib/pleroma/uploads";
}
```
Please keep in mind that after the Akkoma service has been started, any migrations applied by
Akkoma have to be rolled back before the database can be used again with Pleroma. This can be
achieved through `pleroma_ctl ecto.rollback`. Refer to the
[Ecto SQL documentation](https://hexdocs.pm/ecto_sql/Mix.Tasks.Ecto.Rollback.html) for
details.
## Advanced deployment options {#modules-services-akkoma-advanced-deployment}
### Confinement {#modules-services-akkoma-confinement}
The Akkoma systemd service may be confined to a chroot with
```nix
{ services.systemd.akkoma.confinement.enable = true; }
```
Confinement of services is not generally supported in NixOS and therefore disabled by default.
Depending on the Akkoma configuration, the default confinement settings may be insufficient and
lead to subtle errors at run time, requiring adjustment:
Use
[{option}`services.systemd.akkoma.confinement.packages`](options.html#opt-systemd.services._name_.confinement.packages)
to make packages available in the chroot.
{option}`services.systemd.akkoma.serviceConfig.BindPaths` and
{option}`services.systemd.akkoma.serviceConfig.BindReadOnlyPaths` permit access to outside paths
through bind mounts. Refer to
[`BindPaths=`](https://www.freedesktop.org/software/systemd/man/systemd.exec.html#BindPaths=)
of {manpage}`systemd.exec(5)` for details.
### Distributed deployment {#modules-services-akkoma-distributed-deployment}
Being an Elixir application, Akkoma can be deployed in a distributed fashion.
This requires setting
[{option}`services.akkoma.dist.address`](options.html#opt-services.akkoma.dist.address) and
[{option}`services.akkoma.dist.cookie`](options.html#opt-services.akkoma.dist.cookie). The
specifics depend strongly on the deployment environment. For more information please check the
relevant [Erlang documentation](https://www.erlang.org/doc/reference_manual/distributed.html).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
{
lib,
pkgs,
config,
...
}:
with lib;
let
cfg = config.services.alps;
in
{
options.services.alps = {
enable = mkEnableOption "alps";
port = mkOption {
type = types.port;
default = 1323;
description = ''
TCP port the service should listen on.
'';
};
bindIP = mkOption {
default = "[::]";
type = types.str;
description = ''
The IP the service should listen on.
'';
};
theme = mkOption {
type = types.enum [
"alps"
"sourcehut"
];
default = "sourcehut";
description = ''
The frontend's theme to use.
'';
};
imaps = {
port = mkOption {
type = types.port;
default = 993;
description = ''
The IMAPS server port.
'';
};
host = mkOption {
type = types.str;
default = "[::1]";
example = "mail.example.org";
description = ''
The IMAPS server address.
'';
};
};
smtps = {
port = mkOption {
type = types.port;
default = 465;
description = ''
The SMTPS server port.
'';
};
host = mkOption {
type = types.str;
default = cfg.imaps.host;
defaultText = "services.alps.imaps.host";
example = "mail.example.org";
description = ''
The SMTPS server address.
'';
};
};
package = mkOption {
internal = true;
type = types.package;
default = pkgs.alps;
};
args = mkOption {
internal = true;
type = types.listOf types.str;
default = [
"-addr"
"${cfg.bindIP}:${toString cfg.port}"
"-theme"
"${cfg.theme}"
"imaps://${cfg.imaps.host}:${toString cfg.imaps.port}"
"smtps://${cfg.smtps.host}:${toString cfg.smtps.port}"
];
};
};
config = mkIf cfg.enable {
systemd.services.alps = {
description = "alps is a simple and extensible webmail.";
documentation = [ "https://git.sr.ht/~migadu/alps" ];
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [
"network.target"
"network-online.target"
];
serviceConfig = {
ExecStart = "${cfg.package}/bin/alps ${escapeShellArgs cfg.args}";
AmbientCapabilities = "";
CapabilityBoundingSet = "";
DynamicUser = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateIPC = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SocketBindAllow = cfg.port;
SocketBindDeny = "any";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @obsolete"
];
};
};
};
}

View File

@@ -0,0 +1,403 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.anuko-time-tracker;
configFile =
let
smtpPassword =
if cfg.settings.email.smtpPasswordFile == null then
"''"
else
"trim(file_get_contents('${cfg.settings.email.smtpPasswordFile}'))";
in
pkgs.writeText "config.php" ''
<?php
// Set include path for PEAR and its modules, which we include in the distribution.
// Updated for the correct location in the nix store.
set_include_path('${cfg.package}/WEB-INF/lib/pear' . PATH_SEPARATOR . get_include_path());
define('DSN', 'mysqli://${cfg.database.user}@${cfg.database.host}/${cfg.database.name}?charset=utf8mb4');
define('MULTIORG_MODE', ${lib.boolToString cfg.settings.multiorgMode});
define('EMAIL_REQUIRED', ${lib.boolToString cfg.settings.emailRequired});
define('WEEKEND_START_DAY', ${toString cfg.settings.weekendStartDay});
define('FORUM_LINK', '${cfg.settings.forumLink}');
define('HELP_LINK', '${cfg.settings.helpLink}');
define('SENDER', '${cfg.settings.email.sender}');
define('MAIL_MODE', '${cfg.settings.email.mode}');
define('MAIL_SMTP_HOST', '${toString cfg.settings.email.smtpHost}');
define('MAIL_SMTP_PORT', '${toString cfg.settings.email.smtpPort}');
define('MAIL_SMTP_USER', '${cfg.settings.email.smtpUser}');
define('MAIL_SMTP_PASSWORD', ${smtpPassword});
define('MAIL_SMTP_AUTH', ${lib.boolToString cfg.settings.email.smtpAuth});
define('MAIL_SMTP_DEBUG', ${lib.boolToString cfg.settings.email.smtpDebug});
define('DEFAULT_CSS', 'default.css');
define('RTL_CSS', 'rtl.css'); // For right to left languages.
define('LANG_DEFAULT', '${cfg.settings.defaultLanguage}');
define('CURRENCY_DEFAULT', '${cfg.settings.defaultCurrency}');
define('EXPORT_DECIMAL_DURATION', ${lib.boolToString cfg.settings.exportDecimalDuration});
define('REPORT_FOOTER', ${lib.boolToString cfg.settings.reportFooter});
define('AUTH_MODULE', 'db');
'';
package = pkgs.stdenv.mkDerivation rec {
pname = "anuko-time-tracker";
inherit (src) version;
src = cfg.package;
installPhase = ''
mkdir -p $out
cp -r * $out/
# Link config file
ln -s ${configFile} $out/WEB-INF/config.php
# Link writable templates_c directory
rm -rf $out/WEB-INF/templates_c
ln -s ${cfg.dataDir}/templates_c $out/WEB-INF/templates_c
# Remove unsafe dbinstall.php
rm -f $out/dbinstall.php
'';
};
in
{
options.services.anuko-time-tracker = {
enable = lib.mkEnableOption "Anuko Time Tracker";
package = lib.mkPackageOption pkgs "anuko-time-tracker" { };
database = {
createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Create the database and database user locally.";
};
host = lib.mkOption {
type = lib.types.str;
description = "Database host.";
default = "localhost";
};
name = lib.mkOption {
type = lib.types.str;
description = "Database name.";
default = "anuko_time_tracker";
};
user = lib.mkOption {
type = lib.types.str;
description = "Database username.";
default = "anuko_time_tracker";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Database user password file.";
default = null;
};
};
poolConfig = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for Anuko Time Tracker's PHP-FPM pool.
'';
};
hostname = lib.mkOption {
type = lib.types.str;
default =
if config.networking.domain != null then config.networking.fqdn else config.networking.hostName;
defaultText = lib.literalExpression "config.networking.fqdn";
example = "anuko.example.com";
description = ''
The hostname to serve Anuko Time Tracker on.
'';
};
nginx = lib.mkOption {
type = lib.types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { }
);
default = { };
example = lib.literalExpression ''
{
serverAliases = [
"anuko.''${config.networking.domain}"
];
# To enable encryption and let let's encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
description = ''
With this option, you can customize the Nginx virtualHost settings.
'';
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/anuko-time-tracker";
description = "Default data folder for Anuko Time Tracker.";
example = "/mnt/anuko-time-tracker";
};
user = lib.mkOption {
type = lib.types.str;
default = "anuko_time_tracker";
description = "User under which Anuko Time Tracker runs.";
};
settings = {
multiorgMode = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Defines whether users see the Register option in the menu of Time Tracker that allows them
to self-register and create new organizations (top groups).
'';
};
emailRequired = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Defines whether an email is required for new registrations.";
};
weekendStartDay = lib.mkOption {
type = lib.types.int;
default = 6;
description = ''
This option defines which days are highlighted with weekend color.
6 means Saturday. For Saudi Arabia, etc. set it to 4 for Thursday and Friday to be
weekend days.
'';
};
forumLink = lib.mkOption {
type = lib.types.str;
description = "Forum link from the main menu.";
default = "https://www.anuko.com/forum/viewforum.php?f=4";
};
helpLink = lib.mkOption {
type = lib.types.str;
description = "Help link from the main menu.";
default = "https://www.anuko.com/time-tracker/user-guide/index.htm";
};
email = {
sender = lib.mkOption {
type = lib.types.str;
description = "Default sender for mail.";
default = "Anuko Time Tracker <bounces@example.com>";
};
mode = lib.mkOption {
type = lib.types.str;
description = "Mail sending mode. Can be 'mail' or 'smtp'.";
default = "smtp";
};
smtpHost = lib.mkOption {
type = lib.types.str;
description = "MTA hostname.";
default = "localhost";
};
smtpPort = lib.mkOption {
type = lib.types.port;
description = "MTA port.";
default = 25;
};
smtpUser = lib.mkOption {
type = lib.types.str;
description = "MTA authentication username.";
default = "";
};
smtpAuth = lib.mkOption {
type = lib.types.bool;
default = false;
description = "MTA requires authentication.";
};
smtpPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/var/lib/anuko-time-tracker/secrets/smtp-password";
description = ''
Path to file containing the MTA authentication password.
'';
};
smtpDebug = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Debug mail sending.";
};
};
defaultLanguage = lib.mkOption {
type = lib.types.str;
description = ''
Defines Anuko Time Tracker default language. It is used on Time Tracker login page.
After login, a language set for user group is used.
Empty string means the language is defined by user browser.
'';
default = "";
example = "nl";
};
defaultCurrency = lib.mkOption {
type = lib.types.str;
description = ''
Defines a default currency symbol for new groups.
Use , £, a more specific dollar like US$, CAD, etc.
'';
default = "$";
example = "";
};
exportDecimalDuration = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Defines whether time duration values are decimal in CSV and XML data
exports (1.25 vs 1:15).
'';
};
reportFooter = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Defines whether to use a footer on reports.";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = ''
<option>services.anuko-time-tracker.database.passwordFile</option> cannot be specified if
<option>services.anuko-time-tracker.database.createLocally</option> is set to true.
'';
}
{
assertion = cfg.settings.email.smtpAuth -> (cfg.settings.email.smtpPasswordFile != null);
message = ''
<option>services.anuko-time-tracker.settings.email.smtpPasswordFile</option> needs to be set if
<option>services.anuko-time-tracker.settings.email.smtpAuth</option> is enabled.
'';
}
];
services.phpfpm = {
pools.anuko-time-tracker = {
inherit (cfg) user;
group = config.services.nginx.group;
settings = {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
// cfg.poolConfig;
};
};
services.nginx = {
enable = lib.mkDefault true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
virtualHosts."${cfg.hostname}" = lib.mkMerge [
cfg.nginx
{
root = lib.mkForce "${package}";
locations = {
"/".index = "index.php";
"~ [^/]\\.php(/|$)" = {
extraConfig = ''
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_pass unix:${config.services.phpfpm.pools.anuko-time-tracker.socket};
'';
};
};
}
];
};
services.mysql = lib.mkIf cfg.database.createLocally {
enable = lib.mkDefault true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}
];
};
systemd = {
services = {
anuko-time-tracker-setup-database = lib.mkIf cfg.database.createLocally {
description = "Set up Anuko Time Tracker database";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
wantedBy = [ "phpfpm-anuko-time-tracker.service" ];
after = [ "mysql.service" ];
script =
let
mysql = "${config.services.mysql.package}/bin/mysql";
in
''
if [ ! -f ${cfg.dataDir}/.dbexists ]; then
# Load database schema provided with package
${mysql} ${cfg.database.name} < ${cfg.package}/mysql.sql
touch ${cfg.dataDir}/.dbexists
fi
'';
};
};
tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${config.services.nginx.group} -"
"d ${cfg.dataDir}/templates_c 0750 ${cfg.user} ${config.services.nginx.group} -"
];
};
users.users."${cfg.user}" = {
isSystemUser = true;
group = config.services.nginx.group;
};
};
meta.maintainers = [ ];
}

View File

@@ -0,0 +1,130 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
cfg = config.services.artalk;
settingsFormat = pkgs.formats.json { };
in
{
meta = {
maintainers = with lib.maintainers; [ moraxyc ];
};
options = {
services.artalk = {
enable = lib.mkEnableOption "artalk, a comment system";
configFile = lib.mkOption {
type = lib.types.str;
default = "/etc/artalk/config.yml";
description = "Artalk config file path. If it is not exist, Artalk will generate one.";
};
allowModify = lib.mkOption {
type = lib.types.bool;
default = true;
description = "allow Artalk store the settings to config file persistently";
};
workdir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/artalk";
description = "Artalk working directory";
};
user = lib.mkOption {
type = lib.types.str;
default = "artalk";
description = "Artalk user name.";
};
group = lib.mkOption {
type = lib.types.str;
default = "artalk";
description = "Artalk group name.";
};
package = lib.mkPackageOption pkgs "artalk" { };
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
options = {
host = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = ''
Artalk server listen host
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 23366;
description = ''
Artalk server listen port
'';
};
};
};
default = { };
description = ''
The artalk configuration.
If you set allowModify to true, Artalk will be able to store the settings in the config file persistently. This section's content will update in the config file after the service restarts.
Options containing secret data should be set to an attribute set
containing the attribute `_secret` - a string pointing to a file
containing the value the option should be set to.
'';
};
};
};
config = lib.mkIf cfg.enable {
users.users.artalk = lib.optionalAttrs (cfg.user == "artalk") {
description = "artalk user";
isSystemUser = true;
group = cfg.group;
};
users.groups.artalk = lib.optionalAttrs (cfg.group == "artalk") { };
environment.systemPackages = [ cfg.package ];
systemd.services.artalk = {
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = ''
umask 0077
${utils.genJqSecretsReplacementSnippet cfg.settings "/run/artalk/new"}
''
+ (
if cfg.allowModify then
''
[ -e "${cfg.configFile}" ] || ${lib.getExe cfg.package} gen config "${cfg.configFile}"
cat "${cfg.configFile}" | ${lib.getExe pkgs.yj} > "/run/artalk/old"
${lib.getExe pkgs.jq} -s '.[0] * .[1]' "/run/artalk/old" "/run/artalk/new" > "/run/artalk/result"
cat "/run/artalk/result" | ${lib.getExe pkgs.yj} -r > "${cfg.configFile}"
rm /run/artalk/{old,new,result}
''
else
''
cat /run/artalk/new | ${lib.getExe pkgs.yj} -r > "${cfg.configFile}"
rm /run/artalk/new
''
);
serviceConfig = {
User = cfg.user;
Group = cfg.group;
Type = "simple";
ExecStart = "${lib.getExe cfg.package} server --config ${cfg.configFile} --workdir ${cfg.workdir} --host ${cfg.settings.host} --port ${builtins.toString cfg.settings.port}";
Restart = "on-failure";
RestartSec = "5s";
ConfigurationDirectory = [ "artalk" ];
StateDirectory = [ "artalk" ];
RuntimeDirectory = [ "artalk" ];
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
ProtectHome = "yes";
};
};
};
}

View File

@@ -0,0 +1,95 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.audiobookshelf;
in
{
options = {
services.audiobookshelf = {
enable = mkEnableOption "Audiobookshelf, self-hosted audiobook and podcast server";
package = mkPackageOption pkgs "audiobookshelf" { };
dataDir = mkOption {
description = "Path to Audiobookshelf config and metadata inside of /var/lib.";
default = "audiobookshelf";
type = types.str;
};
host = mkOption {
description = "The host Audiobookshelf binds to.";
default = "127.0.0.1";
example = "0.0.0.0";
type = types.str;
};
port = mkOption {
description = "The TCP port Audiobookshelf will listen on.";
default = 8000;
type = types.port;
};
user = mkOption {
description = "User account under which Audiobookshelf runs.";
default = "audiobookshelf";
type = types.str;
};
group = mkOption {
description = "Group under which Audiobookshelf runs.";
default = "audiobookshelf";
type = types.str;
};
openFirewall = mkOption {
description = "Open ports in the firewall for the Audiobookshelf web interface.";
default = false;
type = types.bool;
};
};
};
config = mkIf cfg.enable {
systemd.services.audiobookshelf = {
description = "Audiobookshelf is a self-hosted audiobook and podcast server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
StateDirectory = cfg.dataDir;
WorkingDirectory = "/var/lib/${cfg.dataDir}";
ExecStart = "${cfg.package}/bin/audiobookshelf --host ${cfg.host} --port ${toString cfg.port}";
Restart = "on-failure";
};
};
users.users = mkIf (cfg.user == "audiobookshelf") {
audiobookshelf = {
isSystemUser = true;
group = cfg.group;
home = "/var/lib/${cfg.dataDir}";
};
};
users.groups = mkIf (cfg.group == "audiobookshelf") {
audiobookshelf = { };
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
};
meta.maintainers = with maintainers; [ wietsedv ];
}

View File

@@ -0,0 +1,126 @@
{
config,
lib,
pkgs,
...
}:
let
common-name = "baikal";
cfg = config.services.baikal;
in
{
meta.maintainers = [ lib.maintainers.wrvsrx ];
options = {
services.baikal = {
enable = lib.mkEnableOption "baikal";
user = lib.mkOption {
type = lib.types.str;
default = common-name;
description = ''
User account under which the web-application run.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = common-name;
description = ''
Group account under which the web-application run.
'';
};
pool = lib.mkOption {
type = lib.types.str;
default = common-name;
description = ''
Name of existing phpfpm pool that is used to run web-application.
If not specified a pool will be created automatically with
default values.
'';
};
virtualHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = common-name;
description = ''
Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost.
'';
};
phpPackage = lib.mkPackageOption pkgs "php" { };
package = lib.mkPackageOption pkgs "baikal" { };
};
};
config = lib.mkIf cfg.enable {
services.phpfpm.pools = lib.mkIf (cfg.pool == "${common-name}") {
${common-name} = {
inherit (cfg) user phpPackage;
phpEnv = {
"BAIKAL_PATH_CONFIG" = "/var/lib/baikal/config/";
"BAIKAL_PATH_SPECIFIC" = "/var/lib/baikal/specific/";
};
settings = lib.mapAttrs (name: lib.mkDefault) {
"listen.owner" = "nginx";
"listen.group" = "nginx";
"listen.mode" = "0600";
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 1;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
"pm.process_idle_timeout" = 30;
"catch_workers_output" = 1;
};
};
};
services.nginx = lib.mkIf (cfg.virtualHost != null) {
enable = true;
virtualHosts."${cfg.virtualHost}" = {
root = "${cfg.package}/share/php/baikal/html";
locations = {
"/" = {
index = "index.php";
};
"/.well-known/".extraConfig = ''
rewrite ^/.well-known/caldav /dav.php redirect;
rewrite ^/.well-known/carddav /dav.php redirect;
'';
"~ /(\.ht|Core|Specific|config)".extraConfig = ''
deny all;
return 404;
'';
"~ ^(.+\.php)(.*)$".extraConfig = ''
try_files $fastcgi_script_name =404;
include ${config.services.nginx.package}/conf/fastcgi.conf;
fastcgi_split_path_info ^(.+\.php)(.*)$;
fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
'';
};
};
};
users.users.${cfg.user} = lib.mkIf (cfg.user == common-name) {
description = "baikal service user";
isSystemUser = true;
inherit (cfg) group;
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == common-name) { };
systemd.tmpfiles.settings."baikal" = builtins.listToAttrs (
map
(x: {
name = "/var/lib/baikal/${x}";
value.d = {
mode = "0700";
inherit (cfg) user group;
};
})
[
"config"
"specific"
"specific/db"
]
);
};
}

View File

@@ -0,0 +1,337 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.bluemap;
format = pkgs.formats.hocon { };
coreConfig = format.generate "core.conf" cfg.coreSettings;
webappConfig = format.generate "webapp.conf" cfg.webappSettings;
webserverConfig = format.generate "webserver.conf" cfg.webserverSettings;
mapsFolder = pkgs.linkFarm "maps" (
lib.attrsets.mapAttrs' (
name: value: lib.nameValuePair "${name}.conf" (format.generate "${name}.conf" value)
) cfg.maps
);
storageFolder = pkgs.linkFarm "storage" (
lib.attrsets.mapAttrs' (
name: value: lib.nameValuePair "${name}.conf" (format.generate "${name}.conf" value)
) cfg.storage
);
configFolder = pkgs.linkFarm "bluemap-config" {
"maps" = mapsFolder;
"storages" = storageFolder;
"core.conf" = coreConfig;
"webapp.conf" = webappConfig;
"webserver.conf" = webserverConfig;
"packs" = pkgs.linkFarm "packs" cfg.packs;
};
inherit (lib) mkOption;
in
{
imports = [
(lib.mkRenamedOptionModule
[ "services" "bluemap" "resourcepacks" ]
[ "services" "bluemap" "packs" ]
)
(lib.mkRenamedOptionModule [ "services" "bluemap" "addons" ] [ "services" "bluemap" "packs" ])
];
options.services.bluemap = {
enable = lib.mkEnableOption "bluemap";
eula = mkOption {
type = lib.types.bool;
description = ''
By changing this option to true you confirm that you own a copy of minecraft Java Edition,
and that you agree to minecrafts EULA.
'';
default = false;
};
defaultWorld = mkOption {
type = lib.types.path;
description = ''
The world used by the default map ruleset.
If you configure your own maps you do not need to set this.
'';
example = lib.literalExpression "\${config.services.minecraft.dataDir}/world";
};
enableRender = mkOption {
type = lib.types.bool;
description = "Enable rendering";
default = true;
};
webRoot = mkOption {
type = lib.types.path;
default = "/var/lib/bluemap/web";
description = "The directory for saving and serving the webapp and the maps";
};
enableNginx = mkOption {
type = lib.types.bool;
default = true;
description = "Enable configuring a virtualHost for serving the bluemap webapp";
};
host = mkOption {
type = lib.types.str;
description = "Domain on which nginx will serve the bluemap webapp";
};
onCalendar = mkOption {
type = lib.types.str;
description = ''
How often to trigger rendering the map,
in the format of a systemd timer onCalendar configuration.
See {manpage}`systemd.timer(5)`.
'';
default = "*-*-* 03:10:00";
};
coreSettings = mkOption {
type = lib.types.submodule {
freeformType = format.type;
options = {
data = mkOption {
type = lib.types.path;
description = "Folder for where bluemap stores its data";
default = "/var/lib/bluemap";
};
metrics = lib.mkEnableOption "Sending usage metrics containing the version of bluemap in use";
};
};
description = "Settings for the core.conf file, [see upstream docs](https://github.com/BlueMap-Minecraft/BlueMap/blob/master/BlueMapCommon/src/main/resources/de/bluecolored/bluemap/config/core.conf).";
};
webappSettings = mkOption {
type = lib.types.submodule {
freeformType = format.type;
};
default = {
enabled = true;
webroot = cfg.webRoot;
};
defaultText = lib.literalExpression ''
{
enabled = true;
webroot = config.services.bluemap.webRoot;
}
'';
description = "Settings for the webapp.conf file, see [upstream docs](https://github.com/BlueMap-Minecraft/BlueMap/blob/master/BlueMapCommon/src/main/resources/de/bluecolored/bluemap/config/webapp.conf).";
};
webserverSettings = mkOption {
type = lib.types.submodule {
freeformType = format.type;
options = {
enabled = mkOption {
type = lib.types.bool;
description = ''
Enable bluemap's built-in webserver.
Disabled by default in nixos for use of nginx directly.
'';
default = false;
};
};
};
default = { };
description = ''
Settings for the webserver.conf file, usually not required.
[See upstream docs](https://github.com/BlueMap-Minecraft/BlueMap/blob/master/BlueMapCommon/src/main/resources/de/bluecolored/bluemap/config/webserver.conf).
'';
};
maps = mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
freeformType = format.type;
options = {
world = lib.mkOption {
type = lib.types.path;
description = "Path to world folder containing the dimension to render";
};
};
}
);
default = {
"overworld" = {
world = "${cfg.defaultWorld}";
ambient-light = 0.1;
cave-detection-ocean-floor = -5;
};
"nether" = {
world = "${cfg.defaultWorld}/DIM-1";
sorting = 100;
sky-color = "#290000";
void-color = "#150000";
ambient-light = 0.6;
world-sky-light = 0;
remove-caves-below-y = -10000;
cave-detection-ocean-floor = -5;
cave-detection-uses-block-light = true;
max-y = 90;
};
"end" = {
world = "${cfg.defaultWorld}/DIM1";
sorting = 200;
sky-color = "#080010";
void-color = "#080010";
ambient-light = 0.6;
world-sky-light = 0;
remove-caves-below-y = -10000;
cave-detection-ocean-floor = -5;
};
};
defaultText = lib.literalExpression ''
{
"overworld" = {
world = "''${cfg.defaultWorld}";
ambient-light = 0.1;
cave-detection-ocean-floor = -5;
};
"nether" = {
world = "''${cfg.defaultWorld}/DIM-1";
sorting = 100;
sky-color = "#290000";
void-color = "#150000";
ambient-light = 0.6;
world-sky-light = 0;
remove-caves-below-y = -10000;
cave-detection-ocean-floor = -5;
cave-detection-uses-block-light = true;
max-y = 90;
};
"end" = {
world = "''${cfg.defaultWorld}/DIM1";
sorting = 200;
sky-color = "#080010";
void-color = "#080010";
ambient-light = 0.6;
world-sky-light = 0;
remove-caves-below-y = -10000;
cave-detection-ocean-floor = -5;
};
};
'';
description = ''
Settings for files in `maps/`.
If you define anything here you must define everything yourself.
See the default for an example with good options for the different world types.
For valid values [consult upstream docs](https://github.com/BlueMap-Minecraft/BlueMap/blob/master/BlueMapCommon/src/main/resources/de/bluecolored/bluemap/config/maps/map.conf).
'';
};
storage = mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
freeformType = format.type;
options = {
storage-type = mkOption {
type = lib.types.enum [
"FILE"
"SQL"
];
description = "Type of storage config";
default = "FILE";
};
};
}
);
description = ''
Where the rendered map will be stored.
Unless you are doing something advanced you should probably leave this alone and configure webRoot instead.
[See upstream docs](https://github.com/BlueMap-Minecraft/BlueMap/tree/master/BlueMapCommon/src/main/resources/de/bluecolored/bluemap/config/storages)
'';
default = {
"file" = {
root = "${cfg.webRoot}/maps";
};
};
defaultText = lib.literalExpression ''
{
"file" = {
root = "''${config.services.bluemap.webRoot}/maps";
};
}
'';
};
packs = mkOption {
type = lib.types.attrsOf lib.types.pathInStore;
default = { };
description = ''
A set of resourcepacks, datapacks, and mods to extract resources from,
loaded in alphabetical order.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = config.services.bluemap.eula;
message = ''
You have enabled bluemap but have not accepted minecraft's EULA.
You can achieve this through setting `services.bluemap.eula = true`
'';
}
];
services.bluemap.coreSettings.accept-download = cfg.eula;
systemd.services."render-bluemap-maps" = lib.mkIf cfg.enableRender {
serviceConfig = {
Type = "oneshot";
Group = "nginx";
UMask = "026";
};
script = ''
${lib.getExe pkgs.bluemap} -c ${configFolder} -gs -r
'';
};
systemd.timers."render-bluemap-maps" = lib.mkIf cfg.enableRender {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.onCalendar;
Persistent = true;
Unit = "render-bluemap-maps.service";
};
};
services.nginx.virtualHosts = lib.mkIf cfg.enableNginx {
"${cfg.host}" = {
root = config.services.bluemap.webRoot;
locations = {
"@empty".return = "204";
"~* ^/maps/[^/]*/tiles/".extraConfig = ''
error_page 404 = @empty;
gzip_static always;
'';
};
};
};
};
meta = {
maintainers = with lib.maintainers; [
dandellion
h7x4
];
};
}

View File

@@ -0,0 +1,244 @@
{
lib,
pkgs,
config,
...
}:
let
cfg = config.services.bluesky-pds;
inherit (lib)
getExe
mkEnableOption
mkIf
mkOption
mkPackageOption
escapeShellArgs
concatMapStringsSep
types
literalExpression
;
pdsadminWrapper =
let
cfgSystemd = config.systemd.services.bluesky-pds.serviceConfig;
in
pkgs.writeShellScriptBin "pdsadmin" ''
DUMMY_PDS_ENV_FILE="$(mktemp)"
trap 'rm -f "$DUMMY_PDS_ENV_FILE"' EXIT
env "PDS_ENV_FILE=$DUMMY_PDS_ENV_FILE" \
${escapeShellArgs cfgSystemd.Environment} \
${concatMapStringsSep " " (envFile: "$(cat ${envFile})") cfgSystemd.EnvironmentFile} \
${getExe pkgs.bluesky-pdsadmin} "$@"
'';
in
# All defaults are from https://github.com/bluesky-social/pds/blob/8b9fc24cec5f30066b0d0b86d2b0ba3d66c2b532/installer.sh
{
imports = [
(lib.mkRenamedOptionModule [ "services" "pds" "enable" ] [ "services" "bluesky-pds" "enable" ])
(lib.mkRenamedOptionModule [ "services" "pds" "package" ] [ "services" "bluesky-pds" "package" ])
(lib.mkRenamedOptionModule [ "services" "pds" "settings" ] [ "services" "bluesky-pds" "settings" ])
(lib.mkRenamedOptionModule
[ "services" "pds" "environmentFiles" ]
[ "services" "bluesky-pds" "environmentFiles" ]
)
(lib.mkRenamedOptionModule [ "services" "pds" "pdsadmin" ] [ "services" "bluesky-pds" "pdsadmin" ])
];
options.services.bluesky-pds = {
enable = mkEnableOption "pds";
package = mkPackageOption pkgs "bluesky-pds" { };
settings = mkOption {
type = types.submodule {
freeformType = types.attrsOf (
types.oneOf [
(types.nullOr types.str)
types.port
]
);
options = {
PDS_PORT = mkOption {
type = types.port;
default = 3000;
description = "Port to listen on";
};
PDS_HOSTNAME = mkOption {
type = types.str;
example = "pds.example.com";
description = "Instance hostname (base domain name)";
};
PDS_BLOB_UPLOAD_LIMIT = mkOption {
type = types.str;
default = "52428800";
description = "Size limit of uploaded blobs in bytes";
};
PDS_DID_PLC_URL = mkOption {
type = types.str;
default = "https://plc.directory";
description = "URL of DID PLC directory";
};
PDS_BSKY_APP_VIEW_URL = mkOption {
type = types.str;
default = "https://api.bsky.app";
description = "URL of bsky frontend";
};
PDS_BSKY_APP_VIEW_DID = mkOption {
type = types.str;
default = "did:web:api.bsky.app";
description = "DID of bsky frontend";
};
PDS_REPORT_SERVICE_URL = mkOption {
type = types.str;
default = "https://mod.bsky.app";
description = "URL of mod service";
};
PDS_REPORT_SERVICE_DID = mkOption {
type = types.str;
default = "did:plc:ar7c4by46qjdydhdevvrndac";
description = "DID of mod service";
};
PDS_CRAWLERS = mkOption {
type = types.str;
default = "https://bsky.network";
description = "URL of crawlers";
};
PDS_DATA_DIRECTORY = mkOption {
type = types.str;
default = "/var/lib/pds";
description = "Directory to store state";
};
PDS_BLOBSTORE_DISK_LOCATION = mkOption {
type = types.nullOr types.str;
default = "/var/lib/pds/blocks";
description = "Store blobs at this location, set to null to use e.g. S3";
};
LOG_ENABLED = mkOption {
type = types.nullOr types.str;
default = "true";
description = "Enable logging";
};
};
};
description = ''
Environment variables to set for the service. Secrets should be
specified using {option}`environmentFile`.
Refer to <https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/config/env.ts> for available environment variables.
'';
};
environmentFiles = mkOption {
type = types.listOf types.path;
default = [ ];
description = ''
File to load environment variables from. Loaded variables override
values set in {option}`environment`.
Use it to set values of `PDS_JWT_SECRET`, `PDS_ADMIN_PASSWORD`,
and `PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX` secrets.
`PDS_JWT_SECRET` and `PDS_ADMIN_PASSWORD` can be generated with
```
openssl rand --hex 16
```
`PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX` can be generated with
```
openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
```
'';
};
pdsadmin = {
enable = mkOption {
type = types.bool;
default = cfg.enable;
defaultText = literalExpression "config.services.bluesky-pds.enable";
description = "Add pdsadmin script to PATH";
};
};
};
config = mkIf cfg.enable {
environment = mkIf cfg.pdsadmin.enable {
systemPackages = [ pdsadminWrapper ];
};
systemd.services.bluesky-pds = {
description = "bluesky pds";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = getExe cfg.package;
Environment = lib.mapAttrsToList (k: v: "${k}=${if builtins.isInt v then toString v else v}") (
lib.filterAttrs (_: v: v != null) cfg.settings
);
EnvironmentFile = cfg.environmentFiles;
User = "pds";
Group = "pds";
StateDirectory = "pds";
StateDirectoryMode = "0755";
Restart = "always";
# Hardening
RemoveIPC = true;
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
NoNewPrivileges = true;
PrivateDevices = true;
ProtectClock = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectKernelModules = true;
PrivateMounts = true;
SystemCallArchitectures = [ "native" ];
MemoryDenyWriteExecute = false; # required by V8 JIT
RestrictNamespaces = true;
RestrictSUIDSGID = true;
ProtectHostname = true;
LockPersonality = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictRealtime = true;
DeviceAllow = [ "" ];
ProtectSystem = "strict";
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectHome = true;
PrivateUsers = true;
PrivateTmp = true;
UMask = "0077";
};
};
users = {
users.pds = {
group = "pds";
isSystemUser = true;
};
groups.pds = { };
};
};
meta.maintainers = with lib.maintainers; [ t4ccer ];
}

View File

@@ -0,0 +1,491 @@
{
pkgs,
config,
lib,
...
}:
let
cfg = config.services.bookstack;
user = cfg.user;
group = cfg.group;
defaultUser = "bookstack";
defaultGroup = "bookstack";
artisan = "${cfg.package}/artisan";
env-file-values = lib.mapAttrs' (n: v: {
name = lib.removeSuffix "_FILE" n;
value = v;
}) (lib.filterAttrs (n: v: v != null && lib.match ".+_FILE" n != null) cfg.settings);
env-nonfile-values = lib.filterAttrs (n: v: lib.match ".+_FILE" n == null) cfg.settings;
bookstack-maintenance = pkgs.writeShellScript "bookstack-maintenance.sh" ''
set -a
${lib.toShellVars env-nonfile-values}
${lib.concatLines (lib.mapAttrsToList (n: v: "${n}=\"$(< ${v})\"") env-file-values)}
set +a
${artisan} optimize:clear
rm ${cfg.dataDir}/cache/*.php
${artisan} package:discover
${artisan} migrate --force
${artisan} view:cache
${artisan} route:cache
${artisan} config:cache
'';
commonServiceConfig = {
Type = "oneshot";
User = user;
Group = group;
StateDirectory = "bookstack";
ReadWritePaths = [ cfg.dataDir ];
WorkingDirectory = cfg.package;
PrivateTmp = true;
PrivateDevices = true;
CapabilityBoundingSet = "";
AmbientCapabilities = "";
ProtectSystem = "strict";
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
ProtectHome = "tmpfs";
ProtectKernelLogs = true;
ProtectProc = "invisible";
ProcSubset = "pid";
PrivateNetwork = false;
RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service @resources"
"~@obsolete @privileged"
];
RestrictSUIDSGID = true;
RemoveIPC = true;
NoNewPrivileges = true;
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
PrivateUsers = true;
};
in
{
imports = [
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"extraConfig"
] "Use services.bookstack.settings instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"config"
] "Use services.bookstack.settings instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"cacheDir"
] "The cache directory is now handled automatically.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"appKeyFile"
] "Use services.bookstack.settings.APP_KEY_FILE instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"appURL"
] "Use services.bookstack.settings.APP_URL instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"database"
"host"
] "Use services.bookstack.settings.DB_HOST instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"database"
"port"
] "Use services.bookstack.settings.DB_PORT instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"database"
"passwordFile"
] "Use services.bookstack.settings.DB_PASSWORD_FILE instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"database"
"name"
] "Use services.bookstack.settings.DB_DATABASE instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"database"
"user"
] "Use services.bookstack.settings.DB_USERNAME instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"database"
"createLocally"
] "Use services.mysql.ensureDatabases and services.mysql.ensureUsers instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"host"
] "Use services.bookstack.settings.MAIL_HOST instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"port"
] "Use services.bookstack.settings.MAIL_PORT instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"passwordFile"
] "Use services.bookstack.settings.MAIL_PASSWORD_FILE instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"name"
] "Use services.bookstack.settings.MAIL_DATABASE instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"user"
] "Use services.bookstack.settings.MAIL_USERNAME instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"driver"
] "Use services.bookstack.settings.MAIL_DRIVER instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"fromName"
] "Use services.bookstack.settings.MAIL_FROM_NAME instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"from"
] "Use services.bookstack.settings.MAIL_FROM instead.")
(lib.mkRemovedOptionModule [
"services"
"bookstack"
"mail"
"encryption"
] "Use services.bookstack.settings.MAIL_ENCRYPTION instead.")
];
options.services.bookstack = {
enable = lib.mkEnableOption "BookStack: A platform to create documentation/wiki content built with PHP & Laravel";
package =
lib.mkPackageOption pkgs "bookstack" { }
// lib.mkOption {
apply =
bookstack:
bookstack.override (prev: {
dataDir = cfg.dataDir;
});
};
user = lib.mkOption {
default = defaultUser;
description = "User bookstack runs as";
type = lib.types.str;
};
group = lib.mkOption {
default = if (cfg.nginx != null) then config.services.nginx.group else defaultGroup;
defaultText = "If `services.bookstack.nginx` has any attributes then `nginx` else ${defaultGroup}";
description = "Group bookstack runs as";
type = lib.types.str;
};
hostname = lib.mkOption {
type = lib.types.str;
default = config.networking.fqdnOrHostName;
defaultText = lib.literalExpression "config.networking.fqdnOrHostName";
example = "bookstack.example.com";
description = ''
The hostname to serve BookStack on.
'';
};
dataDir = lib.mkOption {
description = "BookStack data directory";
default = "/var/lib/bookstack";
type = lib.types.path;
};
settings = lib.mkOption {
default = { };
description = ''
Options for Bookstack configuration. Refer to
<https://github.com/BookStackApp/BookStack/blob/development/.env.example> for
details on supported values. For passing secrets, append "_FILE" to the
setting name. For example, you may create a file `/var/secrets/db_pass.txt`
and set `services.bookstack.settings.DB_PASSWORD_FILE` to `/var/secrets/db_pass.txt`
instead of providing a plaintext password using `services.bookstack.settings.DB_PASSWORD`.
'';
example = lib.literalExpression ''
{
APP_ENV = "production";
APP_KEY_FILE = "/var/secrets/bookstack-app-key.txt";
DB_HOST = "db";
DB_PORT = 3306;
DB_DATABASE = "bookstack";
DB_USERNAME = "bookstack";
DB_PASSWORD_FILE = "/var/secrets/bookstack-mysql-password.txt";
}
'';
type = lib.types.submodule {
freeformType = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
options = {
DB_PORT = lib.mkOption {
type = lib.types.port;
default = 3306;
description = ''
The port your database is listening at.
'';
};
DB_HOST = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
The IP or hostname which hosts your database.
'';
};
DB_PASSWORD_FILE = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
The file containing your mysql/mariadb database password.
'';
example = "/var/secrets/bookstack-mysql-pass.txt";
default = null;
};
APP_KEY_FILE = lib.mkOption {
type = lib.types.path;
description = ''
The path to your appkey.
The file should contain a 32 character random app key.
This may be set using `echo "base64:$(head -c 32 /dev/urandom | base64)" > /path/to/key-file`.
'';
};
APP_URL = lib.mkOption {
type = lib.types.str;
default =
if cfg.hostname == "localhost" then "http://${cfg.hostname}" else "https://${cfg.hostname}";
defaultText = ''http(s)://''${config.services.bookstack.hostname}'';
description = ''
The root URL that you want to host BookStack on. All URLs in BookStack
will be generated using this value. It is used to validate specific
requests and to generate URLs in emails.
'';
example = "https://example.com";
};
};
};
};
maxUploadSize = lib.mkOption {
type = lib.types.str;
default = "18M";
example = "1G";
description = "The maximum size for uploads (e.g. images).";
};
poolConfig = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = { };
defaultText = ''
{
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
}
'';
description = ''
Options for the Bookstack PHP pool. See the documentation on `php-fpm.conf`
for details on configuration directives.
'';
};
nginx = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { }
)
);
default = null;
example = lib.literalExpression ''
{
serverAliases = [
"bookstack.''${config.networking.domain}"
];
# To enable encryption and let let's encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
description = ''
With this option, you can customize the nginx virtualHost settings.
'';
};
};
config = lib.mkIf cfg.enable {
services.phpfpm.pools.bookstack = {
inherit user group;
phpPackage = cfg.package.phpPackage;
phpOptions = ''
log_errors = on
post_max_size = ${cfg.maxUploadSize}
upload_max_filesize = ${cfg.maxUploadSize}
'';
settings = {
"listen.mode" = lib.mkDefault "0660";
"listen.owner" = lib.mkDefault user;
"listen.group" = lib.mkDefault group;
"pm" = lib.mkDefault "dynamic";
"pm.max_children" = lib.mkDefault 32;
"pm.start_servers" = lib.mkDefault 2;
"pm.min_spare_servers" = lib.mkDefault 2;
"pm.max_spare_servers" = lib.mkDefault 4;
"pm.max_requests" = lib.mkDefault 500;
}
// cfg.poolConfig;
};
services.nginx = lib.mkIf (cfg.nginx != null) {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
virtualHosts.${cfg.hostname} = lib.mkMerge [
cfg.nginx
{
locations = {
"/" = {
root = "${cfg.package}/public";
index = "index.php";
tryFiles = "$uri $uri/ /index.php?$query_string";
extraConfig = ''
sendfile off;
'';
};
"~ \\.php$" = {
root = "${cfg.package}/public";
extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_param modHeadersAvailable true; # Avoid sending the security headers twice
fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket};
'';
};
"~ \\.(js|css|gif|png|ico|jpg|jpeg)$" = {
root = "${cfg.package}/public";
extraConfig = "expires 365d;";
};
};
}
];
};
systemd.services.bookstack-setup = {
after = [ "mysql.service" ];
requiredBy = [ "phpfpm-bookstack.service" ];
before = [ "phpfpm-bookstack.service" ];
serviceConfig = {
ExecStart = bookstack-maintenance;
RemainAfterExit = true;
}
// commonServiceConfig;
unitConfig.JoinsNamespaceOf = "phpfpm-bookstack.service";
restartTriggers = [ cfg.package ];
partOf = [ "phpfpm-bookstack.service" ];
};
systemd.tmpfiles.settings."10-bookstack" =
let
defaultConfig = {
inherit user group;
mode = "0700";
};
in
{
"${cfg.dataDir}".d = defaultConfig // {
mode = "0710";
};
"${cfg.dataDir}/public".d = defaultConfig // {
mode = "0750";
};
"${cfg.dataDir}/public/uploads".d = defaultConfig // {
mode = "0750";
};
"${cfg.dataDir}/storage".d = defaultConfig;
"${cfg.dataDir}/storage/app".d = defaultConfig;
"${cfg.dataDir}/storage/fonts".d = defaultConfig;
"${cfg.dataDir}/storage/framework".d = defaultConfig;
"${cfg.dataDir}/storage/framework/cache".d = defaultConfig;
"${cfg.dataDir}/storage/framework/sessions".d = defaultConfig;
"${cfg.dataDir}/storage/framework/views".d = defaultConfig;
"${cfg.dataDir}/storage/logs".d = defaultConfig;
"${cfg.dataDir}/storage/uploads".d = defaultConfig;
"${cfg.dataDir}/cache".d = defaultConfig;
"${cfg.dataDir}/themes".d = defaultConfig;
};
users = {
users = lib.mkIf (user == defaultUser) {
bookstack = {
inherit group;
isSystemUser = true;
home = cfg.dataDir;
};
};
groups = lib.mkIf (group == defaultGroup) {
bookstack = { };
};
};
};
meta.maintainers = with lib.maintainers; [
ymarkus
savyajha
];
}

View File

@@ -0,0 +1,40 @@
# c2FmZQ {#module-services-c2fmzq}
c2FmZQ is an application that can securely encrypt, store, and share files,
including but not limited to pictures and videos.
The service `c2fmzq-server` can be enabled by setting
```nix
{ services.c2fmzq-server.enable = true; }
```
This will spin up an instance of the server which is API-compatible with
[Stingle Photos](https://stingle.org) and an experimental Progressive Web App
(PWA) to interact with the storage via the browser.
In principle the server can be exposed directly on a public interface and there
are command line options to manage HTTPS certificates directly, but the module
is designed to be served behind a reverse proxy or only accessed via localhost.
```nix
{
services.c2fmzq-server = {
enable = true;
bindIP = "127.0.0.1"; # default
port = 8080; # default
};
services.nginx = {
enable = true;
recommendedProxySettings = true;
virtualHosts."example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
};
};
};
}
```
For more information, see <https://github.com/c2FmZQ/c2FmZQ/>.

View File

@@ -0,0 +1,158 @@
{
lib,
pkgs,
config,
...
}:
let
inherit (lib)
mkEnableOption
mkPackageOption
mkOption
types
;
cfg = config.services.c2fmzq-server;
argsFormat = {
type =
with lib.types;
attrsOf (
nullOr (oneOf [
bool
int
str
])
);
generate = lib.cli.toGNUCommandLineShell {
mkBool = k: v: [
"--${k}=${if v then "true" else "false"}"
];
};
};
in
{
options.services.c2fmzq-server = {
enable = mkEnableOption "c2fmzq-server";
bindIP = mkOption {
type = types.str;
default = "127.0.0.1";
description = "The local address to use.";
};
port = mkOption {
type = types.port;
default = 8080;
description = "The local port to use.";
};
passphraseFile = mkOption {
type = types.str;
example = "/run/secrets/c2fmzq/pwfile";
description = "Path to file containing the database passphrase";
};
package = mkPackageOption pkgs "c2fmzq" { };
settings = mkOption {
type = types.submodule {
freeformType = argsFormat.type;
options = {
address = mkOption {
internal = true;
type = types.str;
default = "${cfg.bindIP}:${toString cfg.port}";
};
database = mkOption {
type = types.str;
default = "%S/c2fmzq-server/data";
description = "Path of the database";
};
verbose = mkOption {
type = types.ints.between 1 3;
default = 2;
description = "The level of logging verbosity: 1:Error 2:Info 3:Debug";
};
};
};
description = ''
Configuration for c2FmZQ-server passed as CLI arguments.
Run {command}`c2FmZQ-server help` for supported values.
'';
example = {
verbose = 3;
allow-new-accounts = true;
auto-approve-new-accounts = true;
encrypt-metadata = true;
enable-webapp = true;
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.c2fmzq-server = {
description = "c2FmZQ-server";
documentation = [ "https://github.com/c2FmZQ/c2FmZQ/blob/main/README.md" ];
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [
"network.target"
"network-online.target"
];
serviceConfig = {
ExecStart = "${lib.getExe cfg.package} ${argsFormat.generate cfg.settings}";
AmbientCapabilities = "";
CapabilityBoundingSet = "";
DynamicUser = true;
Environment = "C2FMZQ_PASSPHRASE_FILE=%d/passphrase-file";
IPAccounting = true;
IPAddressAllow = cfg.bindIP;
IPAddressDeny = "any";
LoadCredential = "passphrase-file:${cfg.passphraseFile}";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateIPC = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SocketBindAllow = cfg.port;
SocketBindDeny = "any";
StateDirectory = "c2fmzq-server";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @obsolete"
];
};
};
};
meta = {
doc = ./c2fmzq-server.md;
maintainers = with lib.maintainers; [ hmenke ];
};
}

View File

@@ -0,0 +1,210 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.calibre-web;
dataDir = if lib.hasPrefix "/" cfg.dataDir then cfg.dataDir else "/var/lib/${cfg.dataDir}";
inherit (lib)
concatStringsSep
mkEnableOption
mkIf
mkOption
optional
optionals
optionalString
types
;
in
{
options = {
services.calibre-web = {
enable = mkEnableOption "Calibre-Web";
package = lib.mkPackageOption pkgs "calibre-web" { };
listen = {
ip = mkOption {
type = types.str;
default = "::1";
description = ''
IP address that Calibre-Web should listen on.
'';
};
port = mkOption {
type = types.port;
default = 8083;
description = ''
Listen port for Calibre-Web.
'';
};
};
dataDir = mkOption {
type = types.str;
default = "calibre-web";
description = ''
Where Calibre-Web stores its data.
Either an absolute path, or the directory name below {file}`/var/lib`.
'';
};
user = mkOption {
type = types.str;
default = "calibre-web";
description = "User account under which Calibre-Web runs.";
};
group = mkOption {
type = types.str;
default = "calibre-web";
description = "Group account under which Calibre-Web runs.";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open ports in the firewall for the server.
'';
};
options = {
calibreLibrary = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to Calibre library.
'';
};
enableBookConversion = mkOption {
type = types.bool;
default = false;
description = ''
Configure path to the Calibre's ebook-convert in the DB.
'';
};
enableKepubify = mkEnableOption "kebup conversion support";
enableBookUploading = mkOption {
type = types.bool;
default = false;
description = ''
Allow books to be uploaded via Calibre-Web UI.
'';
};
reverseProxyAuth = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable authorization using auth proxy.
'';
};
header = mkOption {
type = types.str;
default = "";
description = ''
Auth proxy header name.
'';
};
};
};
};
};
config = mkIf cfg.enable {
systemd.tmpfiles.settings = lib.optionalAttrs (lib.hasPrefix "/" cfg.dataDir) {
"10-calibre-web".${dataDir}.d = {
inherit (cfg) user group;
mode = "0700";
};
};
systemd.services.calibre-web =
let
appDb = "${dataDir}/app.db";
gdriveDb = "${dataDir}/gdrive.db";
calibreWebCmd = "${cfg.package}/bin/calibre-web -p ${appDb} -g ${gdriveDb}";
settings = concatStringsSep ", " (
[
"config_port = ${toString cfg.listen.port}"
"config_uploading = ${if cfg.options.enableBookUploading then "1" else "0"}"
"config_allow_reverse_proxy_header_login = ${
if cfg.options.reverseProxyAuth.enable then "1" else "0"
}"
"config_reverse_proxy_login_header_name = '${cfg.options.reverseProxyAuth.header}'"
]
++ optional (
cfg.options.calibreLibrary != null
) "config_calibre_dir = '${cfg.options.calibreLibrary}'"
++ optionals cfg.options.enableBookConversion [
"config_converterpath = '${pkgs.calibre}/bin/ebook-convert'"
"config_binariesdir = '${pkgs.calibre}/bin/'"
]
++ optional cfg.options.enableKepubify "config_kepubifypath = '${pkgs.kepubify}/bin/kepubify'"
);
in
{
description = "Web app for browsing, reading and downloading eBooks stored in a Calibre database";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
# fix book cover cache directory defaults to a path under /nix/store/
environment.CACHE_DIR = "/var/cache/calibre-web";
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStartPre = pkgs.writeShellScript "calibre-web-pre-start" (
''
__RUN_MIGRATIONS_AND_EXIT=1 ${calibreWebCmd}
${pkgs.sqlite}/bin/sqlite3 ${appDb} "update settings set ${settings}"
''
+ optionalString (cfg.options.calibreLibrary != null) ''
test -f "${cfg.options.calibreLibrary}/metadata.db" || { echo "Invalid Calibre library"; exit 1; }
''
);
ExecStart = "${calibreWebCmd} -i ${cfg.listen.ip}";
Restart = "on-failure";
CacheDirectory = "calibre-web";
CacheDirectoryMode = "0750";
}
// lib.optionalAttrs (!(lib.hasPrefix "/" cfg.dataDir)) {
StateDirectory = cfg.dataDir;
};
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.listen.port ];
};
users.users = mkIf (cfg.user == "calibre-web") {
calibre-web = {
isSystemUser = true;
group = cfg.group;
};
};
users.groups = mkIf (cfg.group == "calibre-web") {
calibre-web = { };
};
};
meta.maintainers = with lib.maintainers; [ pborzenkov ];
}

View File

@@ -0,0 +1,28 @@
# Castopod {#module-services-castopod}
Castopod is an open-source hosting platform made for podcasters who want to engage and interact with their audience.
## Quickstart {#module-services-castopod-quickstart}
Configure ACME (<https://nixos.org/manual/nixos/unstable/#module-security-acme>).
Use the following configuration to start a public instance of Castopod on `castopod.example.com` domain:
```nix
{
networking.firewall.allowedTCPPorts = [
80
443
];
services.castopod = {
enable = true;
database.createLocally = true;
nginx.virtualHost = {
serverName = "castopod.example.com";
enableACME = true;
forceSSL = true;
};
};
}
```
Go to `https://castopod.example.com/cp-install` to create superadmin account after applying the above configuration.

View File

@@ -0,0 +1,349 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.castopod;
fpm = config.services.phpfpm.pools.castopod;
user = "castopod";
# https://docs.castopod.org/getting-started/install.html#requirements
phpPackage = pkgs.php82.withExtensions (
{ enabled, all }:
with all;
[
intl
curl
mbstring
gd
exif
mysqlnd
]
++ enabled
);
in
{
meta.doc = ./castopod.md;
meta.maintainers = with lib.maintainers; [ alexoundos ];
options.services = {
castopod = {
enable = lib.mkEnableOption "Castopod, a hosting platform for podcasters";
package = lib.mkPackageOption pkgs "castopod" { };
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/castopod";
description = ''
The path where castopod stores all data. This path must be in sync
with the castopod package (where it is hardcoded during the build in
accordance with its own `dataDir` argument).
'';
};
database = {
createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Create the database and database user locally.
'';
};
hostname = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "Database hostname.";
};
name = lib.mkOption {
type = lib.types.str;
default = "castopod";
description = "Database name.";
};
user = lib.mkOption {
type = lib.types.str;
default = user;
description = "Database user.";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/keys/castopod-dbpassword";
description = ''
A file containing the password corresponding to
[](#opt-services.castopod.database.user).
This file is loaded using systemd LoadCredentials.
'';
};
};
settings = lib.mkOption {
type =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
default = { };
example = {
"email.protocol" = "smtp";
"email.SMTPHost" = "localhost";
"email.SMTPUser" = "myuser";
"email.fromEmail" = "castopod@example.com";
};
description = ''
Environment variables used for Castopod.
See [](https://code.castopod.org/adaures/castopod/-/blob/main/.env.example)
for available environment variables.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/keys/castopod-env";
description = ''
Environment file to inject e.g. secrets into the configuration.
See [](https://code.castopod.org/adaures/castopod/-/blob/main/.env.example)
for available environment variables.
This file is loaded using systemd LoadCredentials.
'';
};
configureNginx = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Configure nginx as a reverse proxy for CastoPod.";
};
localDomain = lib.mkOption {
type = lib.types.str;
example = "castopod.example.org";
description = "The domain serving your CastoPod instance.";
};
poolSettings = lib.mkOption {
type =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = "32";
"pm.start_servers" = "2";
"pm.min_spare_servers" = "2";
"pm.max_spare_servers" = "4";
"pm.max_requests" = "500";
};
description = ''
Options for Castopod's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives.
'';
};
maxUploadSize = lib.mkOption {
type = lib.types.str;
default = "512M";
description = ''
Maximum supported size for a file upload in. Maximum HTTP body
size is set to this value for nginx and PHP (because castopod doesn't
support chunked uploads yet:
https://code.castopod.org/adaures/castopod/-/issues/330).
Note, that practical upload size limit is smaller. For example, with
512 MiB setting - around 500 MiB is possible.
'';
};
};
};
config = lib.mkIf cfg.enable {
services.castopod.settings =
let
sslEnabled =
with config.services.nginx.virtualHosts.${cfg.localDomain};
addSSL || forceSSL || onlySSL || enableACME || useACMEHost != null;
baseURL = "http${lib.optionalString sslEnabled "s"}://${cfg.localDomain}";
in
lib.mapAttrs (_: lib.mkDefault) {
"app.forceGlobalSecureRequests" = sslEnabled;
"app.baseURL" = baseURL;
"media.baseURL" = baseURL;
"media.root" = "media";
"media.storage" = cfg.dataDir;
"admin.gateway" = "admin";
"auth.gateway" = "auth";
"database.default.hostname" = cfg.database.hostname;
"database.default.database" = cfg.database.name;
"database.default.username" = cfg.database.user;
"database.default.DBPrefix" = "cp_";
"cache.handler" = "file";
};
services.phpfpm.pools.castopod = {
inherit user;
group = config.services.nginx.group;
inherit phpPackage;
phpOptions = ''
# https://code.castopod.org/adaures/castopod/-/blob/develop/docker/production/common/uploads.template.ini
file_uploads = On
memory_limit = 512M
upload_max_filesize = ${cfg.maxUploadSize}
post_max_size = ${cfg.maxUploadSize}
max_execution_time = 300
max_input_time = 300
'';
settings = {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
// cfg.poolSettings;
};
systemd.services.castopod-setup = {
after = lib.optional config.services.mysql.enable "mysql.service";
requires = lib.optional config.services.mysql.enable "mysql.service";
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openssl
phpPackage
];
script =
let
envFile = "${cfg.dataDir}/.env";
media = "${cfg.settings."media.storage"}/${cfg.settings."media.root"}";
in
''
mkdir -p ${cfg.dataDir}/writable/{cache,logs,session,temp,uploads}
if [ ! -d ${lib.escapeShellArg media} ]; then
cp --no-preserve=mode,ownership -r ${cfg.package}/share/castopod/public/media ${lib.escapeShellArg media}
fi
if [ ! -f ${cfg.dataDir}/salt ]; then
openssl rand -base64 33 > ${cfg.dataDir}/salt
fi
cat <<'EOF' > ${envFile}
${lib.generators.toKeyValue { } cfg.settings}
EOF
echo "analytics.salt=$(cat ${cfg.dataDir}/salt)" >> ${envFile}
${
if (cfg.database.passwordFile != null) then
''
echo "database.default.password=$(cat "$CREDENTIALS_DIRECTORY/dbpasswordfile)" >> ${envFile}
''
else
''
echo "database.default.password=" >> ${envFile}
''
}
${lib.optionalString (cfg.environmentFile != null) ''
cat "$CREDENTIALS_DIRECTORY/envfile" >> ${envFile}
''}
php ${cfg.package}/share/castopod/spark castopod:database-update
'';
serviceConfig = {
StateDirectory = "castopod";
LoadCredential =
lib.optional (cfg.environmentFile != null) "envfile:${cfg.environmentFile}"
++ (lib.optional (cfg.database.passwordFile != null) "dbpasswordfile:${cfg.database.passwordFile}");
WorkingDirectory = "${cfg.package}/share/castopod";
Type = "oneshot";
RemainAfterExit = true;
User = user;
Group = config.services.nginx.group;
ReadWritePaths = cfg.dataDir;
};
};
systemd.services.castopod-scheduled = {
after = [ "castopod-setup.service" ];
wantedBy = [ "multi-user.target" ];
path = [ phpPackage ];
script = ''
php ${cfg.package}/share/castopod/spark tasks:run
'';
serviceConfig = {
StateDirectory = "castopod";
WorkingDirectory = "${cfg.package}/share/castopod";
Type = "oneshot";
User = user;
Group = config.services.nginx.group;
ReadWritePaths = cfg.dataDir;
LogLevelMax = "notice"; # otherwise periodic tasks flood the journal
};
};
systemd.timers.castopod-scheduled = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* *:*:00";
Unit = "castopod-scheduled.service";
};
};
services.mysql = lib.mkIf cfg.database.createLocally {
enable = true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.nginx = lib.mkIf cfg.configureNginx {
enable = true;
virtualHosts."${cfg.localDomain}" = {
root = lib.mkForce "${cfg.package}/share/castopod/public";
extraConfig = ''
try_files $uri $uri/ /index.php?$args;
index index.php index.html;
client_max_body_size ${cfg.maxUploadSize};
'';
locations."^~ /${cfg.settings."media.root"}/" = {
root = cfg.settings."media.storage";
extraConfig = ''
add_header Access-Control-Allow-Origin "*";
expires max;
access_log off;
'';
};
locations."~ \\.php$" = {
fastcgiParams = {
SERVER_NAME = "$host";
};
extraConfig = ''
fastcgi_intercept_errors on;
fastcgi_index index.php;
fastcgi_pass unix:${fpm.socket};
try_files $uri =404;
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
'';
};
};
};
users.users.${user} = lib.mapAttrs (_: lib.mkDefault) {
description = "Castopod user";
isSystemUser = true;
group = config.services.nginx.group;
};
};
}

View File

@@ -0,0 +1,226 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.changedetection-io;
in
{
options.services.changedetection-io = {
enable = mkEnableOption "changedetection-io";
user = mkOption {
default = "changedetection-io";
type = types.str;
description = ''
User account under which changedetection-io runs.
'';
};
group = mkOption {
default = "changedetection-io";
type = types.str;
description = ''
Group account under which changedetection-io runs.
'';
};
listenAddress = mkOption {
type = types.str;
default = "localhost";
description = "Address the server will listen on.";
};
port = mkOption {
type = types.port;
default = 5000;
description = "Port the server will listen on.";
};
datastorePath = mkOption {
type = types.str;
default = "/var/lib/changedetection-io";
description = ''
The directory used to store all data for changedetection-io.
'';
};
baseURL = mkOption {
type = types.nullOr types.str;
default = null;
example = "https://changedetection-io.example";
description = ''
The base url used in notifications and `{base_url}` token.
'';
};
behindProxy = mkOption {
type = types.bool;
default = false;
description = ''
Enable this option when changedetection-io runs behind a reverse proxy, so that it trusts X-* headers.
It is recommend to run changedetection-io behind a TLS reverse proxy.
'';
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/secrets/changedetection-io.env";
description = ''
Securely pass environment variables to changedetection-io.
This can be used to set for example a frontend password reproducible via `SALTED_PASS`
which convinetly also deactivates nags about the hosted version.
`SALTED_PASS` should be 64 characters long while the first 32 are the salt and the second the frontend password.
It can easily be retrieved from the settings file when first set via the frontend with the following command:
``jq -r .settings.application.password /var/lib/changedetection-io/url-watches.json``
'';
};
webDriverSupport = mkOption {
type = types.bool;
default = false;
description = ''
Enable support for fetching web pages using WebDriver and Chromium.
This starts a headless chromium controlled by puppeteer in an oci container.
::: {.note}
Playwright can currently leak memory.
See <https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher#playwright-memory-leak>
:::
'';
};
playwrightSupport = mkOption {
type = types.bool;
default = false;
description = ''
Enable support for fetching web pages using playwright and Chromium.
This starts a headless Chromium controlled by puppeteer in an oci container.
::: {.note}
Playwright can currently leak memory.
See <https://github.com/dgtlmoon/changedetection.io/wiki/Playwright-content-fetcher#playwright-memory-leak>
:::
'';
};
chromePort = mkOption {
type = types.port;
default = 4444;
description = ''
A free port on which webDriverSupport or playwrightSupport listen on localhost.
'';
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !((cfg.webDriverSupport == true) && (cfg.playwrightSupport == true));
message = "'services.changedetection-io.webDriverSupport' and 'services.changedetection-io.playwrightSupport' cannot be used together.";
}
];
systemd =
let
defaultStateDir = cfg.datastorePath == "/var/lib/changedetection-io";
in
{
services.changedetection-io = {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
User = cfg.user;
Group = cfg.group;
StateDirectory = mkIf defaultStateDir "changedetection-io";
StateDirectoryMode = mkIf defaultStateDir "0750";
WorkingDirectory = cfg.datastorePath;
Environment = [
"HIDE_REFERER=true"
]
++ lib.optional (cfg.baseURL != null) "BASE_URL=${cfg.baseURL}"
++ lib.optional cfg.behindProxy "USE_X_SETTINGS=1"
++ lib.optional cfg.webDriverSupport "WEBDRIVER_URL=http://127.0.0.1:${toString cfg.chromePort}/wd/hub"
++ lib.optional cfg.playwrightSupport "PLAYWRIGHT_DRIVER_URL=ws://127.0.0.1:${toString cfg.chromePort}/?stealth=1&--disable-web-security=true";
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
ExecStart = ''
${pkgs.changedetection-io}/bin/changedetection.py \
-h ${cfg.listenAddress} -p ${toString cfg.port} -d ${cfg.datastorePath}
'';
ProtectHome = true;
ProtectSystem = true;
Restart = "on-failure";
};
};
tmpfiles.rules = mkIf (!defaultStateDir) [
"d ${cfg.datastorePath} 0750 ${cfg.user} ${cfg.group} - -"
];
};
users = {
users = optionalAttrs (cfg.user == "changedetection-io") {
"changedetection-io" = {
isSystemUser = true;
group = "changedetection-io";
};
};
groups = optionalAttrs (cfg.group == "changedetection-io") {
"changedetection-io" = { };
};
};
virtualisation = {
oci-containers.containers = lib.mkMerge [
(mkIf cfg.webDriverSupport {
changedetection-io-webdriver = {
image = "selenium/standalone-chrome";
environment = {
VNC_NO_PASSWORD = "1";
SCREEN_WIDTH = "1920";
SCREEN_HEIGHT = "1080";
SCREEN_DEPTH = "24";
};
ports = [
"127.0.0.1:${toString cfg.chromePort}:4444"
];
volumes = [
"/dev/shm:/dev/shm"
];
extraOptions = [ "--network=bridge" ];
};
})
(mkIf cfg.playwrightSupport {
changedetection-io-playwright = {
image = "browserless/chrome";
environment = {
SCREEN_WIDTH = "1920";
SCREEN_HEIGHT = "1024";
SCREEN_DEPTH = "16";
ENABLE_DEBUGGER = "false";
PREBOOT_CHROME = "true";
CONNECTION_TIMEOUT = "300000";
MAX_CONCURRENT_SESSIONS = "10";
CHROME_REFRESH_TIME = "600000";
DEFAULT_BLOCK_ADS = "true";
DEFAULT_STEALTH = "true";
};
ports = [
"127.0.0.1:${toString cfg.chromePort}:3000"
];
extraOptions = [ "--network=bridge" ];
};
})
];
podman.defaultNetwork.settings.dns_enabled = true;
};
};
}

View File

@@ -0,0 +1,212 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.chhoto-url;
environment = lib.mapAttrs (
_: value:
if value == true then
"True"
else if value == false then
"False"
else
toString value
) cfg.settings;
in
{
meta.maintainers = with lib.maintainers; [ defelo ];
options.services.chhoto-url = {
enable = lib.mkEnableOption "Chhoto URL";
package = lib.mkPackageOption pkgs "chhoto-url" { };
settings = lib.mkOption {
description = ''
Configuration of Chhoto URL.
See <https://github.com/SinTan1729/chhoto-url/blob/main/compose.yaml> for a list of options.
'';
example = {
port = 4567;
};
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
options = {
db_url = lib.mkOption {
type = lib.types.path;
description = "The path of the sqlite database.";
default = "/var/lib/chhoto-url/urls.sqlite";
};
port = lib.mkOption {
type = lib.types.port;
description = "The port to listen on.";
example = 4567;
};
cache_control_header = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "The Cache-Control header to send.";
default = null;
example = "no-cache, private";
};
disable_frontend = lib.mkOption {
type = lib.types.bool;
description = "Whether to disable the frontend.";
default = false;
};
public_mode = lib.mkOption {
type = lib.types.bool;
description = "Whether to enable public mode.";
default = false;
apply = x: if x then "Enable" else "Disable";
};
public_mode_expiry_delay = lib.mkOption {
type = lib.types.nullOr lib.types.ints.unsigned;
description = "The maximum expiry delay in seconds to force in public mode.";
default = null;
example = 3600;
};
redirect_method = lib.mkOption {
type = lib.types.enum [
"TEMPORARY"
"PERMANENT"
];
description = "The redirect method to use.";
default = "PERMANENT";
};
hash_algorithm = lib.mkOption {
type = lib.types.nullOr (lib.types.enum [ "Argon2" ]);
description = ''
The hash algorithm to use for passwords and API keys.
Set to `null` if you want to provide these secrets as plaintext.
'';
default = null;
};
site_url = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "The URL under which Chhoto URL is externally reachable.";
default = null;
};
slug_style = lib.mkOption {
type = lib.types.enum [
"Pair"
"UID"
];
description = "The slug style to use for auto-generated URLs.";
default = "Pair";
};
slug_length = lib.mkOption {
type = lib.types.addCheck lib.types.int (x: x >= 4);
description = "The length of auto-generated slugs.";
default = 8;
};
try_longer_slugs = lib.mkOption {
type = lib.types.bool;
description = "Whether to try a longer UID upon collision.";
default = false;
};
allow_capital_letters = lib.mkOption {
type = lib.types.bool;
description = "Whether to allow capital letters in slugs.";
default = false;
};
custom_landing_directory = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = "The path of a directory which contains a custom landing page.";
default = null;
};
};
};
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
example = [ "/run/secrets/chhoto-url.env" ];
description = ''
Files to load environment variables from in addition to [](#opt-services.chhoto-url.settings).
This is useful to avoid putting secrets into the nix store.
See <https://github.com/SinTan1729/chhoto-url/blob/main/compose.yaml> for a list of options.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.chhoto-url = {
wantedBy = [ "multi-user.target" ];
inherit environment;
serviceConfig = {
User = "chhoto-url";
Group = "chhoto-url";
DynamicUser = true;
StateDirectory = "chhoto-url";
EnvironmentFile = cfg.environmentFiles;
ExecStart = lib.getExe cfg.package;
# hardening
AmbientCapabilities = "";
CapabilityBoundingSet = [ "" ];
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SocketBindAllow = "tcp:${toString cfg.settings.port}";
SocketBindDeny = "any";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
UMask = "0077";
};
};
};
}

View File

@@ -0,0 +1,523 @@
{
config,
pkgs,
lib,
...
}:
with lib;
let
cfg = config.services.cloudlog;
dbFile =
let
password =
if cfg.database.createLocally then
"''"
else
"trim(file_get_contents('${cfg.database.passwordFile}'))";
in
pkgs.writeText "database.php" ''
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
$active_group = 'default';
$query_builder = TRUE;
$db['default'] = array(
'dsn' => "",
'hostname' => '${cfg.database.host}',
'username' => '${cfg.database.user}',
'password' => ${password},
'database' => '${cfg.database.name}',
'dbdriver' => 'mysqli',
'dbprefix' => "",
'pconnect' => TRUE,
'db_debug' => (ENVIRONMENT !== 'production'),
'cache_on' => FALSE,
'cachedir' => "",
'char_set' => 'utf8mb4',
'dbcollat' => 'utf8mb4_general_ci',
'swap_pre' => "",
'encrypt' => FALSE,
'compress' => FALSE,
'stricton' => FALSE,
'failover' => array(),
'save_queries' => TRUE
);
'';
configFile = pkgs.writeText "config.php" ''
<?php
include('${pkgs.cloudlog}/install/config/config.php');
$config['datadir'] = "${cfg.dataDir}/";
$config['base_url'] = "${cfg.baseUrl}";
${cfg.extraConfig}
'';
package = pkgs.stdenv.mkDerivation rec {
pname = "cloudlog";
version = src.version;
src = pkgs.cloudlog;
installPhase = ''
mkdir -p $out
cp -r * $out/
ln -s ${configFile} $out/application/config/config.php
ln -s ${dbFile} $out/application/config/database.php
# link writable directories
for directory in updates uploads backup logbook; do
rm -rf $out/$directory
ln -s ${cfg.dataDir}/$directory $out/$directory
done
# link writable asset files
for asset in dok sota wwff; do
rm -rf $out/assets/json/$asset.txt
ln -s ${cfg.dataDir}/assets/json/$asset.txt $out/assets/json/$asset.txt
done
'';
};
in
{
options.services.cloudlog = with types; {
enable = mkEnableOption "Cloudlog";
dataDir = mkOption {
type = str;
default = "/var/lib/cloudlog";
description = "Cloudlog data directory.";
};
baseUrl = mkOption {
type = str;
default = "http://localhost";
description = "Cloudlog base URL";
};
user = mkOption {
type = str;
default = "cloudlog";
description = "User account under which Cloudlog runs.";
};
database = {
createLocally = mkOption {
type = types.bool;
default = true;
description = "Create the database and database user locally.";
};
host = mkOption {
type = str;
description = "MySQL database host";
default = "localhost";
};
name = mkOption {
type = str;
description = "MySQL database name.";
default = "cloudlog";
};
user = mkOption {
type = str;
description = "MySQL user name.";
default = "cloudlog";
};
passwordFile = mkOption {
type = nullOr str;
description = "MySQL user password file.";
default = null;
};
};
poolConfig = mkOption {
type = attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for Cloudlog's PHP-FPM pool.
'';
};
virtualHost = mkOption {
type = nullOr str;
default = "localhost";
description = ''
Name of the nginx virtualhost to use and setup. If null, do not setup
any virtualhost.
'';
};
extraConfig = mkOption {
description = ''
Any additional text to be appended to the config.php
configuration file. This is a PHP script. For configuration
settings, see <https://github.com/magicbug/Cloudlog/wiki/Cloudlog.php-Configuration-File>.
'';
default = "";
type = str;
example = ''
$config['show_time'] = TRUE;
'';
};
upload-lotw = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically upload logs to LoTW. If enabled, a systemd
timer will run the log upload task as specified by the interval
option.
'';
};
interval = mkOption {
type = str;
default = "daily";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the
time at which the LoTW upload will occur.
'';
};
};
upload-clublog = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically upload logs to Clublog. If enabled, a systemd
timer will run the log upload task as specified by the interval option.
'';
};
interval = mkOption {
type = str;
default = "daily";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the time
at which the Clublog upload will occur.
'';
};
};
update-lotw-users = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically update the list of LoTW users. If enabled, a
systemd timer will run the update task as specified by the interval
option.
'';
};
interval = mkOption {
type = str;
default = "weekly";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the
time at which the LoTW user update will occur.
'';
};
};
update-dok = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically update the DOK resource file. If enabled, a
systemd timer will run the update task as specified by the interval option.
'';
};
interval = mkOption {
type = str;
default = "monthly";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the
time at which the DOK update will occur.
'';
};
};
update-clublog-scp = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically update the Clublog SCP database. If enabled,
a systemd timer will run the update task as specified by the interval
option.
'';
};
interval = mkOption {
type = str;
default = "monthly";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the time
at which the Clublog SCP update will occur.
'';
};
};
update-wwff = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically update the WWFF database. If enabled, a
systemd timer will run the update task as specified by the interval
option.
'';
};
interval = mkOption {
type = str;
default = "monthly";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the time
at which the WWFF update will occur.
'';
};
};
upload-qrz = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically upload logs to QRZ. If enabled, a systemd
timer will run the update task as specified by the interval option.
'';
};
interval = mkOption {
type = str;
default = "daily";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the
time at which the QRZ upload will occur.
'';
};
};
update-sota = {
enable = mkOption {
type = bool;
default = true;
description = ''
Whether to periodically update the SOTA database. If enabled, a
systemd timer will run the update task as specified by the interval option.
'';
};
interval = mkOption {
type = str;
default = "monthly";
description = ''
Specification (in the format described by {manpage}`systemd.time(7)`) of the time
at which the SOTA update will occur.
'';
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = "services.cloudlog.database.passwordFile cannot be specified if services.cloudlog.database.createLocally is set to true.";
}
];
services.phpfpm = {
pools.cloudlog = {
inherit (cfg) user;
group = config.services.nginx.group;
settings = {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
// cfg.poolConfig;
};
};
services.nginx = mkIf (cfg.virtualHost != null) {
enable = true;
virtualHosts = {
"${cfg.virtualHost}" = {
root = "${package}";
locations."/".tryFiles = "$uri /index.php$is_args$args";
locations."~ ^/index.php(/|$)".extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params;
include ${pkgs.nginx}/conf/fastcgi.conf;
fastcgi_split_path_info ^(.+\.php)(.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.cloudlog.socket};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
'';
};
};
};
services.mysql = mkIf cfg.database.createLocally {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}
];
};
systemd = {
services = {
cloudlog-setup-database = mkIf cfg.database.createLocally {
description = "Set up cloudlog database";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
wantedBy = [ "phpfpm-cloudlog.service" ];
after = [ "mysql.service" ];
script =
let
mysql = "${config.services.mysql.package}/bin/mysql";
in
''
if [ ! -f ${cfg.dataDir}/.dbexists ]; then
${mysql} ${cfg.database.name} < ${pkgs.cloudlog}/install/assets/install.sql
touch ${cfg.dataDir}/.dbexists
fi
'';
};
cloudlog-upload-lotw = {
description = "Upload QSOs to LoTW if certs have been provided";
enable = cfg.upload-lotw.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/lotw/lotw_upload";
};
cloudlog-update-lotw-users = {
description = "Update LOTW Users Database";
enable = cfg.update-lotw-users.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/lotw/load_users";
};
cloudlog-update-dok = {
description = "Update DOK File for autocomplete";
enable = cfg.update-dok.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/update/update_dok";
};
cloudlog-update-clublog-scp = {
description = "Update Clublog SCP Database File";
enable = cfg.update-clublog-scp.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/update/update_clublog_scp";
};
cloudlog-update-wwff = {
description = "Update WWFF File for autocomplete";
enable = cfg.update-wwff.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/update/update_wwff";
};
cloudlog-upload-qrz = {
description = "Upload QSOs to QRZ Logbook";
enable = cfg.upload-qrz.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/qrz/upload";
};
cloudlog-update-sota = {
description = "Update SOTA File for autocomplete";
enable = cfg.update-sota.enable;
script = "${pkgs.curl}/bin/curl -s ${cfg.baseUrl}/update/update_sota";
};
};
timers = {
cloudlog-upload-lotw = {
enable = cfg.upload-lotw.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-upload-lotw.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.upload-lotw.interval;
Persistent = true;
};
};
cloudlog-upload-clublog = {
enable = cfg.upload-clublog.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-upload-clublog.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.upload-clublog.interval;
Persistent = true;
};
};
cloudlog-update-lotw-users = {
enable = cfg.update-lotw-users.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-update-lotw-users.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.update-lotw-users.interval;
Persistent = true;
};
};
cloudlog-update-dok = {
enable = cfg.update-dok.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-update-dok.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.update-dok.interval;
Persistent = true;
};
};
cloudlog-update-clublog-scp = {
enable = cfg.update-clublog-scp.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-update-clublog-scp.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.update-clublog-scp.interval;
Persistent = true;
};
};
cloudlog-update-wwff = {
enable = cfg.update-wwff.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-update-wwff.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.update-wwff.interval;
Persistent = true;
};
};
cloudlog-upload-qrz = {
enable = cfg.upload-qrz.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-upload-qrz.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.upload-qrz.interval;
Persistent = true;
};
};
cloudlog-update-sota = {
enable = cfg.update-sota.enable;
wantedBy = [ "timers.target" ];
partOf = [ "cloudlog-update-sota.service" ];
after = [ "phpfpm-cloudlog.service" ];
timerConfig = {
OnCalendar = cfg.update-sota.interval;
Persistent = true;
};
};
};
tmpfiles.rules =
let
group = config.services.nginx.group;
in
[
"d ${cfg.dataDir} 0750 ${cfg.user} ${group} - -"
"d ${cfg.dataDir}/updates 0750 ${cfg.user} ${group} - -"
"d ${cfg.dataDir}/uploads 0750 ${cfg.user} ${group} - -"
"d ${cfg.dataDir}/backup 0750 ${cfg.user} ${group} - -"
"d ${cfg.dataDir}/logbook 0750 ${cfg.user} ${group} - -"
"d ${cfg.dataDir}/assets/json 0750 ${cfg.user} ${group} - -"
"d ${cfg.dataDir}/assets/qslcard 0750 ${cfg.user} ${group} - -"
];
};
users.users."${cfg.user}" = {
isSystemUser = true;
group = config.services.nginx.group;
};
};
meta.maintainers = with maintainers; [ melling ];
}

View File

@@ -0,0 +1,281 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.code-server;
defaultUser = "code-server";
defaultGroup = defaultUser;
in
{
options = {
services.code-server = {
enable = lib.mkEnableOption "code-server";
package = lib.mkPackageOption pkgs "code-server" {
example = ''
pkgs.vscode-with-extensions.override {
vscode = pkgs.code-server;
vscodeExtensions = with pkgs.vscode-extensions; [
bbenoist.nix
dracula-theme.theme-dracula
];
}
'';
};
extraPackages = lib.mkOption {
default = [ ];
description = ''
Additional packages to add to the code-server {env}`PATH`.
'';
example = lib.literalExpression "[ pkgs.go ]";
type = lib.types.listOf lib.types.package;
};
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = ''
Additional environment variables to pass to code-server.
'';
default = { };
example = {
PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig";
};
};
extraArguments = lib.mkOption {
default = [ ];
description = ''
Additional arguments to pass to code-server.
'';
example = lib.literalExpression ''[ "--log=info" ]'';
type = lib.types.listOf lib.types.str;
};
host = lib.mkOption {
default = "localhost";
description = ''
The host name or IP address the server should listen to.
'';
type = lib.types.str;
};
port = lib.mkOption {
default = 4444;
description = ''
The port the server should listen to.
'';
type = lib.types.port;
};
auth = lib.mkOption {
default = "password";
description = ''
The type of authentication to use.
'';
type = lib.types.enum [
"none"
"password"
];
};
hashedPassword = lib.mkOption {
default = "";
description = ''
Create the password with: {command}`echo -n 'thisismypassword' | nix run nixpkgs#libargon2 -- "$(head -c 20 /dev/random | base64)" -e`
'';
type = lib.types.str;
};
user = lib.mkOption {
default = defaultUser;
example = "yourUser";
description = ''
The user to run code-server as.
By default, a user named `${defaultUser}` will be created.
'';
type = lib.types.str;
};
group = lib.mkOption {
default = defaultGroup;
example = "yourGroup";
description = ''
The group to run code-server under.
By default, a group named `${defaultGroup}` will be created.
'';
type = lib.types.str;
};
extraGroups = lib.mkOption {
default = [ ];
description = ''
An array of additional groups for the `${defaultUser}` user.
'';
example = [ "docker" ];
type = lib.types.listOf lib.types.str;
};
socket = lib.mkOption {
default = null;
example = "/run/code-server/socket";
description = ''
Path to a socket (bind-addr will be ignored).
'';
type = lib.types.nullOr lib.types.str;
};
socketMode = lib.mkOption {
default = null;
description = ''
File mode of the socket.
'';
type = lib.types.nullOr lib.types.str;
};
userDataDir = lib.mkOption {
default = null;
description = ''
Path to the user data directory.
'';
type = lib.types.nullOr lib.types.str;
};
extensionsDir = lib.mkOption {
default = null;
description = ''
Path to the extensions directory.
'';
type = lib.types.nullOr lib.types.str;
};
proxyDomain = lib.mkOption {
default = null;
example = "code-server.lan";
description = ''
Domain used for proxying ports.
'';
type = lib.types.nullOr lib.types.str;
};
disableTelemetry = lib.mkOption {
default = false;
example = true;
description = ''
Disable telemetry.
'';
type = lib.types.bool;
};
disableUpdateCheck = lib.mkOption {
default = false;
example = true;
description = ''
Disable update check.
Without this flag, code-server checks every 6 hours against the latest github release and
then notifies you once every week that a new release is available.
'';
type = lib.types.bool;
};
disableFileDownloads = lib.mkOption {
default = false;
example = true;
description = ''
Disable file downloads from Code.
'';
type = lib.types.bool;
};
disableWorkspaceTrust = lib.mkOption {
default = false;
example = true;
description = ''
Disable Workspace Trust feature.
'';
type = lib.types.bool;
};
disableGettingStartedOverride = lib.mkOption {
default = false;
example = true;
description = ''
Disable the coder/coder override in the Help: Getting Started page.
'';
type = lib.types.bool;
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.code-server = {
description = "Code server";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = cfg.extraPackages;
environment = {
HASHED_PASSWORD = cfg.hashedPassword;
}
// cfg.extraEnvironment;
serviceConfig = {
ExecStart = ''
${lib.getExe cfg.package} \
--auth=${cfg.auth} \
--bind-addr=${cfg.host}:${toString cfg.port} \
''
+ lib.optionalString (cfg.socket != null) ''
--socket=${cfg.socket} \
''
+ lib.optionalString (cfg.userDataDir != null) ''
--user-data-dir=${cfg.userDataDir} \
''
+ lib.optionalString (cfg.extensionsDir != null) ''
--extensions-dir=${cfg.extensionsDir} \
''
+ lib.optionalString (cfg.disableTelemetry == true) ''
--disable-telemetry \
''
+ lib.optionalString (cfg.disableUpdateCheck == true) ''
--disable-update-check \
''
+ lib.optionalString (cfg.disableFileDownloads == true) ''
--disable-file-downloads \
''
+ lib.optionalString (cfg.disableWorkspaceTrust == true) ''
--disable-workspace-trust \
''
+ lib.optionalString (cfg.disableGettingStartedOverride == true) ''
--disable-getting-started-override \
''
+ lib.escapeShellArgs cfg.extraArguments;
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
RuntimeDirectory = cfg.user;
User = cfg.user;
Group = cfg.group;
Restart = "on-failure";
};
};
users.users."${cfg.user}" = lib.mkMerge [
(lib.mkIf (cfg.user == defaultUser) {
isNormalUser = true;
description = "code-server user";
inherit (cfg) group;
})
{
packages = cfg.extraPackages;
inherit (cfg) extraGroups;
}
];
users.groups."${defaultGroup}" = lib.mkIf (cfg.group == defaultGroup) { };
};
meta.maintainers = [ lib.maintainers.stackshadow ];
}

View File

@@ -0,0 +1,242 @@
{
config,
lib,
options,
pkgs,
...
}:
with lib;
let
cfg = config.services.coder;
name = "coder";
in
{
options = {
services.coder = {
enable = mkEnableOption "Coder service";
user = mkOption {
type = types.str;
default = "coder";
description = ''
User under which the coder service runs.
::: {.note}
If left as the default value this user will automatically be created
on system activation, otherwise it needs to be configured manually.
:::
'';
};
group = mkOption {
type = types.str;
default = "coder";
description = ''
Group under which the coder service runs.
::: {.note}
If left as the default value this group will automatically be created
on system activation, otherwise it needs to be configured manually.
:::
'';
};
package = mkPackageOption pkgs "coder" { };
homeDir = mkOption {
type = types.str;
description = ''
Home directory for coder user.
'';
default = "/var/lib/coder";
};
listenAddress = mkOption {
type = types.str;
description = ''
Listen address.
'';
default = "127.0.0.1:3000";
};
accessUrl = mkOption {
type = types.nullOr types.str;
description = ''
Access URL should be a external IP address or domain with DNS records pointing to Coder.
'';
default = null;
example = "https://coder.example.com";
};
wildcardAccessUrl = mkOption {
type = types.nullOr types.str;
description = ''
If you are providing TLS certificates directly to the Coder server, you must use a single certificate for the root and wildcard domains.
'';
default = null;
example = "*.coder.example.com";
};
environment = {
extra = mkOption {
type = types.attrs;
description = "Extra environment variables to pass run Coder's server with. See Coder documentation.";
default = { };
example = {
CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS = true;
CODER_OAUTH2_GITHUB_ALLOWED_ORGS = "your-org";
};
};
file = mkOption {
type = types.nullOr types.path;
description = "Systemd environment file to add to Coder.";
default = null;
};
};
database = {
createLocally = mkOption {
type = types.bool;
default = true;
description = ''
Create the database and database user locally.
'';
};
host = mkOption {
type = types.str;
default = "/run/postgresql";
description = ''
Hostname hosting the database.
'';
};
database = mkOption {
type = types.str;
default = "coder";
description = ''
Name of database.
'';
};
username = mkOption {
type = types.str;
default = "coder";
description = ''
Username for accessing the database.
'';
};
password = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Password for accessing the database.
'';
};
sslmode = mkOption {
type = types.nullOr types.str;
default = "disable";
description = ''
Password for accessing the database.
'';
};
};
tlsCert = mkOption {
type = types.nullOr types.path;
description = ''
The path to the TLS certificate.
'';
default = null;
};
tlsKey = mkOption {
type = types.nullOr types.path;
description = ''
The path to the TLS key.
'';
default = null;
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion =
cfg.database.createLocally
-> cfg.database.username == name && cfg.database.database == cfg.database.username;
message = "services.coder.database.username must be set to ${name} if services.coder.database.createLocally is set true";
}
];
systemd.services.coder = {
description = "Coder - Self-hosted developer workspaces on your infra";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = cfg.environment.extra // {
CODER_ACCESS_URL = cfg.accessUrl;
CODER_WILDCARD_ACCESS_URL = cfg.wildcardAccessUrl;
CODER_PG_CONNECTION_URL = "user=${cfg.database.username} ${
optionalString (cfg.database.password != null) "password=${cfg.database.password}"
} database=${cfg.database.database} host=${cfg.database.host} ${
optionalString (cfg.database.sslmode != null) "sslmode=${cfg.database.sslmode}"
}";
CODER_ADDRESS = cfg.listenAddress;
CODER_TLS_ENABLE = optionalString (cfg.tlsCert != null) "1";
CODER_TLS_CERT_FILE = cfg.tlsCert;
CODER_TLS_KEY_FILE = cfg.tlsKey;
};
serviceConfig = {
ProtectSystem = "full";
PrivateTmp = "yes";
PrivateDevices = "yes";
SecureBits = "keep-caps";
AmbientCapabilities = "CAP_IPC_LOCK CAP_NET_BIND_SERVICE";
CacheDirectory = "coder";
CapabilityBoundingSet = "CAP_SYSLOG CAP_IPC_LOCK CAP_NET_BIND_SERVICE";
KillSignal = "SIGINT";
KillMode = "mixed";
NoNewPrivileges = "yes";
Restart = "on-failure";
ExecStart = "${cfg.package}/bin/coder server";
User = cfg.user;
Group = cfg.group;
EnvironmentFile = lib.mkIf (cfg.environment.file != null) cfg.environment.file;
};
};
services.postgresql = lib.mkIf cfg.database.createLocally {
enable = true;
ensureDatabases = [
cfg.database.database
];
ensureUsers = [
{
name = cfg.user;
ensureDBOwnership = true;
}
];
};
users.groups = optionalAttrs (cfg.group == name) {
"${cfg.group}" = { };
};
users.users = optionalAttrs (cfg.user == name) {
${name} = {
description = "Coder service user";
group = cfg.group;
home = cfg.homeDir;
createHome = true;
isSystemUser = true;
};
};
};
meta.maintainers = pkgs.coder.meta.maintainers;
}

View File

@@ -0,0 +1,200 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
cfg = config.services.collabora-online;
freeformType = lib.types.attrsOf ((pkgs.formats.json { }).type) // {
description = ''
`coolwsd.xml` configuration type, used to override values in the default configuration.
Attribute names correspond to XML tags unless prefixed with `@`. Nested attribute sets
correspond to nested XML tags. Attribute prefixed with `@` correspond to XML attributes. E.g.,
`{ storage.wopi."@allow" = true; }` in Nix corresponds to
`<storage><wopi allow="true"/></storage>` in `coolwsd.xml`, or `--o:storage.wopi[@allow]=true`
in the command line.
Arrays correspond to multiple elements with the same tag name. E.g.
`{ host = [ '''127\.0\.0\.1''' "::1" ]; }` in Nix corresponds to
```xml
<net><post_allow>
<host>127\.0\.0\.1</host>
<host>::1</host>
</post_allow></net>
```
in `coolwsd.xml`, or
`--o:net.post_allow.host[0]='127\.0\.0\.1 --o:net.post_allow.host[1]=::1` in the command line.
Null values could be used to remove an element from the default configuration.
'';
};
configFile =
pkgs.runCommandLocal "coolwsd.xml"
{
nativeBuildInputs = [
pkgs.jq
pkgs.yq-go
];
userConfig = builtins.toJSON { config = cfg.settings; };
passAsFile = [ "userConfig" ];
}
# Merge the cfg.settings into the default coolwsd.xml.
# See https://github.com/CollaboraOnline/online/issues/10049.
''
yq --input-format=xml \
--xml-attribute-prefix=@ \
--output-format=json \
${cfg.package}/etc/coolwsd/coolwsd.xml \
> ./default_coolwsd.json
jq '.[0] * .[1] | del(..|nulls)' \
--slurp \
./default_coolwsd.json \
$userConfigPath \
> ./merged.json
yq --output-format=xml \
--xml-attribute-prefix=@ \
./merged.json \
> $out
'';
in
{
options.services.collabora-online = {
enable = lib.mkEnableOption "collabora-online";
package = lib.mkPackageOption pkgs "Collabora Online" { default = "collabora-online"; };
port = lib.mkOption {
type = lib.types.port;
default = 9980;
description = "Listening port";
};
settings = lib.mkOption {
type = freeformType;
default = { };
description = ''
Configuration for Collabora Online WebSocket Daemon, see
<https://sdk.collaboraonline.com/docs/installation/Configuration.html>, or
<https://github.com/CollaboraOnline/online/blob/master/coolwsd.xml.in> for the default
configuration.
'';
};
aliasGroups = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
example = "scheme://hostname:port";
description = "Hostname to allow or deny.";
};
aliases = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"scheme://aliasname1:port"
"scheme://aliasname2:port"
];
description = "A list of regex pattern of aliasname.";
};
};
}
);
default = [ ];
description = "Alias groups to use.";
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Extra arguments to pass to the service.";
};
};
config = lib.mkIf cfg.enable {
services.collabora-online.settings = {
child_root_path = lib.mkDefault "/var/lib/cool/child-roots";
sys_template_path = lib.mkDefault "/var/lib/cool/systemplate";
file_server_root_path = lib.mkDefault "${config.services.collabora-online.package}/share/coolwsd";
# Use mount namespaces instead of setcap'd coolmount/coolforkit.
mount_namespaces = lib.mkDefault true;
# By default, use dummy self-signed certificates provided for testing.
ssl.ca_file_path = lib.mkDefault "${config.services.collabora-online.package}/etc/coolwsd/ca-chain.cert.pem";
ssl.cert_file_path = lib.mkDefault "${config.services.collabora-online.package}/etc/coolwsd/cert.pem";
ssl.key_file_path = lib.mkDefault "${config.services.collabora-online.package}/etc/coolwsd/key.pem";
};
users.users.cool = {
isSystemUser = true;
group = "cool";
};
users.groups.cool = { };
systemd.services.coolwsd-systemplate-setup = {
description = "Collabora Online WebSocket Daemon Setup";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = utils.escapeSystemdExecArgs [
"${cfg.package}/bin/coolwsd-systemplate-setup"
"/var/lib/cool/systemplate"
"${cfg.package.libreoffice}/lib/collaboraoffice"
];
RemainAfterExit = true;
StateDirectory = "cool";
Type = "oneshot";
User = "cool";
};
};
systemd.services.coolwsd = {
description = "Collabora Online WebSocket Daemon";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"coolwsd-systemplate-setup.service"
];
environment = builtins.listToAttrs (
lib.imap1 (n: ag: {
name = "aliasgroup${toString n}";
value = lib.concatStringsSep "," ([ ag.host ] ++ ag.aliases);
}) cfg.aliasGroups
);
serviceConfig = {
ExecStart = utils.escapeSystemdExecArgs (
[
"${cfg.package}/bin/coolwsd"
"--config-file=${configFile}"
"--port=${toString cfg.port}"
"--use-env-vars"
"--version"
]
++ cfg.extraArgs
);
KillMode = "mixed";
KillSignal = "SIGINT";
LimitNOFILE = "infinity:infinity";
Restart = "always";
StateDirectory = "cool";
TimeoutStopSec = 120;
User = "cool";
};
};
};
meta.maintainers = [ lib.maintainers.xzfc ];
}

View File

@@ -0,0 +1,115 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.commafeed;
in
{
options.services.commafeed = {
enable = lib.mkEnableOption "CommaFeed";
package = lib.mkPackageOption pkgs "commafeed" { };
user = lib.mkOption {
type = lib.types.str;
description = "User under which CommaFeed runs.";
default = "commafeed";
};
group = lib.mkOption {
type = lib.types.str;
description = "Group under which CommaFeed runs.";
default = "commafeed";
};
stateDir = lib.mkOption {
type = lib.types.path;
description = "Directory holding all state for CommaFeed to run.";
default = "/var/lib/commafeed";
};
environment = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.bool
lib.types.int
lib.types.str
]
);
description = ''
Extra environment variables passed to CommaFeed, refer to
<https://github.com/Athou/commafeed/blob/master/commafeed-server/config.yml.example>
for supported values. The default user is `admin` and the default password is `admin`.
Correct configuration for H2 database is already provided.
'';
default = { };
example = {
CF_SERVER_APPLICATIONCONNECTORS_0_TYPE = "http";
CF_SERVER_APPLICATIONCONNECTORS_0_PORT = 9090;
};
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
Environment file as defined in {manpage}`systemd.exec(5)`.
'';
default = null;
example = "/var/lib/commafeed/commafeed.env";
};
};
config = lib.mkIf cfg.enable {
systemd.services.commafeed = {
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = lib.mapAttrs (
_: v: if lib.isBool v then lib.boolToString v else toString v
) cfg.environment;
serviceConfig = {
ExecStart = "${lib.getExe cfg.package} server ${cfg.package}/share/config.yml";
User = cfg.user;
Group = cfg.group;
StateDirectory = baseNameOf cfg.stateDir;
WorkingDirectory = cfg.stateDir;
# Hardening
CapabilityBoundingSet = [ "" ];
DevicePolicy = "closed";
DynamicUser = true;
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
}
// lib.optionalAttrs (cfg.environmentFile != null) { EnvironmentFile = cfg.environmentFile; };
};
};
meta.maintainers = [ lib.maintainers.raroh73 ];
}

View File

@@ -0,0 +1,80 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.convos;
in
{
options.services.convos = {
enable = mkEnableOption "Convos";
listenPort = mkOption {
type = types.port;
default = 3000;
example = 8080;
description = "Port the web interface should listen on";
};
listenAddress = mkOption {
type = types.str;
default = "*";
example = "127.0.0.1";
description = "Address or host the web interface should listen on";
};
reverseProxy = mkOption {
type = types.bool;
default = false;
description = ''
Enables reverse proxy support. This will allow Convos to automatically
pick up the `X-Forwarded-For` and
`X-Request-Base` HTTP headers set in your reverse proxy
web server. Note that enabling this option without a reverse proxy in
front will be a security issue.
'';
};
};
config = mkIf cfg.enable {
systemd.services.convos = {
description = "Convos Service";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
environment = {
CONVOS_HOME = "%S/convos";
CONVOS_REVERSE_PROXY = if cfg.reverseProxy then "1" else "0";
MOJO_LISTEN = "http://${toString cfg.listenAddress}:${toString cfg.listenPort}";
};
serviceConfig = {
ExecStart = "${pkgs.convos}/bin/convos daemon";
Restart = "on-failure";
StateDirectory = "convos";
WorkingDirectory = "%S/convos";
DynamicUser = true;
MemoryDenyWriteExecute = true;
ProtectHome = true;
ProtectClock = true;
ProtectHostname = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateUsers = true;
LockPersonality = true;
RestrictRealtime = true;
RestrictNamespaces = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
SystemCallFilter = "@system-service";
SystemCallArchitectures = "native";
CapabilityBoundingSet = "";
};
};
};
}

View File

@@ -0,0 +1,113 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.cook-cli;
inherit (lib)
mkIf
mkEnableOption
mkPackageOption
mkOption
getExe
types
;
in
{
options = {
services.cook-cli = {
enable = lib.mkEnableOption "cook-cli";
package = lib.mkPackageOption pkgs "cook-cli" { };
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to start cook-cli server automatically.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 9080;
description = ''
Which port cook-cli server will use.
'';
};
basePath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/cook-cli";
description = ''
Path to the directory cook-cli will look for recipes.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to open the cook-cli server port in the firewall.
'';
};
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
systemd.tmpfiles.rules = [
"d ${cfg.basePath} 0770 cook-cli users"
];
users.users.cook-cli = {
home = "${cfg.basePath}";
group = "cook-cli";
isSystemUser = true;
};
users.groups.cook-cli.members = [
"cook-cli"
];
systemd.services.cook-cli = {
description = "cook-cli server";
serviceConfig = {
ExecStart = "${getExe cfg.package} server --host --port ${toString cfg.port} ${cfg.basePath}";
WorkingDirectory = cfg.basePath;
User = "cook-cli";
Group = "cook-cli";
# Hardening options
CapabilityBoundingSet = [ "CAP_SYS_NICE" ];
AmbientCapabilities = [ "CAP_SYS_NICE" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
ReadWritePaths = cfg.basePath;
RestrictNamespaces = true;
RestrictSUIDSGID = true;
Restart = "on-failure";
RestartSec = 5;
};
wantedBy = mkIf cfg.autoStart [ "multi-user.target" ];
wants = [ "network.target" ];
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
};
meta.maintainers = [
lib.maintainers.luNeder
lib.maintainers.emilioziniades
];
}

View File

@@ -0,0 +1,173 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
literalExpression
mkEnableOption
mkIf
mkOption
mkPackageOption
;
inherit (lib.types)
attrsOf
package
port
str
;
cfg = config.services.crabfit;
in
{
options.services.crabfit = {
enable = mkEnableOption "Crab Fit, a meeting scheduler based on peoples' availability";
frontend = {
package = mkPackageOption pkgs "crabfit-frontend" { };
finalDrv = mkOption {
readOnly = true;
type = package;
default = cfg.frontend.package.override {
api_url = "https://${cfg.api.host}";
frontend_url = cfg.frontend.host;
};
defaultText = literalExpression ''
cfg.package.override {
api_url = "https://''${cfg.api.host}";
frontend_url = cfg.frontend.host;
};
'';
description = ''
The patched frontend, using the correct urls for the API and frontend.
'';
};
environment = mkOption {
type = attrsOf str;
default = { };
description = ''
Environment variables for the crabfit frontend.
'';
};
host = mkOption {
type = str;
description = ''
The hostname of the frontend.
'';
};
port = mkOption {
type = port;
default = 3001;
description = ''
The internal listening port of the frontend.
'';
};
};
api = {
package = mkPackageOption pkgs "crabfit-api" { };
environment = mkOption {
type = attrsOf str;
default = { };
description = ''
Environment variables for the crabfit API.
'';
};
host = mkOption {
type = str;
description = ''
The hostname of the API.
'';
};
port = mkOption {
type = port;
default = 3000;
description = ''
The internal listening port of the API.
'';
};
};
};
config = mkIf cfg.enable {
systemd.services = {
crabfit-api = {
description = "The API for Crab Fit.";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.target" ];
serviceConfig = {
# TODO: harden
ExecStart = lib.getExe cfg.api.package;
User = "crabfit";
};
environment = {
API_LISTEN = "127.0.0.1:${builtins.toString cfg.api.port}";
DATABASE_URL = "postgres:///crabfit?host=/run/postgresql";
FRONTEND_URL = "https://${cfg.frontend.host}";
}
// cfg.api.environment;
};
crabfit-frontend = {
description = "The frontend for Crab Fit.";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
# TODO: harden
CacheDirectory = "crabfit";
DynamicUser = true;
ExecStart = "${lib.getExe pkgs.nodejs} standalone/server.js";
WorkingDirectory = cfg.frontend.finalDrv;
};
environment = {
NEXT_PUBLIC_API_URL = "https://${cfg.api.host}";
PORT = builtins.toString cfg.frontend.port;
}
// cfg.frontend.environment;
};
};
users = {
groups.crabfit = { };
users.crabfit = {
group = "crabfit";
isSystemUser = true;
};
};
services = {
postgresql = {
enable = true;
ensureDatabases = [ "crabfit" ];
ensureUsers = [
{
name = "crabfit";
ensureDBOwnership = true;
}
];
};
};
};
}

View File

@@ -0,0 +1,295 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.cryptpad;
inherit (lib)
mkIf
mkMerge
mkOption
strings
types
;
# The Cryptpad configuration file isn't JSON, but a JavaScript source file that assigns a JSON value
# to a variable.
cryptpadConfigFile = builtins.toFile "cryptpad_config.js" ''
module.exports = ${builtins.toJSON cfg.settings}
'';
# Derive domain names for Nginx configuration from Cryptpad configuration
mainDomain = strings.removePrefix "https://" cfg.settings.httpUnsafeOrigin;
sandboxDomain =
if cfg.settings.httpSafeOrigin == null then
mainDomain
else
strings.removePrefix "https://" cfg.settings.httpSafeOrigin;
in
{
options.services.cryptpad = {
enable = lib.mkEnableOption "cryptpad";
package = lib.mkPackageOption pkgs "cryptpad" { };
configureNginx = mkOption {
description = ''
Configure Nginx as a reverse proxy for Cryptpad.
Note that this makes some assumptions on your setup, and sets settings that will
affect other virtualHosts running on your Nginx instance, if any.
Alternatively you can configure a reverse-proxy of your choice.
'';
type = types.bool;
default = false;
};
settings = mkOption {
description = ''
Cryptpad configuration settings.
See <https://github.com/cryptpad/cryptpad/blob/main/config/config.example.js> for a more extensive
reference documentation.
Test your deployed instance through `https://<domain>/checkup/`.
'';
type = types.submodule {
freeformType = (pkgs.formats.json { }).type;
options = {
httpUnsafeOrigin = mkOption {
type = types.str;
example = "https://cryptpad.example.com";
default = "";
description = "This is the URL that users will enter to load your instance";
};
httpSafeOrigin = mkOption {
type = types.nullOr types.str;
example = "https://cryptpad-ui.example.com. Apparently optional but recommended.";
description = "Cryptpad sandbox URL";
};
httpAddress = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address on which the Node.js server should listen";
};
httpPort = mkOption {
type = types.port;
default = 3000;
description = "Port on which the Node.js server should listen";
};
websocketPort = mkOption {
type = types.port;
default = 3003;
description = "Port for the websocket that needs to be separate";
};
maxWorkers = mkOption {
type = types.nullOr types.int;
default = null;
description = "Number of child processes, defaults to number of cores available";
};
adminKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = "List of public signing keys of users that can access the admin panel";
example = [ "[cryptpad-user1@my.awesome.website/YZgXQxKR0Rcb6r6CmxHPdAGLVludrAF2lEnkbx1vVOo=]" ];
};
logToStdout = mkOption {
type = types.bool;
default = true;
description = "Controls whether log output should go to stdout of the systemd service";
};
logLevel = mkOption {
type = types.str;
default = "info";
description = "Controls log level";
};
blockDailyCheck = mkOption {
type = types.bool;
default = true;
description = ''
Disable telemetry. This setting is only effective if the 'Disable server telemetry'
setting in the admin menu has been untouched, and will be ignored by cryptpad once
that option is set either way.
Note that due to the service confinement, just enabling the option in the admin
menu will not be able to resolve DNS and fail; this setting must be set as well.
'';
};
installMethod = mkOption {
type = types.str;
default = "nixos";
description = ''
Install method is listed in telemetry if you agree to it through the consentToContact
setting in the admin panel.
'';
};
};
};
};
};
config = mkIf cfg.enable (mkMerge [
{
systemd.services.cryptpad = {
description = "Cryptpad service";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
serviceConfig = {
BindReadOnlyPaths = [
cryptpadConfigFile
# apparently needs proc for workers management
"/proc"
"/dev/urandom"
];
DynamicUser = true;
Environment = [
"CRYPTPAD_CONFIG=${cryptpadConfigFile}"
"HOME=%S/cryptpad"
];
ExecStart = lib.getExe cfg.package;
Restart = "always";
StateDirectory = "cryptpad";
WorkingDirectory = "%S/cryptpad";
# security way too many numerous options, from the systemd-analyze security output
# at end of test: block everything except
# - SystemCallFiters=@resources is required for node
# - MemoryDenyWriteExecute for node JIT
# - RestrictAddressFamilies=~AF_(INET|INET6) / PrivateNetwork to bind to sockets
# - IPAddressDeny likewise allow localhost if binding to localhost or any otherwise
# - PrivateUsers somehow service doesn't start with that
# - DeviceAllow (char-rtc r added by ProtectClock)
AmbientCapabilities = "";
CapabilityBoundingSet = "";
DeviceAllow = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RuntimeDirectoryMode = "700";
SocketBindAllow = [
"tcp:${builtins.toString cfg.settings.httpPort}"
"tcp:${builtins.toString cfg.settings.websocketPort}"
];
SocketBindDeny = [ "any" ];
StateDirectoryMode = "0700";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@pkey"
"@system-service"
# /!\ order matters: @privileged contains @chown, so we need
# @privileged negated before we re-list @chown for libuv copy
"~@privileged"
"~@chown:EPERM"
"~@keyring"
"~@memlock"
"~@resources"
"~@setuid"
"~@timer"
];
UMask = "0077";
};
confinement = {
enable = true;
binSh = null;
mode = "chroot-only";
};
};
}
# block external network access if not phoning home and
# binding to localhost (default)
(mkIf
(
cfg.settings.blockDailyCheck
&& (builtins.elem cfg.settings.httpAddress [
"127.0.0.1"
"::1"
])
)
{
systemd.services.cryptpad = {
serviceConfig = {
IPAddressAllow = [ "localhost" ];
IPAddressDeny = [ "any" ];
};
};
}
)
# .. conversely allow DNS & TLS if telemetry is explicitly enabled
(mkIf (!cfg.settings.blockDailyCheck) {
systemd.services.cryptpad = {
serviceConfig = {
BindReadOnlyPaths = [
"-/etc/resolv.conf"
"-/run/systemd"
"/etc/hosts"
"${config.security.pki.caBundle}:/etc/ssl/certs/ca-certificates.crt"
];
};
};
})
(mkIf cfg.configureNginx {
assertions = [
{
assertion = cfg.settings.httpUnsafeOrigin != "";
message = "services.cryptpad.settings.httpUnsafeOrigin is required";
}
{
assertion = strings.hasPrefix "https://" cfg.settings.httpUnsafeOrigin;
message = "services.cryptpad.settings.httpUnsafeOrigin must start with https://";
}
{
assertion =
cfg.settings.httpSafeOrigin == null || strings.hasPrefix "https://" cfg.settings.httpSafeOrigin;
message = "services.cryptpad.settings.httpSafeOrigin must start with https:// (or be unset)";
}
];
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
virtualHosts = mkMerge [
{
"${mainDomain}" = {
serverAliases = lib.optionals (cfg.settings.httpSafeOrigin != null) [ sandboxDomain ];
enableACME = lib.mkDefault true;
forceSSL = true;
locations."/" = {
proxyPass = "http://${cfg.settings.httpAddress}:${builtins.toString cfg.settings.httpPort}";
extraConfig = ''
client_max_body_size 150m;
'';
};
locations."/cryptpad_websocket" = {
proxyPass = "http://${cfg.settings.httpAddress}:${builtins.toString cfg.settings.websocketPort}";
proxyWebsockets = true;
};
};
}
];
};
})
]);
}

View File

@@ -0,0 +1,173 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib.types) package str;
inherit (lib)
mkIf
mkOption
mkEnableOption
mkPackageOption
;
cfg = config.services.dashy;
in
{
options.services.dashy = {
enable = mkEnableOption ''
Dashy, a highly customizable, easy to use, privacy-respecting dashboard app.
Note that this builds a static web app as opposed to running a full node server, unlike the default docker image.
Writing config changes to disk through the UI, triggering a rebuild through the UI and application status checks are
unavailable without the node server; Everything else will work fine.
See the deployment docs for [building from source](https://dashy.to/docs/deployment#build-from-source), [hosting with a CDN](https://dashy.to/docs/deployment#hosting-with-cdn) and [CDN cloud deploy](https://dashy.to/docs/deployment#cdn--cloud-deploy) for more information.
'';
virtualHost = {
enableNginx = mkEnableOption "a virtualhost to serve dashy through nginx";
domain = mkOption {
description = ''
Domain to use for the virtual host.
This can be used to change nginx options like
```nix
services.nginx.virtualHosts."$\{config.services.dashy.virtualHost.domain}".listen = [ ... ]
```
or
```nix
services.nginx.virtualHosts."example.com".listen = [ ... ]
```
'';
type = str;
};
};
package = mkPackageOption pkgs "dashy-ui" { };
finalDrv = mkOption {
readOnly = true;
default =
if cfg.settings != { } then cfg.package.override { inherit (cfg) settings; } else cfg.package;
defaultText = ''
if cfg.settings != {}
then cfg.package.override {inherit (cfg) settings;}
else cfg.package;
'';
type = package;
description = ''
Final derivation containing the fully built static files
'';
};
settings = mkOption {
default = { };
description = ''
Settings serialized into `user-data/conf.yml` before build.
If left empty, the default configuration shipped with the package will be used instead.
Note that the full configuration will be written to the nix store as world readable, which may include secrets such as [password hashes](https://dashy.to/docs/configuring#appconfigauthusers-optional).
To add files such as icons or backgrounds, you can reference them in line such as
```nix
icon = "$\{./icon.png}";
```
This will add the file to the nix store upon build, referencing it by file path as expected by Dashy.
'';
example = ''
{
appConfig = {
cssThemes = [
"example-theme-1"
"example-theme-2"
];
enableFontAwesome = true;
fontAwesomeKey = "e9076c7025";
theme = "thebe";
};
pageInfo = {
description = "My Awesome Dashboard";
navLinks = [
{
path = "/";
title = "Home";
}
{
path = "https://example.com";
title = "Example 1";
}
{
path = "https://example.com";
title = "Example 2";
}
];
title = "Dashy";
};
sections = [
{
displayData = {
collapsed = true;
cols = 2;
customStyles = "border: 2px dashed red;";
itemSize = "large";
};
items = [
{
backgroundColor = "#0079ff";
color = "#00ffc9";
description = "Source code and documentation on GitHub";
icon = "fab fa-github";
target = "sametab";
title = "Source";
url = "https://github.com/Lissy93/dashy";
}
{
description = "View currently open issues, or raise a new one";
icon = "fas fa-bug";
title = "Issues";
url = "https://github.com/Lissy93/dashy/issues";
}
{
description = "Live Demo #1";
icon = "fas fa-rocket";
target = "iframe";
title = "Demo 1";
url = "https://dashy-demo-1.as93.net";
}
{
description = "Live Demo #2";
icon = "favicon";
target = "newtab";
title = "Demo 2";
url = "https://dashy-demo-2.as93.net";
}
];
name = "Getting Started";
}
];
}
'';
inherit (pkgs.formats.json { }) type;
};
};
config = mkIf cfg.enable {
services.nginx = mkIf cfg.virtualHost.enableNginx {
enable = true;
virtualHosts."${cfg.virtualHost.domain}" = {
locations."/" = {
root = cfg.finalDrv;
tryFiles = "$uri /index.html ";
};
};
};
};
meta.maintainers = [
lib.maintainers.therealgramdalf
];
}

View File

@@ -0,0 +1,33 @@
# Davis {#module-services-davis}
[Davis](https://github.com/tchapi/davis/) is a caldav and carrddav server. It
has a simple, fully translatable admin interface for sabre/dav based on Symfony
5 and Bootstrap 5, initially inspired by Baïkal.
## Basic Usage {#module-services-davis-basic-usage}
At first, an application secret is needed, this can be generated with:
```ShellSession
$ cat /dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1
```
After that, `davis` can be deployed like this:
```
{
services.davis = {
enable = true;
hostname = "davis.example.com";
mail = {
dsn = "smtp://username@example.com:25";
inviteFromAddress = "davis@example.com";
};
adminLogin = "admin";
adminPasswordFile = "/run/secrets/davis-admin-password";
appSecretFile = "/run/secrets/davis-app-secret";
};
}
```
This deploys Davis using a sqlite database running out of `/var/lib/davis`.
Logs can be found in `/var/lib/davis/var/log/`.

View File

@@ -0,0 +1,563 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.davis;
db = cfg.database;
mail = cfg.mail;
mysqlLocal = db.createLocally && db.driver == "mysql";
pgsqlLocal = db.createLocally && db.driver == "postgresql";
user = cfg.user;
group = cfg.group;
isSecret = v: lib.isAttrs v && v ? _secret && (lib.isString v._secret || builtins.isPath v._secret);
davisEnvVars = lib.generators.toKeyValue {
mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
mkValueString =
v:
if builtins.isInt v then
toString v
else if lib.isString v then
"\"${v}\""
else if true == v then
"true"
else if false == v then
"false"
else if null == v then
""
else if isSecret v then
if (lib.isString v._secret) then
builtins.hashString "sha256" v._secret
else
builtins.hashString "sha256" (builtins.readFile v._secret)
else
throw "unsupported type ${builtins.typeOf v}: ${(lib.generators.toPretty { }) v}";
};
};
secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
mkSecretReplacement = file: ''
replace-secret ${
lib.escapeShellArgs [
(
if (lib.isString file) then
builtins.hashString "sha256" file
else
builtins.hashString "sha256" (builtins.readFile file)
)
file
"${cfg.dataDir}/.env.local"
]
}
'';
secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
filteredConfig = lib.converge (lib.filterAttrsRecursive (
_: v:
!lib.elem v [
{ }
null
]
)) cfg.config;
davisEnv = pkgs.writeText "davis.env" (davisEnvVars filteredConfig);
in
{
options.services.davis = {
enable = lib.mkEnableOption "Davis is a caldav and carddav server";
user = lib.mkOption {
default = "davis";
description = "User davis runs as.";
type = lib.types.str;
};
group = lib.mkOption {
default = "davis";
description = "Group davis runs as.";
type = lib.types.str;
};
package = lib.mkPackageOption pkgs "davis" { };
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/davis";
description = ''
Davis data directory.
'';
};
hostname = lib.mkOption {
type = lib.types.str;
example = "davis.yourdomain.org";
description = ''
Domain of the host to serve davis under. You may want to change it if you
run Davis on a different URL than davis.yourdomain.
'';
};
config = lib.mkOption {
type = lib.types.attrsOf (
lib.types.nullOr (
lib.types.either
(lib.types.oneOf [
lib.types.bool
lib.types.int
lib.types.port
lib.types.path
lib.types.str
])
(
lib.types.submodule {
options = {
_secret = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.str
lib.types.path
]
);
description = ''
The path to a file containing the value the
option should be set to in the final
configuration file.
'';
};
};
}
)
)
);
default = { };
example = '''';
description = '''';
};
adminLogin = lib.mkOption {
type = lib.types.str;
default = "root";
description = ''
Username for the admin account.
'';
};
adminPasswordFile = lib.mkOption {
type = lib.types.path;
description = ''
The full path to a file that contains the admin's password. Must be
readable by the user.
'';
example = "/run/secrets/davis-admin-pass";
};
appSecretFile = lib.mkOption {
type = lib.types.path;
description = ''
A file containing the Symfony APP_SECRET - Its value should be a series
of characters, numbers and symbols chosen randomly and the recommended
length is around 32 characters. Can be generated with <code>cat
/dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1</code>.
'';
example = "/run/secrets/davis-appsecret";
};
database = {
driver = lib.mkOption {
type = lib.types.enum [
"sqlite"
"postgresql"
"mysql"
];
default = "sqlite";
description = "Database type, required in all circumstances.";
};
urlFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/davis-db-url";
description = ''
A file containing the database connection url. If set then it
overrides all other database settings (except driver). This is
mandatory if you want to use an external database, that is when
`services.davis.database.createLocally` is `false`.
'';
};
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "davis";
description = "Database name, only used when the databse is created locally.";
};
createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Create the database and database user locally.";
};
};
mail = {
dsn = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Mail DSN for sending emails. Mutually exclusive with `services.davis.mail.dsnFile`.";
example = "smtp://username:password@example.com:25";
};
dsnFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/run/secrets/davis-mail-dsn";
description = "A file containing the mail DSN for sending emails. Mutually exclusive with `servies.davis.mail.dsn`.";
};
inviteFromAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Email address to send invitations from.";
example = "no-reply@dav.example.com";
};
};
nginx = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
}
)
);
default = { };
example = ''
{
serverAliases = [
"dav.''${config.networking.domain}"
];
# To enable encryption and let let's encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
description = ''
Use this option to customize an nginx virtual host. To disable the nginx set this to null.
'';
};
poolConfig = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for the davis PHP pool. See the documentation on <literal>php-fpm.conf</literal>
for details on configuration directives.
'';
};
};
config =
let
defaultServiceConfig = {
ReadWritePaths = "${cfg.dataDir}";
User = user;
UMask = 77;
DeviceAllow = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@resources"
"~@privileged"
];
WorkingDirectory = "${cfg.package}/";
};
in
lib.mkIf cfg.enable {
assertions = [
{
assertion = db.createLocally -> db.urlFile == null;
message = "services.davis.database.urlFile must be unset if services.davis.database.createLocally is set true.";
}
{
assertion = db.createLocally || db.urlFile != null;
message = "One of services.davis.database.urlFile or services.davis.database.createLocally must be set.";
}
{
assertion = !(mail.dsn != null && mail.dsnFile != null);
message = "services.davis.mail.dsn and services.davis.mail.dsnFile cannot both be set.";
}
];
services.davis.config = {
APP_ENV = "prod";
APP_CACHE_DIR = "${cfg.dataDir}/var/cache";
APP_LOG_DIR = "${cfg.dataDir}/var/log";
LOG_FILE_PATH = "%kernel.logs_dir%/%kernel.environment%.log";
DATABASE_DRIVER = db.driver;
INVITE_FROM_ADDRESS = mail.inviteFromAddress;
APP_SECRET._secret = cfg.appSecretFile;
ADMIN_LOGIN = cfg.adminLogin;
ADMIN_PASSWORD._secret = cfg.adminPasswordFile;
APP_TIMEZONE = config.time.timeZone;
WEBDAV_ENABLED = false;
CALDAV_ENABLED = true;
CARDDAV_ENABLED = true;
}
// (
if mail.dsn != null then
{ MAILER_DSN = mail.dsn; }
else if mail.dsnFile != null then
{ MAILER_DSN._secret = mail.dsnFile; }
else
{ }
)
// (
if db.createLocally then
{
DATABASE_URL =
if db.driver == "sqlite" then
"sqlite:///${cfg.dataDir}/davis.db" # note: sqlite needs 4 slashes for an absolute path
else if
pgsqlLocal
# note: davis expects a non-standard postgres uri (due to the underlying doctrine library)
# specifically the dummy hostname which is overridden by the host query parameter
then
"postgres://${user}@localhost/${db.name}?host=/run/postgresql"
else if mysqlLocal then
"mysql://${user}@localhost/${db.name}?socket=/run/mysqld/mysqld.sock"
else
null;
}
else
{ DATABASE_URL._secret = db.urlFile; }
);
users = {
users = lib.mkIf (user == "davis") {
davis = {
description = "Davis service user";
group = cfg.group;
isSystemUser = true;
home = cfg.dataDir;
};
};
groups = lib.mkIf (group == "davis") { davis = { }; };
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0710 ${user} ${group} - -"
"d ${cfg.dataDir}/var 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/var/log 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/var/cache 0700 ${user} ${group} - -"
];
services.phpfpm.pools.davis = {
inherit user group;
phpOptions = ''
log_errors = on
'';
phpEnv = {
ENV_DIR = "${cfg.dataDir}";
APP_CACHE_DIR = "${cfg.dataDir}/var/cache";
APP_LOG_DIR = "${cfg.dataDir}/var/log";
};
phpPackage = lib.mkDefault cfg.package.passthru.php;
settings = {
"listen.mode" = "0660";
"pm" = "dynamic";
"pm.max_children" = 256;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
}
// (
if cfg.nginx != null then
{
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
else
{ }
)
// cfg.poolConfig;
};
# Reading the user-provided secret files requires root access
systemd.services.davis-env-setup = {
description = "Setup davis environment";
before = [
"phpfpm-davis.service"
"davis-db-migrate.service"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.replace-secret ];
restartTriggers = [
cfg.package
davisEnv
];
script = ''
# error handling
set -euo pipefail
# create .env file with the upstream values
install -T -m 0600 -o ${user} ${cfg.package}/env-upstream "${cfg.dataDir}/.env"
# create .env.local file with the user-provided values
install -T -m 0600 -o ${user} ${davisEnv} "${cfg.dataDir}/.env.local"
${secretReplacements}
'';
};
systemd.services.davis-db-migrate = {
description = "Migrate davis database";
before = [ "phpfpm-davis.service" ];
after =
lib.optional mysqlLocal "mysql.service"
++ lib.optional pgsqlLocal "postgresql.target"
++ [ "davis-env-setup.service" ];
requires =
lib.optional mysqlLocal "mysql.service"
++ lib.optional pgsqlLocal "postgresql.target"
++ [ "davis-env-setup.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = defaultServiceConfig // {
Type = "oneshot";
RemainAfterExit = true;
Environment = [
"ENV_DIR=${cfg.dataDir}"
"APP_CACHE_DIR=${cfg.dataDir}/var/cache"
"APP_LOG_DIR=${cfg.dataDir}/var/log"
];
EnvironmentFile = "${cfg.dataDir}/.env.local";
};
restartTriggers = [
cfg.package
davisEnv
];
script = ''
set -euo pipefail
${cfg.package}/bin/console cache:clear --no-debug
${cfg.package}/bin/console cache:warmup --no-debug
${cfg.package}/bin/console doctrine:migrations:migrate
'';
};
systemd.services.phpfpm-davis.after = [
"davis-env-setup.service"
"davis-db-migrate.service"
];
systemd.services.phpfpm-davis.requires = [
"davis-env-setup.service"
"davis-db-migrate.service"
]
++ lib.optional mysqlLocal "mysql.service"
++ lib.optional pgsqlLocal "postgresql.target";
systemd.services.phpfpm-davis.serviceConfig.ReadWritePaths = [ cfg.dataDir ];
services.nginx = lib.mkIf (cfg.nginx != null) {
enable = lib.mkDefault true;
virtualHosts = {
"${cfg.hostname}" = lib.mkMerge [
cfg.nginx
{
root = lib.mkForce "${cfg.package}/public";
extraConfig = ''
charset utf-8;
index index.php;
'';
locations = {
"/" = {
extraConfig = ''
try_files $uri $uri/ /index.php$is_args$args;
'';
};
"~* ^/.well-known/(caldav|carddav)$" = {
extraConfig = ''
return 302 https://$host/dav/;
'';
};
"~ ^(.+\\.php)(.*)$" = {
extraConfig = ''
try_files $fastcgi_script_name =404;
include ${config.services.nginx.package}/conf/fastcgi_params;
include ${config.services.nginx.package}/conf/fastcgi.conf;
fastcgi_pass unix:${config.services.phpfpm.pools.davis.socket};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_split_path_info ^(.+\.php)(.*)$;
fastcgi_param X-Forwarded-Proto https;
fastcgi_param X-Forwarded-Port $http_x_forwarded_port;
'';
};
"~ /(\\.ht)" = {
extraConfig = ''
deny all;
return 404;
'';
};
};
}
];
};
};
services.mysql = lib.mkIf mysqlLocal {
enable = true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ db.name ];
ensureUsers = [
{
name = user;
ensurePermissions = {
"${db.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.postgresql = lib.mkIf pgsqlLocal {
enable = true;
ensureDatabases = [ db.name ];
ensureUsers = [
{
name = user;
ensureDBOwnership = true;
}
];
};
};
meta = {
doc = ./davis.md;
maintainers = pkgs.davis.meta.maintainers;
};
}

View File

@@ -0,0 +1,634 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.dependency-track;
settingsFormat = pkgs.formats.javaProperties { };
frontendConfigFormat = pkgs.formats.json { };
frontendConfigFile = frontendConfigFormat.generate "config.json" {
API_BASE_URL = cfg.frontend.baseUrl;
OIDC_ISSUER = cfg.oidc.issuer;
OIDC_CLIENT_ID = cfg.oidc.clientId;
OIDC_SCOPE = cfg.oidc.scope;
OIDC_FLOW = cfg.oidc.flow;
OIDC_LOGIN_BUTTON_TEXT = cfg.oidc.loginButtonText;
};
sslEnabled =
config.services.nginx.virtualHosts.${cfg.nginx.domain}.addSSL
|| config.services.nginx.virtualHosts.${cfg.nginx.domain}.forceSSL
|| config.services.nginx.virtualHosts.${cfg.nginx.domain}.onlySSL
|| config.services.nginx.virtualHosts.${cfg.nginx.domain}.enableACME;
assertStringPath =
optionName: value:
if lib.isPath value then
throw ''
services.dependency-track.${optionName}:
${toString value}
is a Nix path, but should be a string, since Nix
paths are copied into the world-readable Nix store.
''
else
value;
filterNull = lib.filterAttrs (_: v: v != null);
renderSettings =
settings:
lib.mapAttrs' (
n: v:
lib.nameValuePair (lib.toUpper (lib.replaceStrings [ "." ] [ "_" ] n)) (
if lib.isBool v then lib.boolToString v else v
)
) (filterNull settings);
in
{
options.services.dependency-track = {
enable = lib.mkEnableOption "dependency-track";
package = lib.mkPackageOption pkgs "dependency-track" { };
logLevel = lib.mkOption {
type = lib.types.enum [
"INFO"
"WARN"
"ERROR"
"DEBUG"
"TRACE"
];
default = "INFO";
description = "Log level for dependency-track";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = ''
On which port dependency-track should listen for new HTTP connections.
'';
};
javaArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = lib.literalExpression ''[ "-Xmx16G" ] '';
description = ''
Java options passed to JVM. Configuring this is usually not necessary, but for small systems
it can be useful to tweak the JVM heap size.
'';
};
database = {
type = lib.mkOption {
type = lib.types.enum [
"h2"
"postgresql"
"manual"
];
default = "postgresql";
description = ''
`h2` database is not recommended for a production setup.
`postgresql` this settings it recommended for production setups.
`manual` the module doesn't handle database settings.
'';
};
createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether a database should be automatically created on the
local host. Set this to false if you plan on provisioning a
local database yourself.
'';
};
databaseName = lib.mkOption {
type = lib.types.str;
default = "dependency-track";
description = ''
Database name to use when connecting to an external or
manually provisioned database; has no effect when a local
database is automatically provisioned.
To use this with a local database, set {option}`services.dependency-track.database.createLocally`
to `false` and create the database and user.
'';
};
username = lib.mkOption {
type = lib.types.str;
default = "dependency-track";
description = ''
Username to use when connecting to an external or manually
provisioned database; has no effect when a local database is
automatically provisioned.
To use this with a local database, set {option}`services.dependency-track.database.createLocally`
to `false` and create the database and user.
'';
};
passwordFile = lib.mkOption {
type = lib.types.path;
example = "/run/keys/db_password";
apply = assertStringPath "passwordFile";
description = ''
The path to a file containing the database password.
'';
};
};
ldap.bindPasswordFile = lib.mkOption {
type = lib.types.path;
example = "/run/keys/ldap_bind_password";
apply = assertStringPath "bindPasswordFile";
description = ''
The path to a file containing the LDAP bind password.
'';
};
frontend = {
baseUrl = lib.mkOption {
type = lib.types.str;
default = lib.optionalString cfg.nginx.enable "${
if sslEnabled then "https" else "http"
}://${cfg.nginx.domain}";
defaultText = lib.literalExpression ''
lib.optionalString config.services.dependency-track.nginx.enable "''${
if sslEnabled then "https" else "http"
}://''${config.services.dependency-track.nginx.domain}";
'';
description = ''
The base URL of the API server.
NOTE:
* This URL must be reachable by the browsers of your users.
* The frontend container itself does NOT communicate with the API server directly, it just serves static files.
* When deploying to dedicated servers, please use the external IP or domain of the API server.
'';
};
};
oidc = {
enable = lib.mkEnableOption "oidc support";
issuer = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
Defines the issuer URL to be used for OpenID Connect.
See alpine.oidc.issuer property of the API server.
'';
};
clientId = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
Defines the client ID for OpenID Connect.
'';
};
scope = lib.mkOption {
type = lib.types.str;
default = "openid profile email";
description = ''
Defines the scopes to request for OpenID Connect.
See also: <https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
'';
};
flow = lib.mkOption {
type = lib.types.enum [
"code"
"implicit"
];
default = "code";
description = ''
Specifies the OpenID Connect flow to use.
Values other than "implicit" will result in the Code+PKCE flow to be used.
Usage of the implicit flow is strongly discouraged, but may be necessary when
the IdP of choice does not support the Code+PKCE flow.
See also:
- <https://oauth.net/2/grant-types/implicit/>
- <https://oauth.net/2/pkce/>
'';
};
loginButtonText = lib.mkOption {
type = lib.types.str;
default = "Login with OpenID Connect";
description = ''
Defines the scopes to request for OpenID Connect.
See also: <https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
'';
};
usernameClaim = lib.mkOption {
type = lib.types.str;
default = "name";
example = "preferred_username";
description = ''
Defines the name of the claim that contains the username in the provider's userinfo endpoint.
Common claims are "name", "username", "preferred_username" or "nickname".
See also: <https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse>
'';
};
userProvisioning = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Specifies if mapped OpenID Connect accounts are automatically created upon successful
authentication. When a user logs in with a valid access token but an account has
not been previously provisioned, an authentication failure will be returned.
This allows admins to control specifically which OpenID Connect users can access the
system and which users cannot. When this value is set to true, a local OpenID Connect
user will be created and mapped to the OpenID Connect account automatically. This
automatic provisioning only affects authentication, not authorization.
'';
};
teamSynchronization = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
This option will ensure that team memberships for OpenID Connect users are dynamic and
synchronized with membership of OpenID Connect groups or assigned roles. When a team is
mapped to an OpenID Connect group, all local OpenID Connect users will automatically be
assigned to the team if they are a member of the group the team is mapped to. If the user
is later removed from the OpenID Connect group, they will also be removed from the team. This
option provides the ability to dynamically control user permissions via the identity provider.
Note that team synchronization is only performed during user provisioning and after successful
authentication.
'';
};
teams = {
claim = lib.mkOption {
type = lib.types.str;
default = "groups";
description = ''
Defines the name of the claim that contains group memberships or role assignments in the provider's userinfo endpoint.
The claim must be an array of strings. Most public identity providers do not support group or role management.
When using a customizable / on-demand hosted identity provider, name, content, and inclusion in the userinfo endpoint
will most likely need to be configured.
'';
};
default = lib.mkOption {
type = lib.types.nullOr lib.types.commas;
default = null;
description = ''
Defines one or more team names that auto-provisioned OIDC users shall be added to.
Multiple team names may be provided as comma-separated list.
Has no effect when {option}`services.dependency-track.oidc.userProvisioning`=false,
or {option}`services.dependency-track.oidc.teamSynchronization`=true.
'';
};
};
};
nginx = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Whether to set up an nginx virtual host.
'';
};
domain = lib.mkOption {
type = lib.types.str;
example = "dtrack.example.com";
description = ''
The domain name under which to set up the virtual host.
'';
};
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
options = {
"alpine.data.directory" = lib.mkOption {
type = lib.types.path;
default = "/var/lib/dependency-track";
description = ''
Defines the path to the data directory. This directory will hold logs, keys,
and any database or index files along with application-specific files or
directories.
'';
};
"alpine.database.mode" = lib.mkOption {
type = lib.types.enum [
"server"
"embedded"
"external"
];
default =
if cfg.database.type == "h2" then
"embedded"
else if cfg.database.type == "postgresql" then
"external"
else
null;
defaultText = lib.literalExpression ''
if config.services.dependency-track.database.type == "h2" then "embedded"
else if config.services.dependency-track.database.type == "postgresql" then "external"
else null
'';
description = ''
Defines the database mode of operation. Valid choices are:
'server', 'embedded', and 'external'.
In server mode, the database will listen for connections from remote hosts.
In embedded mode, the system will be more secure and slightly faster.
External mode should be used when utilizing an external database server
(i.e. mysql, postgresql, etc).
'';
};
"alpine.database.url" = lib.mkOption {
type = lib.types.str;
default =
if cfg.database.type == "h2" then
"jdbc:h2:/var/lib/dependency-track/db"
else if cfg.database.type == "postgresql" then
"jdbc:postgresql:${cfg.database.databaseName}?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/run/postgresql/.s.PGSQL.5432"
else
null;
defaultText = lib.literalExpression ''
if config.services.dependency-track.database.type == "h2" then "jdbc:h2:/var/lib/dependency-track/db"
else if config.services.dependency-track.database.type == "postgresql" then "jdbc:postgresql:''${config.services.dependency-track.database.name}?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/run/postgresql/.s.PGSQL.5432"
else null
'';
description = "Specifies the JDBC URL to use when connecting to the database.";
};
"alpine.database.driver" = lib.mkOption {
type = lib.types.enum [
"org.h2.Driver"
"org.postgresql.Driver"
"com.microsoft.sqlserver.jdbc.SQLServerDriver"
"com.mysql.cj.jdbc.Driver"
];
default =
if cfg.database.type == "h2" then
"org.h2.Driver"
else if cfg.database.type == "postgresql" then
"org.postgresql.Driver"
else
null;
defaultText = lib.literalExpression ''
if config.services.dependency-track.database.type == "h2" then "org.h2.Driver"
else if config.services.dependency-track.database.type == "postgresql" then "org.postgresql.Driver"
else null;
'';
description = "Specifies the JDBC driver class to use.";
};
"alpine.database.username" = lib.mkOption {
type = lib.types.str;
default = if cfg.database.createLocally then "dependency-track" else cfg.database.username;
defaultText = lib.literalExpression ''
if config.services.dependency-track.database.createLocally then "dependency-track"
else config.services.dependency-track.database.username
'';
description = "Specifies the username to use when authenticating to the database.";
};
"alpine.ldap.enabled" = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Defines if LDAP will be used for user authentication. If enabled,
alpine.ldap.* properties should be set accordingly.
'';
};
"alpine.oidc.enabled" = lib.mkOption {
type = lib.types.bool;
default = cfg.oidc.enable;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.enable";
description = ''
Defines if OpenID Connect will be used for user authentication.
If enabled, alpine.oidc.* properties should be set accordingly.
'';
};
"alpine.oidc.client.id" = lib.mkOption {
type = lib.types.str;
default = cfg.oidc.clientId;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.clientId";
description = ''
Defines the client ID to be used for OpenID Connect.
The client ID should be the same as the one configured for the frontend,
and will only be used to validate ID tokens.
'';
};
"alpine.oidc.issuer" = lib.mkOption {
type = lib.types.str;
default = cfg.oidc.issuer;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.issuer";
description = ''
Defines the issuer URL to be used for OpenID Connect.
This issuer MUST support provider configuration via the /.well-known/openid-configuration endpoint.
See also:
- <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>
- <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig>
'';
};
"alpine.oidc.username.claim" = lib.mkOption {
type = lib.types.str;
default = cfg.oidc.usernameClaim;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.usernameClaim";
description = ''
Defines the name of the claim that contains the username in the provider's userinfo endpoint.
Common claims are "name", "username", "preferred_username" or "nickname".
See also: <https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse>
'';
};
"alpine.oidc.user.provisioning" = lib.mkOption {
type = lib.types.bool;
default = cfg.oidc.userProvisioning;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.userProvisioning";
description = ''
Specifies if mapped OpenID Connect accounts are automatically created upon successful
authentication. When a user logs in with a valid access token but an account has
not been previously provisioned, an authentication failure will be returned.
This allows admins to control specifically which OpenID Connect users can access the
system and which users cannot. When this value is set to true, a local OpenID Connect
user will be created and mapped to the OpenID Connect account automatically. This
automatic provisioning only affects authentication, not authorization.
'';
};
"alpine.oidc.team.synchronization" = lib.mkOption {
type = lib.types.bool;
default = cfg.oidc.teamSynchronization;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.teamSynchronization";
description = ''
This option will ensure that team memberships for OpenID Connect users are dynamic and
synchronized with membership of OpenID Connect groups or assigned roles. When a team is
mapped to an OpenID Connect group, all local OpenID Connect users will automatically be
assigned to the team if they are a member of the group the team is mapped to. If the user
is later removed from the OpenID Connect group, they will also be removed from the team. This
option provides the ability to dynamically control user permissions via the identity provider.
Note that team synchronization is only performed during user provisioning and after successful
authentication.
'';
};
"alpine.oidc.teams.claim" = lib.mkOption {
type = lib.types.str;
default = cfg.oidc.teams.claim;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.teams.claim";
description = ''
Defines the name of the claim that contains group memberships or role assignments in the provider's userinfo endpoint.
The claim must be an array of strings. Most public identity providers do not support group or role management.
When using a customizable / on-demand hosted identity provider, name, content, and inclusion in the userinfo endpoint
will most likely need to be configured.
'';
};
"alpine.oidc.teams.default" = lib.mkOption {
type = lib.types.nullOr lib.types.commas;
default = cfg.oidc.teams.default;
defaultText = lib.literalExpression "config.services.dependency-track.oidc.teams.default";
description = ''
Defines one or more team names that auto-provisioned OIDC users shall be added to.
Multiple team names may be provided as comma-separated list.
Has no effect when {option}`services.dependency-track.oidc.userProvisioning`=false,
or {option}`services.dependency-track.oidc.teamSynchronization`=true.
'';
};
};
};
default = { };
description = "See <https://docs.dependencytrack.org/getting-started/configuration/#default-configuration> for possible options";
};
};
config = lib.mkIf cfg.enable {
services.nginx = lib.mkIf cfg.nginx.enable {
enable = true;
recommendedGzipSettings = lib.mkDefault true;
recommendedOptimisation = lib.mkDefault true;
recommendedProxySettings = lib.mkDefault true;
recommendedTlsSettings = lib.mkDefault true;
upstreams.dependency-track.servers."localhost:${toString cfg.port}" = { };
virtualHosts.${cfg.nginx.domain} = {
locations = {
"/" = {
alias = "${cfg.package.frontend}/dist/";
index = "index.html";
tryFiles = "$uri $uri/ /index.html";
extraConfig = ''
location ~ (index\.html)$ {
add_header Cache-Control "max-age=0, no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
'';
};
"/api".proxyPass = "http://dependency-track";
"= /static/config.json" = {
alias = frontendConfigFile;
extraConfig = ''
add_header Cache-Control "max-age=0, no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
'';
};
};
};
};
systemd.services.dependency-track-postgresql-init = lib.mkIf cfg.database.createLocally {
after = [ "postgresql.target" ];
before = [ "dependency-track.service" ];
bindsTo = [ "postgresql.target" ];
path = [ config.services.postgresql.package ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
Group = "postgres";
LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
PrivateTmp = true;
};
script = ''
set -eou pipefail
shopt -s inherit_errexit
# Read the password from the credentials directory and
# escape any single quotes by adding additional single
# quotes after them, following the rules laid out here:
# https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
db_password="''${db_password//\'/\'\'}"
echo "CREATE ROLE \"dependency-track\" WITH LOGIN PASSWORD '$db_password' CREATEDB" > /tmp/create_role.sql
psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='dependency-track'" | grep -q 1 || psql -tA --file="/tmp/create_role.sql"
psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'dependency-track'" | grep -q 1 || psql -tAc 'CREATE DATABASE "dependency-track" OWNER "dependency-track"'
'';
};
services.postgresql.enable = lib.mkIf cfg.database.createLocally (lib.mkDefault true);
systemd.services."dependency-track" =
let
databaseServices =
if cfg.database.createLocally then
[
"dependency-track-postgresql-init.service"
"postgresql.target"
]
else
[ ];
in
{
description = "Dependency Track";
wantedBy = [ "multi-user.target" ];
requires = databaseServices;
after = databaseServices;
# provide settings via env vars to allow overriding default settings.
environment = {
HOME = "%S/dependency-track";
}
// renderSettings cfg.settings;
serviceConfig = {
User = "dependency-track";
Group = "dependency-track";
DynamicUser = true;
StateDirectory = "dependency-track";
LoadCredential = [
"db_password:${cfg.database.passwordFile}"
]
++
lib.optional cfg.settings."alpine.ldap.enabled"
"ldap_bind_password:${cfg.ldap.bindPasswordFile}";
};
script = ''
set -eou pipefail
shopt -s inherit_errexit
export ALPINE_DATABASE_PASSWORD_FILE="$CREDENTIALS_DIRECTORY/db_password"
${lib.optionalString cfg.settings."alpine.ldap.enabled" ''
export ALPINE_LDAP_BIND_PASSWORD="$(<"$CREDENTIALS_DIRECTORY/ldap_bind_password")"
''}
exec ${lib.getExe pkgs.jre_headless} ${
lib.escapeShellArgs (
cfg.javaArgs
++ [
"-DdependencyTrack.logging.level=${cfg.logLevel}"
"-jar"
"${cfg.package}/share/dependency-track/dependency-track.jar"
"-port"
"${toString cfg.port}"
]
)
}
'';
};
};
meta = {
maintainers = lib.teams.cyberus.members;
};
}

View File

@@ -0,0 +1,170 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.dex;
fixClient =
client:
if client ? secretFile then
(
(builtins.removeAttrs client [ "secretFile" ])
// {
secret = client.secretFile;
}
)
else
client;
filteredSettings = mapAttrs (
n: v: if n == "staticClients" then (builtins.map fixClient v) else v
) cfg.settings;
secretFiles = flatten (
builtins.map (c: optional (c ? secretFile) c.secretFile) (cfg.settings.staticClients or [ ])
);
settingsFormat = pkgs.formats.yaml { };
configFile = settingsFormat.generate "config.yaml" filteredSettings;
startPreScript = pkgs.writeShellScript "dex-start-pre" (
concatStringsSep "\n" (
map (file: ''
replace-secret '${file}' '${file}' /run/dex/config.yaml
'') secretFiles
)
);
restartTriggers =
[ ]
++ (optionals (cfg.environmentFile != null) [ cfg.environmentFile ])
++ (filter (file: builtins.typeOf file == "path") secretFiles);
in
{
options.services.dex = {
enable = mkEnableOption "the OpenID Connect and OAuth2 identity provider";
package = mkPackageOption pkgs "dex-oidc" { };
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Environment file (see {manpage}`systemd.exec(5)`
"EnvironmentFile=" section for the syntax) to define variables for dex.
This option can be used to safely include secret keys into the dex configuration.
'';
};
settings = mkOption {
type = settingsFormat.type;
default = { };
example = literalExpression ''
{
# External url
issuer = "http://127.0.0.1:5556/dex";
storage = {
type = "postgres";
config.host = "/var/run/postgres";
};
web = {
http = "127.0.0.1:5556";
};
enablePasswordDB = true;
staticClients = [
{
id = "oidcclient";
name = "Client";
redirectURIs = [ "https://example.com/callback" ];
secretFile = "/etc/dex/oidcclient"; # The content of `secretFile` will be written into to the config as `secret`.
}
];
}
'';
description = ''
The available options can be found in
[the example configuration](https://github.com/dexidp/dex/blob/v${cfg.package.version}/config.yaml.dist).
It's also possible to refer to environment variables (defined in [services.dex.environmentFile](#opt-services.dex.environmentFile))
using the syntax `$VARIABLE_NAME`.
'';
};
};
config = mkIf cfg.enable {
systemd.services.dex = {
description = "dex identity provider";
wantedBy = [ "multi-user.target" ];
after = [
"networking.target"
]
++ (optional (cfg.settings.storage.type == "postgres") "postgresql.target");
path = with pkgs; [ replace-secret ];
restartTriggers = restartTriggers;
serviceConfig = {
ExecStart = "${cfg.package}/bin/dex serve /run/dex/config.yaml";
ExecStartPre = [
"${pkgs.coreutils}/bin/install -m 600 ${configFile} /run/dex/config.yaml"
"+${startPreScript}"
];
RuntimeDirectory = "dex";
BindReadOnlyPaths = [
"/nix/store"
"-/etc/dex"
"-/etc/hosts"
"-/etc/localtime"
"-/etc/nsswitch.conf"
"-/etc/resolv.conf"
"${config.security.pki.caBundle}:/etc/ssl/certs/ca-certificates.crt"
];
BindPaths = optional (cfg.settings.storage.type == "postgres") "/var/run/postgresql";
# ProtectClock= adds DeviceAllow=char-rtc r
DeviceAllow = "";
DynamicUser = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
# Port needs to be exposed to the host network
#PrivateNetwork = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectHome = true;
ProtectHostname = true;
ProtectSystem = "strict";
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @setuid @keyring"
];
UMask = "0066";
}
// optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
};
# uses attributes of the linked package
meta.buildDocsInSandbox = false;
}

View File

@@ -0,0 +1,296 @@
# Discourse {#module-services-discourse}
[Discourse](https://www.discourse.org/) is a
modern and open source discussion platform.
## Basic usage {#module-services-discourse-basic-usage}
A minimal configuration using Let's Encrypt for TLS certificates looks like this:
```nix
{
services.discourse = {
enable = true;
hostname = "discourse.example.com";
admin = {
email = "admin@example.com";
username = "admin";
fullName = "Administrator";
passwordFile = "/path/to/password_file";
};
secretKeyBaseFile = "/path/to/secret_key_base_file";
};
security.acme.email = "me@example.com";
security.acme.acceptTerms = true;
}
```
Provided a proper DNS setup, you'll be able to connect to the
instance at `discourse.example.com` and log in
using the credentials provided in
`services.discourse.admin`.
## Using a regular TLS certificate {#module-services-discourse-tls}
To set up TLS using a regular certificate and key on file, use
the [](#opt-services.discourse.sslCertificate)
and [](#opt-services.discourse.sslCertificateKey)
options:
```nix
{
services.discourse = {
enable = true;
hostname = "discourse.example.com";
sslCertificate = "/path/to/ssl_certificate";
sslCertificateKey = "/path/to/ssl_certificate_key";
admin = {
email = "admin@example.com";
username = "admin";
fullName = "Administrator";
passwordFile = "/path/to/password_file";
};
secretKeyBaseFile = "/path/to/secret_key_base_file";
};
}
```
## Database access {#module-services-discourse-database}
Discourse uses PostgreSQL to store most of its
data. A database will automatically be enabled and a database
and role created unless [](#opt-services.discourse.database.host) is changed from
its default of `null` or [](#opt-services.discourse.database.createLocally) is set
to `false`.
External database access can also be configured by setting
[](#opt-services.discourse.database.host),
[](#opt-services.discourse.database.username) and
[](#opt-services.discourse.database.passwordFile) as
appropriate. Note that you need to manually create a database
called `discourse` (or the name you chose in
[](#opt-services.discourse.database.name)) and
allow the configured database user full access to it.
## Email {#module-services-discourse-mail}
In addition to the basic setup, you'll want to configure an SMTP
server Discourse can use to send user
registration and password reset emails, among others. You can
also optionally let Discourse receive
email, which enables people to reply to threads and conversations
via email.
A basic setup which assumes you want to use your configured
[hostname](#opt-services.discourse.hostname) as
email domain can be done like this:
```nix
{
services.discourse = {
enable = true;
hostname = "discourse.example.com";
sslCertificate = "/path/to/ssl_certificate";
sslCertificateKey = "/path/to/ssl_certificate_key";
admin = {
email = "admin@example.com";
username = "admin";
fullName = "Administrator";
passwordFile = "/path/to/password_file";
};
mail.outgoing = {
serverAddress = "smtp.emailprovider.com";
port = 587;
username = "user@emailprovider.com";
passwordFile = "/path/to/smtp_password_file";
};
mail.incoming.enable = true;
secretKeyBaseFile = "/path/to/secret_key_base_file";
};
}
```
This assumes you have set up an MX record for the address you've
set in [hostname](#opt-services.discourse.hostname) and
requires proper SPF, DKIM and DMARC configuration to be done for
the domain you're sending from, in order for email to be reliably delivered.
If you want to use a different domain for your outgoing email
(for example `example.com` instead of
`discourse.example.com`) you should set
[](#opt-services.discourse.mail.notificationEmailAddress) and
[](#opt-services.discourse.mail.contactEmailAddress) manually.
::: {.note}
Setup of TLS for incoming email is currently only configured
automatically when a regular TLS certificate is used, i.e. when
[](#opt-services.discourse.sslCertificate) and
[](#opt-services.discourse.sslCertificateKey) are
set.
:::
## Additional settings {#module-services-discourse-settings}
Additional site settings and backend settings, for which no
explicit NixOS options are provided,
can be set in [](#opt-services.discourse.siteSettings) and
[](#opt-services.discourse.backendSettings) respectively.
### Site settings {#module-services-discourse-site-settings}
"Site settings" are the settings that can be
changed through the Discourse
UI. Their *default* values can be set using
[](#opt-services.discourse.siteSettings).
Settings are expressed as a Nix attribute set which matches the
structure of the configuration in
[config/site_settings.yml](https://github.com/discourse/discourse/blob/master/config/site_settings.yml).
To find a setting's path, you only need to care about the first
two levels; i.e. its category (e.g. `login`)
and name (e.g. `invite_only`).
Settings containing secret data should be set to an attribute
set containing the attribute `_secret` - a
string pointing to a file containing the value the option
should be set to. See the example.
### Backend settings {#module-services-discourse-backend-settings}
Settings are expressed as a Nix attribute set which matches the
structure of the configuration in
[config/discourse.conf](https://github.com/discourse/discourse/blob/stable/config/discourse_defaults.conf).
Empty parameters can be defined by setting them to
`null`.
### Example {#module-services-discourse-settings-example}
The following example sets the title and description of the
Discourse instance and enables
GitHub login in the site settings,
and changes a few request limits in the backend settings:
```nix
{
services.discourse = {
enable = true;
hostname = "discourse.example.com";
sslCertificate = "/path/to/ssl_certificate";
sslCertificateKey = "/path/to/ssl_certificate_key";
admin = {
email = "admin@example.com";
username = "admin";
fullName = "Administrator";
passwordFile = "/path/to/password_file";
};
mail.outgoing = {
serverAddress = "smtp.emailprovider.com";
port = 587;
username = "user@emailprovider.com";
passwordFile = "/path/to/smtp_password_file";
};
mail.incoming.enable = true;
siteSettings = {
required = {
title = "My Cats";
site_description = "Discuss My Cats (and be nice plz)";
};
login = {
enable_github_logins = true;
github_client_id = "a2f6dfe838cb3206ce20";
github_client_secret._secret = /run/keys/discourse_github_client_secret;
};
};
backendSettings = {
max_reqs_per_ip_per_minute = 300;
max_reqs_per_ip_per_10_seconds = 60;
max_asset_reqs_per_ip_per_10_seconds = 250;
max_reqs_per_ip_mode = "warn+block";
};
secretKeyBaseFile = "/path/to/secret_key_base_file";
};
}
```
In the resulting site settings file, the
`login.github_client_secret` key will be set
to the contents of the
{file}`/run/keys/discourse_github_client_secret`
file.
## Plugins {#module-services-discourse-plugins}
You can install Discourse plugins
using the [](#opt-services.discourse.plugins)
option. Pre-packaged plugins are provided in
`<your_discourse_package_here>.plugins`. If
you want the full suite of plugins provided through
`nixpkgs`, you can also set the [](#opt-services.discourse.package) option to
`pkgs.discourseAllPlugins`.
Plugins can be built with the
`<your_discourse_package_here>.mkDiscoursePlugin`
function. Normally, it should suffice to provide a
`name` and `src` attribute. If
the plugin has Ruby dependencies, however, they need to be
packaged in accordance with the [Developing with Ruby](https://nixos.org/manual/nixpkgs/stable/#developing-with-ruby)
section of the Nixpkgs manual and the
appropriate gem options set in `bundlerEnvArgs`
(normally `gemdir` is sufficient). A plugin's
Ruby dependencies are listed in its
{file}`plugin.rb` file as function calls to
`gem`. To construct the corresponding
{file}`Gemfile` manually, run {command}`bundle init`, then add the `gem` lines to it
verbatim.
Much of the packaging can be done automatically by the
{file}`nixpkgs/pkgs/servers/web-apps/discourse/update.py`
script - just add the plugin to the `plugins`
list in the `update_plugins` function and run
the script:
```bash
./update.py update-plugins
```
Some plugins provide [site settings](#module-services-discourse-site-settings).
Their defaults can be configured using [](#opt-services.discourse.siteSettings), just like
regular site settings. To find the names of these settings, look
in the `config/settings.yml` file of the plugin
repo.
For example, to add the [discourse-spoiler-alert](https://github.com/discourse/discourse-spoiler-alert)
and [discourse-solved](https://github.com/discourse/discourse-solved)
plugins, and disable `discourse-spoiler-alert`
by default:
```nix
{
services.discourse = {
enable = true;
hostname = "discourse.example.com";
sslCertificate = "/path/to/ssl_certificate";
sslCertificateKey = "/path/to/ssl_certificate_key";
admin = {
email = "admin@example.com";
username = "admin";
fullName = "Administrator";
passwordFile = "/path/to/password_file";
};
mail.outgoing = {
serverAddress = "smtp.emailprovider.com";
port = 587;
username = "user@emailprovider.com";
passwordFile = "/path/to/smtp_password_file";
};
mail.incoming.enable = true;
plugins = with config.services.discourse.package.plugins; [
discourse-spoiler-alert
discourse-solved
];
siteSettings = {
plugins = {
spoiler_enabled = false;
};
};
secretKeyBaseFile = "/path/to/secret_key_base_file";
};
}
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
{
pkgs,
lib,
config,
...
}:
with lib;
let
cfg = config.services.documize;
mkParams =
optional:
concatMapStrings (
name:
let
predicate = optional -> cfg.${name} != null;
template = " -${name} '${toString cfg.${name}}'";
in
optionalString predicate template
);
in
{
options.services.documize = {
enable = mkEnableOption "Documize Wiki";
stateDirectoryName = mkOption {
type = types.str;
default = "documize";
description = ''
The name of the directory below {file}`/var/lib/private`
where documize runs in and stores, for example, backups.
'';
};
package = mkPackageOption pkgs "documize-community" { };
salt = mkOption {
type = types.nullOr types.str;
default = null;
example = "3edIYV6c8B28b19fh";
description = ''
The salt string used to encode JWT tokens, if not set a random value will be generated.
'';
};
cert = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The {file}`cert.pem` file used for https.
'';
};
key = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The {file}`key.pem` file used for https.
'';
};
port = mkOption {
type = types.port;
default = 5001;
description = ''
The http/https port number.
'';
};
forcesslport = mkOption {
type = types.nullOr types.port;
default = null;
description = ''
Redirect given http port number to TLS.
'';
};
offline = mkOption {
type = types.bool;
default = false;
description = ''
Set `true` for offline mode.
'';
apply = v: if true == v then 1 else 0;
};
dbtype = mkOption {
type = types.enum [
"mysql"
"percona"
"mariadb"
"postgresql"
"sqlserver"
];
default = "postgresql";
description = ''
Specify the database provider: `mysql`, `percona`, `mariadb`, `postgresql`, `sqlserver`
'';
};
db = mkOption {
type = types.str;
description = ''
Database specific connection string for example:
- MySQL/Percona/MariaDB:
`user:password@tcp(host:3306)/documize`
- MySQLv8+:
`user:password@tcp(host:3306)/documize?allowNativePasswords=true`
- PostgreSQL:
`host=localhost port=5432 dbname=documize user=admin password=secret sslmode=disable`
- MSSQL:
`sqlserver://username:password@localhost:1433?database=Documize` or
`sqlserver://sa@localhost/SQLExpress?database=Documize`
'';
};
location = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
reserved
'';
};
};
config = mkIf cfg.enable {
systemd.services.documize-server = {
description = "Documize Wiki";
documentation = [ "https://documize.com/" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = concatStringsSep " " [
"${cfg.package}/bin/documize"
(mkParams false [
"db"
"dbtype"
"port"
])
(mkParams true [
"offline"
"location"
"forcesslport"
"key"
"cert"
"salt"
])
];
Restart = "always";
DynamicUser = "yes";
StateDirectory = cfg.stateDirectoryName;
WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}";
};
};
};
}

View File

@@ -0,0 +1,637 @@
{
config,
pkgs,
lib,
...
}:
with lib;
let
inherit (lib.options) showOption showFiles;
cfg = config.services.dokuwiki;
eachSite = cfg.sites;
user = "dokuwiki";
webserver = config.services.${cfg.webserver};
mkPhpIni = generators.toKeyValue {
mkKeyValue = generators.mkKeyValueDefault { } " = ";
};
mkPhpPackage =
cfg:
cfg.phpPackage.buildEnv {
extraConfig = mkPhpIni cfg.phpOptions;
};
# "you're escaped" -> "'you\'re escaped'"
# https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.single
toPhpString = s: "'${escape [ "'" "\\" ] s}'";
dokuwikiAclAuthConfig =
hostName: cfg:
let
inherit (cfg) acl;
acl_gen = concatMapStringsSep "\n" (l: "${l.page} \t ${l.actor} \t ${toString l.level}");
in
pkgs.writeText "acl.auth-${hostName}.php" ''
# acl.auth.php
# <?php exit()?>
#
# Access Control Lists
#
${if isString acl then acl else acl_gen acl}
'';
mergeConfig =
cfg:
{
useacl = false; # Dokuwiki default
savedir = cfg.stateDir;
}
// cfg.settings;
writePhpFile =
name: text:
pkgs.writeTextFile {
inherit name;
text = "<?php\n${text}";
checkPhase = "${pkgs.php84}/bin/php --syntax-check $target";
};
mkPhpValue =
v:
let
isHasAttr = s: isAttrs v && hasAttr s v;
in
if isString v then
toPhpString v
# NOTE: If any value contains a , (comma) this will not get escaped
else if isList v && strings.isConvertibleWithToString v then
toPhpString (concatMapStringsSep "," toString v)
else if isInt v then
toString v
else if isBool v then
toString (if v then 1 else 0)
else if isHasAttr "_file" then
"trim(file_get_contents(${toPhpString (toString v._file)}))"
else if isHasAttr "_raw" then
v._raw
else
abort "The dokuwiki localConf value ${lib.generators.toPretty { } v} can not be encoded.";
mkPhpAttrVals = v: flatten (mapAttrsToList mkPhpKeyVal v);
mkPhpKeyVal =
k: v:
let
values =
if (isAttrs v && (hasAttr "_file" v || hasAttr "_raw" v)) || !isAttrs v then
[ " = ${mkPhpValue v};" ]
else
mkPhpAttrVals v;
in
map (e: "[${toPhpString k}]${e}") (flatten values);
dokuwikiLocalConfig =
hostName: cfg:
let
conf_gen = c: map (v: "$conf${v}") (mkPhpAttrVals c);
in
writePhpFile "local-${hostName}.php" ''
${concatStringsSep "\n" (conf_gen cfg.mergedConfig)}
'';
dokuwikiPluginsLocalConfig =
hostName: cfg:
let
pc = cfg.pluginsConfig;
pc_gen =
pc: concatStringsSep "\n" (mapAttrsToList (n: v: "$plugins['${n}'] = ${boolToString v};") pc);
in
writePhpFile "plugins.local-${hostName}.php" ''
${if isString pc then pc else pc_gen pc}
'';
pkg =
hostName: cfg:
cfg.package.combine {
inherit (cfg) plugins templates;
pname = p: "${p.pname}-${hostName}";
basePackage = cfg.package;
localConfig = dokuwikiLocalConfig hostName cfg;
pluginsConfig = dokuwikiPluginsLocalConfig hostName cfg;
aclConfig =
if cfg.settings.useacl && cfg.acl != null then dokuwikiAclAuthConfig hostName cfg else null;
};
aclOpts =
{ ... }:
{
options = {
page = mkOption {
type = types.str;
description = "Page or namespace to restrict";
example = "start";
};
actor = mkOption {
type = types.str;
description = "User or group to restrict";
example = "@external";
};
level =
let
available = {
"none" = 0;
"read" = 1;
"edit" = 2;
"create" = 4;
"upload" = 8;
"delete" = 16;
};
in
mkOption {
type = types.enum ((attrValues available) ++ (attrNames available));
apply = x: if isInt x then x else available.${x};
description = ''
Permission level to restrict the actor(s) to.
See <https://www.dokuwiki.org/acl#background_info> for explanation
'';
example = "read";
};
};
};
siteOpts =
{
options,
config,
lib,
name,
...
}:
{
# TODO: Remove in time for 25.11 and/or simplify once https://github.com/NixOS/nixpkgs/issues/96006 is fixed
imports = [
(
{ config, options, ... }:
let
removalNote = "The option has had no effect for 3+ years. There is no replacement available.";
optPath = lib.options.showOption [
"services"
"dokuwiki"
"sites"
name
"enable"
];
in
{
options.enable = mkOption {
visible = false;
apply =
x: throw "The option `${optPath}' can no longer be used since it's been removed. ${removalNote}";
};
config.assertions = [
{
assertion = !options.enable.isDefined;
message = ''
The option definition `${optPath}' in ${showFiles options.enable.files} no longer has any effect; please remove it.
${removalNote}
'';
}
];
}
)
];
options = {
package = mkPackageOption pkgs "dokuwiki" { };
stateDir = mkOption {
type = types.path;
default = "/var/lib/dokuwiki/${name}/data";
description = "Location of the DokuWiki state directory.";
};
acl = mkOption {
type = with types; nullOr (listOf (submodule aclOpts));
default = null;
example = literalExpression ''
[
{
page = "start";
actor = "@external";
level = "read";
}
{
page = "*";
actor = "@users";
level = "upload";
}
]
'';
description = ''
Access Control Lists: see <https://www.dokuwiki.org/acl>
Mutually exclusive with services.dokuwiki.aclFile
Set this to a value other than null to take precedence over aclFile option.
Warning: Consider using aclFile instead if you do not
want to store the ACL in the world-readable Nix store.
'';
};
aclFile = mkOption {
type = with types; nullOr str;
default =
if (config.mergedConfig.useacl && config.acl == null) then
"/var/lib/dokuwiki/${name}/acl.auth.php"
else
null;
description = ''
Location of the dokuwiki acl rules. Mutually exclusive with services.dokuwiki.acl
Mutually exclusive with services.dokuwiki.acl which is preferred.
Consult documentation <https://www.dokuwiki.org/acl> for further instructions.
Example: <https://github.com/splitbrain/dokuwiki/blob/master/conf/acl.auth.php.dist>
'';
example = "/var/lib/dokuwiki/${name}/acl.auth.php";
};
pluginsConfig = mkOption {
type = with types; attrsOf bool;
default = {
authad = false;
authldap = false;
authmysql = false;
authpgsql = false;
};
description = ''
List of the dokuwiki (un)loaded plugins.
'';
};
usersFile = mkOption {
type = with types; nullOr str;
default = if config.mergedConfig.useacl then "/var/lib/dokuwiki/${name}/users.auth.php" else null;
description = ''
Location of the dokuwiki users file. List of users. Format:
login:passwordhash:Real Name:email:groups,comma,separated
Create passwordHash easily by using:
mkpasswd -5 password `pwgen 8 1`
Example: <https://github.com/splitbrain/dokuwiki/blob/master/conf/users.auth.php.dist>
'';
example = "/var/lib/dokuwiki/${name}/users.auth.php";
};
plugins = mkOption {
type = types.listOf types.path;
default = [ ];
description = ''
List of path(s) to respective plugin(s) which are copied from the 'plugin' directory.
::: {.note}
These plugins need to be packaged before use, see example.
:::
'';
example = literalExpression ''
let
plugin-icalevents = pkgs.stdenv.mkDerivation rec {
name = "icalevents";
version = "2017-06-16";
src = pkgs.fetchzip {
stripRoot = false;
url = "https://github.com/real-or-random/dokuwiki-plugin-icalevents/releases/download/''${version}/dokuwiki-plugin-icalevents-''${version}.zip";
hash = "sha256-IPs4+qgEfe8AAWevbcCM9PnyI0uoyamtWeg4rEb+9Wc=";
};
installPhase = "mkdir -p $out; cp -R * $out/";
};
# And then pass this theme to the plugin list like this:
in [ plugin-icalevents ]
'';
};
templates = mkOption {
type = types.listOf types.path;
default = [ ];
description = ''
List of path(s) to respective template(s) which are copied from the 'tpl' directory.
::: {.note}
These templates need to be packaged before use, see example.
:::
'';
example = literalExpression ''
let
template-bootstrap3 = pkgs.stdenv.mkDerivation rec {
name = "bootstrap3";
version = "2022-07-27";
src = pkgs.fetchFromGitHub {
owner = "giterlizzi";
repo = "dokuwiki-template-bootstrap3";
rev = "v''${version}";
hash = "sha256-B3Yd4lxdwqfCnfmZdp+i/Mzwn/aEuZ0ovagDxuR6lxo=";
};
installPhase = "mkdir -p $out; cp -R * $out/";
};
# And then pass this theme to the template list like this:
in [ template-bootstrap3 ]
'';
};
poolConfig = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for the DokuWiki PHP pool. See the documentation on `php-fpm.conf`
for details on configuration directives.
'';
};
phpPackage = mkPackageOption pkgs "php" {
default = "php84";
};
phpOptions = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Options for PHP's php.ini file for this dokuwiki site.
'';
example = literalExpression ''
{
"opcache.interned_strings_buffer" = "8";
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.revalidate_freq" = "15";
"opcache.fast_shutdown" = "1";
}
'';
};
settings = mkOption {
type = types.attrsOf types.anything;
default = {
useacl = true;
superuser = "admin";
};
description = ''
Structural DokuWiki configuration.
Refer to <https://www.dokuwiki.org/config>
for details and supported values.
Settings can either be directly set from nix,
loaded from a file using `._file` or obtained from any
PHP function calls using `._raw`.
'';
example = literalExpression ''
{
title = "My Wiki";
userewrite = 1;
disableactions = [ "register" ]; # Will be concatenated with commas
plugin.smtp = {
smtp_pass._file = "/var/run/secrets/dokuwiki/smtp_pass";
smtp_user._raw = "getenv('DOKUWIKI_SMTP_USER')";
};
}
'';
};
mergedConfig = mkOption {
readOnly = true;
default = mergeConfig config;
defaultText = literalExpression ''
{
useacl = true;
}
'';
description = ''
Read only representation of the final configuration.
'';
};
# TODO: Remove when no submodule-level assertions are needed anymore
assertions = mkOption {
type = types.listOf types.unspecified;
default = [ ];
visible = false;
internal = true;
};
};
};
in
{
options = {
services.dokuwiki = {
sites = mkOption {
type = types.attrsOf (types.submodule siteOpts);
default = { };
description = "Specification of one or more DokuWiki sites to serve";
};
webserver = mkOption {
type = types.enum [
"nginx"
"caddy"
];
default = "nginx";
description = ''
Whether to use nginx or caddy for virtual host management.
Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
See [](#opt-services.nginx.virtualHosts) for further information.
Further caddy configuration can be done by adapting `services.caddy.virtualHosts.<name>`.
See [](#opt-services.caddy.virtualHosts) for further information.
'';
};
};
};
# implementation
config = mkIf (eachSite != { }) (mkMerge [
{
# TODO: Remove when no submodule-level assertions are needed anymore
assertions = flatten (mapAttrsToList (_: cfg: cfg.assertions) eachSite);
services.phpfpm.pools = mapAttrs' (
hostName: cfg:
(nameValuePair "dokuwiki-${hostName}" {
inherit user;
group = webserver.group;
phpPackage = mkPhpPackage cfg;
phpEnv =
optionalAttrs (cfg.usersFile != null) {
DOKUWIKI_USERS_AUTH_CONFIG = "${cfg.usersFile}";
}
// optionalAttrs (cfg.mergedConfig.useacl) {
DOKUWIKI_ACL_AUTH_CONFIG =
if (cfg.acl != null) then "${dokuwikiAclAuthConfig hostName cfg}" else "${toString cfg.aclFile}";
};
settings = {
"listen.owner" = webserver.user;
"listen.group" = webserver.group;
}
// cfg.poolConfig;
})
) eachSite;
}
{
systemd.tmpfiles.rules = flatten (
mapAttrsToList (
hostName: cfg:
[
"d ${cfg.stateDir}/attic 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/cache 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/index 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/locks 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/log 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/media 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/media_attic 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/media_meta 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/meta 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/pages 0750 ${user} ${webserver.group} - -"
"d ${cfg.stateDir}/tmp 0750 ${user} ${webserver.group} - -"
]
++
lib.optional (cfg.aclFile != null)
"C ${cfg.aclFile} 0640 ${user} ${webserver.group} - ${pkg hostName cfg}/share/dokuwiki/conf/acl.auth.php.dist"
++
lib.optional (cfg.usersFile != null)
"C ${cfg.usersFile} 0640 ${user} ${webserver.group} - ${pkg hostName cfg}/share/dokuwiki/conf/users.auth.php.dist"
) eachSite
);
users.users.${user} = {
group = webserver.group;
isSystemUser = true;
};
}
(mkIf (cfg.webserver == "nginx") {
services.nginx = {
enable = true;
virtualHosts = mapAttrs (hostName: cfg: {
serverName = mkDefault hostName;
root = "${pkg hostName cfg}/share/dokuwiki";
locations = {
"~ /(conf/|bin/|inc/|install.php)" = {
extraConfig = "deny all;";
};
"~ ^/data/" = {
root = "${cfg.stateDir}";
extraConfig = "internal;";
};
"~ ^/lib.*\\.(js|css|gif|png|ico|jpg|jpeg)$" = {
extraConfig = "expires 365d;";
};
"/" = {
priority = 1;
index = "doku.php";
extraConfig = ''try_files $uri $uri/ @dokuwiki;'';
};
"@dokuwiki" = {
extraConfig = ''
# rewrites "doku.php/" out of the URLs if you set the userwrite setting to .htaccess in dokuwiki config page
rewrite ^/_media/(.*) /lib/exe/fetch.php?media=$1 last;
rewrite ^/_detail/(.*) /lib/exe/detail.php?media=$1 last;
rewrite ^/_export/([^/]+)/(.*) /doku.php?do=export_$1&id=$2 last;
rewrite ^/(.*) /doku.php?id=$1&$args last;
'';
};
"~ \\.php$" = {
extraConfig = ''
try_files $uri $uri/ /doku.php;
include ${config.services.nginx.package}/conf/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param REDIRECT_STATUS 200;
fastcgi_pass unix:${config.services.phpfpm.pools."dokuwiki-${hostName}".socket};
'';
};
};
}) eachSite;
};
})
(mkIf (cfg.webserver == "caddy") {
services.caddy = {
enable = true;
virtualHosts = mapAttrs' (
hostName: cfg:
(nameValuePair hostName {
extraConfig = ''
root * ${pkg hostName cfg}/share/dokuwiki
file_server
encode zstd gzip
php_fastcgi unix/${config.services.phpfpm.pools."dokuwiki-${hostName}".socket}
@restrict_files {
path /data/* /conf/* /bin/* /inc/* /vendor/* /install.php
}
respond @restrict_files 404
@allow_media {
path_regexp path ^/_media/(.*)$
}
rewrite @allow_media /lib/exe/fetch.php?media=/{http.regexp.path.1}
@allow_detail {
path /_detail*
}
rewrite @allow_detail /lib/exe/detail.php?media={path}
@allow_export {
path /_export*
path_regexp export /([^/]+)/(.*)
}
rewrite @allow_export /doku.php?do=export_{http.regexp.export.1}&id={http.regexp.export.2}
try_files {path} {path}/ /doku.php?id={path}&{query}
'';
})
) eachSite;
};
})
]);
meta.maintainers = with maintainers; [
_1000101
onny
dandellion
e1mo
];
}

View File

@@ -0,0 +1,379 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
any
boolToString
concatStringsSep
isBool
isString
mapAttrsToList
mkDefault
mkEnableOption
mkIf
mkMerge
mkOption
optionalAttrs
types
mkPackageOption
;
package = cfg.package.override { inherit (cfg) stateDir; };
cfg = config.services.dolibarr;
vhostCfg = lib.optionalAttrs (cfg.nginx != null) config.services.nginx.virtualHosts."${cfg.domain}";
mkConfigFile =
filename: settings:
let
# hack in special logic for secrets so we read them from a separate file avoiding the nix store
secretKeys = [
"force_install_databasepass"
"dolibarr_main_db_pass"
"dolibarr_main_instance_unique_id"
];
toStr =
k: v:
if (any (str: k == str) secretKeys) then
v
else if isString v then
"'${v}'"
else if isBool v then
boolToString v
else if v == null then
"null"
else
toString v;
in
pkgs.writeText filename ''
<?php
${concatStringsSep "\n" (mapAttrsToList (k: v: "\$${k} = ${toStr k v};") settings)}
'';
# see https://github.com/Dolibarr/dolibarr/blob/develop/htdocs/install/install.forced.sample.php for all possible values
install = {
force_install_noedit = 2;
force_install_main_data_root = "${cfg.stateDir}/documents";
force_install_nophpinfo = true;
force_install_lockinstall = "444";
force_install_distrib = "nixos";
force_install_type = "mysqli";
force_install_dbserver = cfg.database.host;
force_install_port = toString cfg.database.port;
force_install_database = cfg.database.name;
force_install_databaselogin = cfg.database.user;
force_install_mainforcehttps = vhostCfg.forceSSL or false;
force_install_createuser = false;
force_install_dolibarrlogin = null;
}
// optionalAttrs (cfg.database.passwordFile != null) {
force_install_databasepass = ''file_get_contents("${cfg.database.passwordFile}")'';
};
in
{
# interface
options.services.dolibarr = {
enable = mkEnableOption "dolibarr";
package = mkPackageOption pkgs "dolibarr" { };
domain = mkOption {
type = types.str;
default = "localhost";
description = ''
Domain name of your server.
'';
};
user = mkOption {
type = types.str;
default = "dolibarr";
description = ''
User account under which dolibarr runs.
::: {.note}
If left as the default value this user will automatically be created
on system activation, otherwise you are responsible for
ensuring the user exists before the dolibarr application starts.
:::
'';
};
group = mkOption {
type = types.str;
default = "dolibarr";
description = ''
Group account under which dolibarr runs.
::: {.note}
If left as the default value this group will automatically be created
on system activation, otherwise you are responsible for
ensuring the group exists before the dolibarr application starts.
:::
'';
};
stateDir = mkOption {
type = types.str;
default = "/var/lib/dolibarr";
description = ''
State and configuration directory dolibarr will use.
'';
};
database = {
host = mkOption {
type = types.str;
default = "localhost";
description = "Database host address.";
};
port = mkOption {
type = types.port;
default = 3306;
description = "Database host port.";
};
name = mkOption {
type = types.str;
default = "dolibarr";
description = "Database name.";
};
user = mkOption {
type = types.str;
default = "dolibarr";
description = "Database username.";
};
passwordFile = mkOption {
type = with types; nullOr path;
default = null;
example = "/run/keys/dolibarr-dbpassword";
description = "Database password file.";
};
createLocally = mkOption {
type = types.bool;
default = true;
description = "Create the database and database user locally.";
};
};
settings = mkOption {
type =
with types;
(attrsOf (oneOf [
bool
int
str
]));
default = { };
description = "Dolibarr settings, see <https://github.com/Dolibarr/dolibarr/blob/develop/htdocs/conf/conf.php.example> for details.";
};
nginx = mkOption {
type = types.nullOr (
types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
# enable encryption by default,
# as sensitive login and Dolibarr (ERP) data should not be transmitted in clear text.
options.forceSSL.default = true;
options.enableACME.default = true;
}
)
);
default = null;
example = lib.literalExpression ''
{
serverAliases = [
"dolibarr.''${config.networking.domain}"
"erp.''${config.networking.domain}"
];
enableACME = false;
}
'';
description = ''
With this option, you can customize an nginx virtual host which already has sensible defaults for Dolibarr.
Set to {} if you do not need any customization to the virtual host.
If enabled, then by default, the {option}`serverName` is
`''${domain}`,
SSL is active, and certificates are acquired via ACME.
If this is set to null (the default), no nginx virtualHost will be configured.
'';
};
poolConfig = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for the Dolibarr PHP pool. See the documentation on [`php-fpm.conf`](https://www.php.net/manual/en/install.fpm.configuration.php)
for details on configuration directives.
'';
};
};
# implementation
config = mkIf cfg.enable (mkMerge [
{
assertions = [
{
assertion = cfg.database.createLocally -> cfg.database.user == cfg.user;
message = "services.dolibarr.database.user must match services.dolibarr.user if the database is to be automatically provisioned";
}
];
services.dolibarr.settings = {
dolibarr_main_url_root = "https://${cfg.domain}";
dolibarr_main_document_root = "${package}/htdocs";
dolibarr_main_url_root_alt = "/custom";
dolibarr_main_data_root = "${cfg.stateDir}/documents";
dolibarr_main_db_host = cfg.database.host;
dolibarr_main_db_port = toString cfg.database.port;
dolibarr_main_db_name = cfg.database.name;
dolibarr_main_db_prefix = "llx_";
dolibarr_main_db_user = cfg.database.user;
dolibarr_main_db_pass = mkIf (cfg.database.passwordFile != null) ''
file_get_contents("${cfg.database.passwordFile}")
'';
dolibarr_main_db_type = "mysqli";
dolibarr_main_db_character_set = mkDefault "utf8";
dolibarr_main_db_collation = mkDefault "utf8_unicode_ci";
# Authentication settings
dolibarr_main_authentication = mkDefault "dolibarr";
# Security settings
dolibarr_main_prod = true;
dolibarr_main_force_https = vhostCfg.forceSSL or false;
dolibarr_main_restrict_os_commands = "${pkgs.mariadb}/bin/mysqldump, ${pkgs.mariadb}/bin/mysql";
dolibarr_nocsrfcheck = false;
dolibarr_main_instance_unique_id = ''
file_get_contents("${cfg.stateDir}/dolibarr_main_instance_unique_id")
'';
dolibarr_mailing_limit_sendbyweb = false;
};
systemd.tmpfiles.rules = [
"d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group}"
"d '${cfg.stateDir}/documents' 0750 ${cfg.user} ${cfg.group}"
"f '${cfg.stateDir}/conf.php' 0660 ${cfg.user} ${cfg.group}"
"L '${cfg.stateDir}/install.forced.php' - ${cfg.user} ${cfg.group} - ${mkConfigFile "install.forced.php" install}"
];
services.mysql = mkIf cfg.database.createLocally {
enable = mkDefault true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.nginx.enable = mkIf (cfg.nginx != null) true;
services.nginx.virtualHosts."${cfg.domain}" = mkIf (cfg.nginx != null) (
lib.mkMerge [
cfg.nginx
{
root = lib.mkForce "${package}/htdocs";
locations."/".index = "index.php";
locations."~ [^/]\\.php(/|$)" = {
extraConfig = ''
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_pass unix:${config.services.phpfpm.pools.dolibarr.socket};
'';
};
}
]
);
systemd.services."phpfpm-dolibarr".after = mkIf cfg.database.createLocally [ "mysql.service" ];
services.phpfpm.pools.dolibarr = {
inherit (cfg) user group;
phpPackage = pkgs.php83.buildEnv {
extensions = { enabled, all }: enabled ++ [ all.calendar ];
# recommended by dolibarr web application
extraConfig = ''
session.use_strict_mode = 1
session.cookie_samesite = "Lax"
; open_basedir = "${package}/htdocs, ${cfg.stateDir}"
allow_url_fopen = 0
disable_functions = "pcntl_alarm, pcntl_fork, pcntl_waitpid, pcntl_wait, pcntl_wifexited, pcntl_wifstopped, pcntl_wifsignaled, pcntl_wifcontinued, pcntl_wexitstatus, pcntl_wtermsig, pcntl_wstopsig, pcntl_signal, pcntl_signal_get_handler, pcntl_signal_dispatch, pcntl_get_last_error, pcntl_strerror, pcntl_sigprocmask, pcntl_sigwaitinfo, pcntl_sigtimedwait, pcntl_exec, pcntl_getpriority, pcntl_setpriority, pcntl_async_signals"
'';
};
settings = {
"listen.mode" = "0660";
"listen.owner" = cfg.user;
"listen.group" = cfg.group;
}
// cfg.poolConfig;
};
# there are several challenges with dolibarr and NixOS which we can address here
# - the dolibarr installer cannot be entirely automated, though it can partially be by including a file called install.forced.php
# - the dolibarr installer requires write access to its config file during installation, though not afterwards
# - the dolibarr config file generally holds secrets generated by the installer, though the config file is a php file so we can read and write these secrets from an external file
systemd.services.dolibarr-config = {
description = "dolibarr configuration file management via NixOS";
wantedBy = [ "multi-user.target" ];
script =
let
php = lib.getExe config.services.phpfpm.pools.dolibarr.phpPackage;
in
''
# extract the 'main instance unique id' secret that the dolibarr installer generated for us, store it in a file for use by our own NixOS generated configuration file
${php} -r "include '${cfg.stateDir}/conf.php'; file_put_contents('${cfg.stateDir}/dolibarr_main_instance_unique_id', \$dolibarr_main_instance_unique_id);"
# replace configuration file generated by installer with the NixOS generated configuration file
install -m 440 ${mkConfigFile "conf.php" cfg.settings} '${cfg.stateDir}/conf.php'
'';
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
RemainAfterExit = "yes";
};
unitConfig = {
ConditionFileNotEmpty = "${cfg.stateDir}/conf.php";
};
};
users.users.dolibarr = mkIf (cfg.user == "dolibarr") {
isSystemUser = true;
group = cfg.group;
};
users.groups = optionalAttrs (cfg.group == "dolibarr") {
dolibarr = { };
};
}
(mkIf (cfg.nginx != null) {
users.users."${config.services.nginx.group}".extraGroups = mkIf (cfg.nginx != null) [ cfg.group ];
})
]);
}

View File

@@ -0,0 +1,550 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
any
attrValues
flatten
literalExpression
mapAttrs
mapAttrs'
mapAttrsToList
mkDefault
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOption
nameValuePair
optionalAttrs
types
;
inherit (pkgs)
mariadb
stdenv
writeShellScript
;
cfg = config.services.drupal;
eachSite = cfg.sites;
user = "drupal";
webserver = config.services.${cfg.webserver};
pkg =
hostName: cfg:
stdenv.mkDerivation (finalAttrs: {
pname = "drupal-${hostName}";
name = "drupal-${hostName}";
src = cfg.package;
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r * $out/
runHook postInstall
'';
postInstall = ''
ln -s ${cfg.filesDir} $out/share/php/drupal/sites/default/files
ln -s ${cfg.stateDir}/sites/default/settings.php $out/share/php/drupal/sites/default/settings.php
ln -s ${cfg.modulesDir} $out/share/php/drupal/modules
ln -s ${cfg.themesDir} $out/share/php/drupal/themes
'';
});
drupalSettings =
hostName: cfg:
pkgs.writeTextFile {
name = "settings.nixos-${hostName}.php";
text = ''
<?php
// NixOS automatically generated settings
$settings['file_private_path'] = '${cfg.privateFilesDir}';
$settings['config_sync_directory'] = '${cfg.configSyncDir}';
// Extra config
${cfg.extraConfig}
'';
checkPhase = "${pkgs.php}/bin/php --syntax-check $target";
};
appendSettings =
hostName:
pkgs.writeTextFile {
name = "append-drupal-settings-${hostName}";
text = ''
// NixOS settings file import.
require dirname(__FILE__) . '/settings.nixos-${hostName}.php';
'';
};
siteOpts =
{
options,
config,
lib,
name,
...
}:
{
options = {
enable = mkEnableOption "Drupal web application";
package = mkPackageOption pkgs "drupal" { };
filesDir = mkOption {
type = types.path;
default = "/var/lib/drupal/${name}/sites/default/files";
defaultText = "/var/lib/drupal/<name>/sites/default/files";
description = ''
The location of the Drupal files directory.
'';
};
privateFilesDir = mkOption {
type = types.path;
default = "/var/lib/drupal/${name}/private";
defaultText = "/var/lib/drupal/<name>/private";
description = "The location of the Drupal private files directory.";
};
configSyncDir = mkOption {
type = types.path;
default = "/var/lib/drupal/${name}/config/sync";
defaultText = "/var/lib/drupal/<name>/config/sync";
description = "The location of the Drupal config sync directory.";
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = ''
Extra configuration values that you want to insert into settings.php.
All configuration must be written as PHP script.
'';
example = ''
$config['user.settings']['anonymous'] = 'Visitor';
$settings['entity_update_backup'] = TRUE;
'';
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/drupal/${name}";
defaultText = "/var/lib/drupal/<name>";
description = "The location of the Drupal site state directory.";
};
modulesDir = mkOption {
type = types.path;
default = "/var/lib/drupal/${name}/modules";
defaultText = "/var/lib/drupal/<name>/modules";
description = "The location for users to install Drupal modules.";
};
themesDir = mkOption {
type = types.path;
default = "/var/lib/drupal/${name}/themes";
defaultText = "/var/lib/drupal/<name>/themes";
description = "The location for users to install Drupal themes.";
};
phpOptions = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Options for PHP's php.ini file for this Drupal site.
'';
example = literalExpression ''
{
"opcache.interned_strings_buffer" = "8";
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.revalidate_freq" = "15";
"opcache.fast_shutdown" = "1";
}
'';
};
database = {
host = mkOption {
type = types.str;
default = "localhost";
description = "Database host address.";
};
port = mkOption {
type = types.port;
default = 3306;
description = "Database host port.";
};
name = mkOption {
type = types.str;
default = "drupal";
description = "Database name.";
};
user = mkOption {
type = types.str;
default = "drupal";
description = "Database user.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/database-dbpassword";
description = ''
A file containing the password corresponding to
{option}`database.user`.
'';
};
tablePrefix = mkOption {
type = types.str;
default = "dp_";
description = ''
The $table_prefix is the value placed in the front of your database tables.
Change the value if you want to use something other than dp_ for your database
prefix. Typically this is changed if you are installing multiple Drupal sites
in the same database.
'';
};
socket = mkOption {
type = types.nullOr types.path;
default = null;
defaultText = literalExpression "/run/mysqld/mysqld.sock";
description = "Path to the unix socket file to use for authentication.";
};
createLocally = mkOption {
type = types.bool;
default = true;
description = "Create the database and database user locally.";
};
};
virtualHost = mkOption {
type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
example = literalExpression ''
{
adminAddr = "webmaster@example.org";
forceSSL = true;
enableACME = true;
}
'';
description = ''
Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
'';
};
poolConfig = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for the Drupal PHP pool. See the documentation on `php-fpm.conf`
for details on configuration directives.
'';
};
};
config.virtualHost.hostName = mkDefault name;
};
in
{
options = {
services.drupal = {
enable = mkEnableOption "drupal";
package = mkPackageOption pkgs "drupal" { };
sites = mkOption {
type = types.attrsOf (types.submodule siteOpts);
default = {
"localhost" = {
enable = true;
};
};
description = "Specification of one or more Drupal sites to serve";
};
webserver = mkOption {
type = types.enum [
"nginx"
"caddy"
];
default = "nginx";
description = ''
Whether to use nginx or caddy for virtual host management.
Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
See [](#opt-services.nginx.virtualHosts) for further information.
Further caddy configuration can be done by adapting `services.caddy.virtualHosts.<name>`.
See [](#opt-services.caddy.virtualHosts) for further information.
'';
};
};
};
config = mkIf (cfg.enable) (mkMerge [
{
assertions =
(mapAttrsToList (hostName: cfg: {
assertion = cfg.database.createLocally -> cfg.database.user == user;
message = ''services.drupal.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
}) eachSite)
++ (mapAttrsToList (hostName: cfg: {
assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = ''services.drupal.sites."${hostName}".database.passwordFile cannot be specified if services.drupal.sites."${hostName}".database.createLocally is set to true.'';
}) eachSite);
services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
enable = true;
package = mkDefault mariadb;
ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
ensureUsers = mapAttrsToList (hostName: cfg: {
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}) eachSite;
};
services.phpfpm.pools = mapAttrs' (
hostName: cfg:
(nameValuePair "drupal-${hostName}" {
inherit user;
group = webserver.group;
settings = {
"listen.owner" = webserver.user;
"listen.group" = webserver.group;
}
// cfg.poolConfig;
})
) eachSite;
}
{
systemd.tmpfiles.rules = flatten (
mapAttrsToList (hostName: cfg: [
"d '${cfg.stateDir}' 0750 ${user} ${webserver.group} - -"
"d '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -"
"Z '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -"
"d '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -"
"Z '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -"
"d '${cfg.privateFilesDir}' 0750 ${user} ${webserver.group} - -"
"d '${cfg.configSyncDir}' 0750 ${user} ${webserver.group} - -"
]) eachSite
);
users.users.${user} = {
group = webserver.group;
isSystemUser = true;
};
}
{
# Run a service that prepares the state directory.
systemd.services = mkMerge [
(mapAttrs' (
hostName: cfg:
(nameValuePair "drupal-state-init-${hostName}" {
wantedBy = [ "multi-user.target" ];
before = [ "nginx.service" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
User = "root";
RemainAfterExit = true;
ExecStart = writeShellScript "drupal-state-init-${hostName}" ''
set -e
if [ ! -d "${cfg.stateDir}/sites" ]; then
echo "Preparing sites directory..."
cp -r "${cfg.package}/share/php/drupal/sites" "${cfg.stateDir}"
fi
if [ ! -d "${cfg.filesDir}" ]; then
echo "Preparing files directory..."
mkdir -p "${cfg.filesDir}"
chown -R ${user}:${webserver.group} ${cfg.filesDir}
fi
settings_file="${cfg.stateDir}/sites/default/settings.php"
default_settings="${cfg.package}/share/php/drupal/sites/default/default.settings.php"
if [ ! -f "$settings_file" ]; then
echo "Preparing settings.php for ${hostName}..."
cp "$default_settings" "$settings_file"
cat < ${appendSettings hostName} >> "$settings_file"
chmod 644 "$settings_file"
fi
# Link the NixOS-managed settings file to the state directory.
ln -sf ${drupalSettings hostName cfg} ${cfg.stateDir}/sites/default/settings.nixos-${hostName}.php
# Set or reset file permissions so that the web user and webserver owns them.
chown -R ${user}:${webserver.group} ${cfg.stateDir}
'';
};
# Rerun this service if certain settings were updated
reloadTriggers = [
cfg.extraConfig
cfg.privateFilesDir
cfg.configSyncDir
];
})
) eachSite)
(optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
httpd.after = [ "mysql.service" ];
})
];
}
(mkIf (cfg.webserver == "nginx") {
services.nginx = {
enable = true;
virtualHosts = mapAttrs (hostName: cfg: {
serverName = mkDefault hostName;
root = "${pkg hostName cfg}/share/php/drupal";
extraConfig = ''
index index.php;
'';
locations = {
"~ '\.php$|^/update.php'" = {
extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools."drupal-${hostName}".socket};
fastcgi_index index.php;
include "${config.services.nginx.package}/conf/fastcgi.conf";
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
# Mitigate https://httpoxy.org/ vulnerabilities
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
'';
};
"= /favicon.ico" = {
extraConfig = ''
log_not_found off;
access_log off;
'';
};
"= /robots.txt" = {
extraConfig = ''
allow all;
log_not_found off;
access_log off;
'';
};
"~ \..*/.*\.php$" = {
extraConfig = ''
return 403;
'';
};
"~ ^/sites/.*/private/" = {
extraConfig = ''
return 403;
'';
};
"~ ^/sites/[^/]+/files/.*\.php$" = {
extraConfig = ''
deny all;
'';
};
"~* ^/.well-known/" = {
extraConfig = ''
allow all;
'';
};
"/" = {
extraConfig = ''
try_files $uri /index.php?$query_string;
'';
};
"@rewrite" = {
extraConfig = ''
rewrite ^ /index.php;
'';
};
"~ /vendor/.*\.php$" = {
extraConfig = ''
deny all;
return 404;
'';
};
"~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$" = {
extraConfig = ''
try_files $uri @rewrite;
expires max;
log_not_found off;
'';
};
"~ ^/sites/.*/files/styles/" = {
extraConfig = ''
try_files $uri @rewrite;
'';
};
"~ ^(/[a-z\-]+)?/system/files/" = {
extraConfig = ''
try_files $uri /index.php?$query_string;
'';
};
};
}) eachSite;
};
})
(mkIf (cfg.webserver == "caddy") {
services.caddy = {
enable = true;
virtualHosts = mapAttrs' (
hostName: cfg:
(nameValuePair hostName {
extraConfig = ''
root * ${pkg hostName cfg}/share/php/drupal
file_server
encode zstd gzip
php_fastcgi unix/${config.services.phpfpm.pools."drupal-${hostName}".socket}
'';
})
) cfg.sites;
};
})
]);
}

View File

@@ -0,0 +1,128 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.echoip;
in
{
meta.maintainers = with lib.maintainers; [ defelo ];
options.services.echoip = {
enable = lib.mkEnableOption "echoip";
package = lib.mkPackageOption pkgs "echoip" { };
virtualHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the nginx virtual host to use and setup. If null, do not setup anything.
'';
default = null;
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Extra command line arguments to pass to echoip. See <https://github.com/mpolden/echoip> for details.";
default = [ ];
};
listenAddress = lib.mkOption {
type = lib.types.str;
description = "The address echoip should listen on";
default = ":8080";
example = "127.0.0.1:8000";
};
enablePortLookup = lib.mkEnableOption "port lookup";
enableReverseHostnameLookups = lib.mkEnableOption "reverse hostname lookups";
remoteIpHeader = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Header to trust for remote IP, if present";
default = null;
example = "X-Real-IP";
};
};
config = lib.mkIf cfg.enable {
systemd.services.echoip = {
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
User = "echoip";
Group = "echoip";
DynamicUser = true;
ExecStart = lib.escapeShellArgs (
[
(lib.getExe cfg.package)
"-l"
cfg.listenAddress
]
++ lib.optional cfg.enablePortLookup "-p"
++ lib.optional cfg.enableReverseHostnameLookups "-r"
++ lib.optionals (cfg.remoteIpHeader != null) [
"-H"
cfg.remoteIpHeader
]
++ cfg.extraArgs
);
# Hardening
AmbientCapabilities = "";
CapabilityBoundingSet = [ "" ];
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET AF_INET6 AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
"setrlimit"
];
UMask = "0077";
};
};
services.nginx = lib.mkIf (cfg.virtualHost != null) {
enable = true;
virtualHosts.${cfg.virtualHost} = {
locations."/" = {
proxyPass = "http://${cfg.listenAddress}";
recommendedProxySettings = true;
};
};
};
services.echoip = lib.mkIf (cfg.virtualHost != null) {
listenAddress = lib.mkDefault "127.0.0.1:8080";
remoteIpHeader = "X-Real-IP";
};
};
}

View File

@@ -0,0 +1,104 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.eintopf;
in
{
options.services.eintopf = {
enable = mkEnableOption "Lauti (Eintopf) community event calendar web app";
settings = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Settings to configure web service. See
<https://codeberg.org/Klasse-Methode/lauti/src/branch/main/DEPLOYMENT.md>
for available options.
'';
example = literalExpression ''
{
EINTOPF_ADDR = ":1234";
EINTOPF_ADMIN_EMAIL = "admin@example.org";
EINTOPF_TIMEZONE = "Europe/Berlin";
}
'';
};
secrets = lib.mkOption {
type = with types; listOf path;
description = ''
A list of files containing the various secrets. Should be in the
format expected by systemd's `EnvironmentFile` directory.
'';
default = [ ];
};
};
config = mkIf cfg.enable {
systemd.services.eintopf = {
description = "Community event calendar web app";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = cfg.settings;
serviceConfig = {
ExecStart = lib.getExe pkgs.lauti;
WorkingDirectory = "/var/lib/eintopf";
StateDirectory = "eintopf";
EnvironmentFile = [ cfg.secrets ];
# hardening
AmbientCapabilities = "";
CapabilityBoundingSet = "";
DevicePolicy = "closed";
DynamicUser = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
};
};
meta.maintainers = with lib.maintainers; [ onny ];
}

View File

@@ -0,0 +1,213 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
inherit (lib)
mkDefault
mkEnableOption
mkIf
mkOption
mkPackageOption
mkRenamedOptionModule
types
;
cfg = config.services.engelsystem;
in
{
imports = [
(mkRenamedOptionModule
[ "services" "engelsystem" "config" ]
[ "services" "engelsystem" "settings" ]
)
];
options.services.engelsystem = {
enable = mkEnableOption "engelsystem, an online tool for coordinating volunteers and shifts on large events";
package = mkPackageOption pkgs "engelsystem" { };
domain = mkOption {
type = types.str;
example = "engelsystem.example.com";
description = "Domain to serve on.";
};
createDatabase = mkOption {
type = types.bool;
default = true;
description = ''
Whether to create a local database automatically.
This will override every database setting in {option}`services.engelsystem.settings`.
'';
};
settings = mkOption {
type = types.attrs;
default = {
database = {
host = "localhost";
database = "engelsystem";
username = "engelsystem";
};
};
example = {
maintenance = false;
database = {
host = "database.example.com";
database = "engelsystem";
username = "engelsystem";
password._secret = "/var/keys/engelsystem/database";
};
email = {
driver = "smtp";
host = "smtp.example.com";
port = 587;
from.address = "engelsystem@example.com";
from.name = "example engelsystem";
encryption = "tls";
username = "engelsystem@example.com";
password._secret = "/var/keys/engelsystem/mail";
};
autoarrive = true;
min_password_length = 6;
default_locale = "de_DE";
};
description = ''
Options to be added to config.php, as a nix attribute set. Options containing secret data
should be set to an attribute set containing the attribute _secret - a string pointing to a
file containing the value the option should be set to. See the example to get a better
picture of this: in the resulting config.php file, the email.password key will be set to
the contents of the /var/keys/engelsystem/mail file.
See <https://engelsystem.de/doc/admin/configuration/> for available options.
Note that the admin user login credentials cannot be set here - they always default to
admin:asdfasdf. Log in and change them immediately.
'';
};
};
config = mkIf cfg.enable {
# create database
services.mysql = mkIf cfg.createDatabase {
enable = true;
package = mkDefault pkgs.mariadb;
ensureUsers = [
{
name = "engelsystem";
ensurePermissions = {
"engelsystem.*" = "ALL PRIVILEGES";
};
}
];
ensureDatabases = [ "engelsystem" ];
};
environment.etc."engelsystem/config.php".source = pkgs.writeText "config.php" ''
<?php
return json_decode(file_get_contents("/var/lib/engelsystem/config.json"), true);
'';
services.phpfpm.pools.engelsystem = {
user = "engelsystem";
settings = {
"listen.owner" = config.services.nginx.user;
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.max_requests" = 500;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 5;
"php_admin_value[error_log]" = "stderr";
"php_admin_flag[log_errors]" = true;
"catch_workers_output" = true;
};
};
services.nginx = {
enable = true;
virtualHosts."${cfg.domain}".locations = {
"/" = {
root = "${cfg.package}/share/php/engelsystem/public";
extraConfig = ''
index index.php;
try_files $uri $uri/ /index.php?$args;
autoindex off;
'';
};
"~ \\.php$" = {
root = "${cfg.package}/share/php/engelsystem/public";
extraConfig = ''
fastcgi_pass unix:${config.services.phpfpm.pools.engelsystem.socket};
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include ${config.services.nginx.package}/conf/fastcgi_params;
include ${config.services.nginx.package}/conf/fastcgi.conf;
'';
};
};
};
systemd.services."engelsystem-init" = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
};
script =
let
genConfigScript = pkgs.writeScript "engelsystem-gen-config.sh" (
utils.genJqSecretsReplacementSnippet cfg.settings "config.json"
);
in
''
umask 077
mkdir -p /var/lib/engelsystem/storage/app
mkdir -p /var/lib/engelsystem/storage/cache/views
cd /var/lib/engelsystem
${genConfigScript}
chmod 400 config.json
chown -R engelsystem .
'';
};
systemd.services."engelsystem-migrate" = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = "engelsystem";
Group = "engelsystem";
};
script = ''
versionFile="/var/lib/engelsystem/.version"
version=$(cat "$versionFile" 2>/dev/null || echo 0)
if [[ $version != ${cfg.package.version} ]]; then
# prune template cache between releases
rm -rfv /var/lib/engelsystem/storage/cache/*
${cfg.package}/bin/migrate
echo ${cfg.package.version} > "$versionFile"
fi
'';
after = [
"engelsystem-init.service"
"mysql.service"
];
};
systemd.services."phpfpm-engelsystem".after = [ "engelsystem-migrate.service" ];
users.users.engelsystem = {
isSystemUser = true;
createHome = true;
home = "/var/lib/engelsystem/storage";
group = "engelsystem";
};
users.groups.engelsystem = { };
};
}

View File

@@ -0,0 +1,178 @@
# Ente.io {#module-services-ente}
[Ente](https://ente.io/) is a service that provides a fully open source,
end-to-end encrypted platform for photos and videos.
## Quickstart {#module-services-ente-quickstart}
To host ente, you need the following things:
- S3 storage server (either external or self-hosted like [minio](https://github.com/minio/minio))
- Several subdomains pointing to your server:
- accounts.example.com
- albums.example.com
- api.example.com
- cast.example.com
- photos.example.com
- s3.example.com
The following example shows how to setup ente with a self-hosted S3 storage via minio.
You can host the minio s3 storage on the same server as ente, but as this isn't
a requirement the example shows the minio and ente setup separately.
We assume that the minio server will be reachable at `https://s3.example.com`.
```nix
{
services.minio = {
enable = true;
# ente's config must match this region!
region = "us-east-1";
# Please use a file, agenix or sops-nix to securely store your root user password!
# MINIO_ROOT_USER=your_root_user
# MINIO_ROOT_PASSWORD=a_randomly_generated_long_password
rootCredentialsFile = "/run/secrets/minio-credentials-full";
};
systemd.services.minio.environment.MINIO_SERVER_URL = "https://s3.example.com";
# Proxy for minio
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
recommendedProxySettings = true;
virtualHosts."s3.example.com" = {
forceSSL = true;
useACME = true;
locations."/".proxyPass = "http://localhost:9000";
# determine max file upload size
extraConfig = ''
client_max_body_size 16G;
proxy_buffering off;
proxy_request_buffering off;
'';
};
};
}
```
And the configuration for ente:
```nix
{
services.ente = {
web = {
enable = true;
domains = {
accounts = "accounts.example.com";
albums = "albums.example.com";
cast = "cast.example.com";
photos = "photos.example.com";
};
};
api = {
enable = true;
nginx.enable = true;
# Create a local postgres database and set the necessary config in ente
enableLocalDB = true;
domain = "api.example.com";
# You can hide secrets by setting xyz._secret = file instead of xyz = value.
# Make sure to not include any of the secrets used here directly
# in your config. They would be publicly readable in the nix store.
# Use agenix, sops-nix or an equivalent secret management solution.
settings = {
s3 = {
use_path_style_urls = true;
b2-eu-cen = {
endpoint = "https://s3.example.com";
region = "us-east-1";
bucket = "ente";
key._secret = pkgs.writeText "minio_user" "minio_user";
secret._secret = pkgs.writeText "minio_pw" "minio_pw";
};
};
key = {
# generate with: openssl rand -base64 32
encryption._secret = pkgs.writeText "encryption" "T0sn+zUVFOApdX4jJL4op6BtqqAfyQLH95fu8ASWfno=";
# generate with: openssl rand -base64 64
hash._secret = pkgs.writeText "hash" "g/dBZBs1zi9SXQ0EKr4RCt1TGr7ZCKkgrpjyjrQEKovWPu5/ce8dYM6YvMIPL23MMZToVuuG+Z6SGxxTbxg5NQ==";
};
# generate with: openssl rand -base64 32
jwt.secret._secret = pkgs.writeText "jwt" "i2DecQmfGreG6q1vBj5tCokhlN41gcfS2cjOs9Po-u8=";
};
};
};
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
recommendedProxySettings = true; # This is important!
virtualHosts."accounts.${domain}".enableACME = true;
virtualHosts."albums.${domain}".enableACME = true;
virtualHosts."api.${domain}".enableACME = true;
virtualHosts."cast.${domain}".enableACME = true;
virtualHosts."photos.${domain}".enableACME = true;
};
}
```
If you have a mail server or smtp relay, you can optionally configure
`services.ente.api.settings.smtp` so ente can send you emails (registration code and possibly
other events). This is optional.
After starting the minio server, make sure the bucket exists:
```
mc alias set minio https://s3.example.com root_user root_password --api s3v4
mc mb -p minio/ente
```
Now ente should be ready to go under `https://photos.example.com`.
## Registering users {#module-services-ente-registering-users}
Now you can open photos.example.com and register your user(s).
Beware that the first created account will be considered to be the admin account,
which among some other things allows you to use `ente-cli` to increase storage limits for any user.
If you have configured smtp, you will get a mail with a verification code,
otherwise you can find the code in the server logs.
```
journalctl -eu ente
[...]
ente # [ 157.145165] ente[982]: INFO[0141]email.go:130 sendViaTransmail Skipping sending email to a@a.a: Verification code: 134033
```
After you have registered your users, you can set
`settings.internal.disable-registration = true;` to prevent
further signups.
## Increasing storage limit {#module-services-ente-increasing-storage-limit}
By default, all users will be on the free plan which is the only plan
available. While adding new plans is possible in theory, it requires some
manual database operations which isn't worthwhile. Instead, use `ente-cli`
with your admin user to modify the storage limit.
## iOS background sync
On iOS, background sync is achived via a silent notification sent by the server
every 30 minutes that allows the phone to sync for about 30 seconds, enough for
all but the largest videos to be synced on background (if the app is brought to
foreground though, sync will resume as normal). To achive this however, a
Firebase account is needed. In the settings option, configure credentials-dir
to point towards the directory where the JSON containing the Firebase
credentials are stored.
```nix
{
# This directory should contain your fcm-service-account.json file
services.ente.api.settings = {
credentials-dir = "/path/to/credentials";
# [...]
};
}
```

View File

@@ -0,0 +1,363 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
inherit (lib)
getExe
mkDefault
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOption
optional
types
;
cfgApi = config.services.ente.api;
cfgWeb = config.services.ente.web;
webPackage =
enteApp:
cfgWeb.package.override {
inherit enteApp;
enteMainUrl = "https://${cfgWeb.domains.photos}";
extraBuildEnv = {
NEXT_PUBLIC_ENTE_ENDPOINT = "https://${cfgWeb.domains.api}";
NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT = "https://${cfgWeb.domains.albums}";
NEXT_TELEMETRY_DISABLED = "1";
};
};
defaultUser = "ente";
defaultGroup = "ente";
dataDir = "/var/lib/ente";
yamlFormat = pkgs.formats.yaml { };
in
{
options.services.ente = {
web = {
enable = mkEnableOption "Ente web frontend (Photos, Albums)";
package = mkPackageOption pkgs "ente-web" { };
domains = {
api = mkOption {
type = types.str;
example = "api.ente.example.com";
description = ''
The domain under which the api is served. This will NOT serve the api itself,
but is a required setting to host the frontends! This will automatically be set
for you if you enable both the api server and web frontends.
'';
};
accounts = mkOption {
type = types.str;
example = "accounts.ente.example.com";
description = "The domain under which the accounts frontend will be served.";
};
cast = mkOption {
type = types.str;
example = "cast.ente.example.com";
description = "The domain under which the cast frontend will be served.";
};
albums = mkOption {
type = types.str;
example = "albums.ente.example.com";
description = "The domain under which the albums frontend will be served.";
};
photos = mkOption {
type = types.str;
example = "photos.ente.example.com";
description = "The domain under which the photos frontend will be served.";
};
};
};
api = {
enable = mkEnableOption "Museum (API server for ente.io)";
package = mkPackageOption pkgs "museum" { };
nginx.enable = mkEnableOption "nginx proxy for the API server";
user = mkOption {
type = types.str;
default = defaultUser;
description = "User under which museum runs. If you set this option you must make sure the user exists.";
};
group = mkOption {
type = types.str;
default = defaultGroup;
description = "Group under which museum runs. If you set this option you must make sure the group exists.";
};
domain = mkOption {
type = types.str;
example = "api.ente.example.com";
description = "The domain under which the api will be served.";
};
enableLocalDB = mkEnableOption "the automatic creation of a local postgres database for museum.";
settings = mkOption {
description = ''
Museum yaml configuration. Refer to upstream [local.yaml](https://github.com/ente-io/ente/blob/main/server/configurations/local.yaml) for more information.
You can specify secret values in this configuration by setting `somevalue._secret = "/path/to/file"` instead of setting `somevalue` directly.
'';
default = { };
type = types.submodule {
freeformType = yamlFormat.type;
options = {
apps = {
public-albums = mkOption {
type = types.str;
default = "https://albums.ente.io";
description = ''
If you're running a self hosted instance and wish to serve public links,
set this to the URL where your albums web app is running.
'';
};
cast = mkOption {
type = types.str;
default = "https://cast.ente.io";
description = ''
Set this to the URL where your cast page is running.
This is for browser and chromecast casting support.
'';
};
accounts = mkOption {
type = types.str;
default = "https://accounts.ente.io";
description = ''
Set this to the URL where your accounts page is running.
This is primarily for passkey support.
'';
};
};
db = {
host = mkOption {
type = types.str;
description = "The database host";
};
port = mkOption {
type = types.port;
default = 5432;
description = "The database port";
};
name = mkOption {
type = types.str;
description = "The database name";
};
user = mkOption {
type = types.str;
description = "The database user";
};
};
};
};
};
};
};
config = mkMerge [
(mkIf cfgApi.enable {
services.postgresql = mkIf cfgApi.enableLocalDB {
enable = true;
ensureUsers = [
{
name = "ente";
ensureDBOwnership = true;
}
];
ensureDatabases = [ "ente" ];
};
services.ente.web.domains.api = mkIf cfgWeb.enable cfgApi.domain;
services.ente.api.settings = {
# This will cause logs to be written to stdout/err, which then end up in the journal
log-file = mkDefault "";
db = mkIf cfgApi.enableLocalDB {
host = "/run/postgresql";
port = 5432;
name = "ente";
user = "ente";
};
};
systemd.services.ente = {
description = "Ente.io Museum API Server";
after = [ "network.target" ] ++ optional cfgApi.enableLocalDB "postgresql.service";
requires = optional cfgApi.enableLocalDB "postgresql.service";
wantedBy = [ "multi-user.target" ];
preStart = ''
# Generate config including secret values. YAML is a superset of JSON, so we can use this here.
${utils.genJqSecretsReplacementSnippet cfgApi.settings "/run/ente/local.yaml"}
# Setup paths
mkdir -p ${dataDir}/configurations
ln -sTf /run/ente/local.yaml ${dataDir}/configurations/local.yaml
'';
serviceConfig = {
ExecStart = getExe cfgApi.package;
Type = "simple";
Restart = "on-failure";
AmbientCapabilities = [ ];
CapabilityBoundingSet = [ ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = false;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
UMask = "077";
BindReadOnlyPaths = [
"${cfgApi.package}/share/museum/migrations:${dataDir}/migrations"
"${cfgApi.package}/share/museum/mail-templates:${dataDir}/mail-templates"
"${cfgApi.package}/share/museum/web-templates:${dataDir}/web-templates"
];
User = cfgApi.user;
Group = cfgApi.group;
SyslogIdentifier = "ente";
StateDirectory = "ente";
WorkingDirectory = dataDir;
RuntimeDirectory = "ente";
};
# Environment MUST be called local, otherwise we cannot log to stdout
environment = {
ENVIRONMENT = "local";
GIN_MODE = "release";
};
};
users = {
users = mkIf (cfgApi.user == defaultUser) {
${defaultUser} = {
description = "ente.io museum service user";
inherit (cfgApi) group;
isSystemUser = true;
home = dataDir;
};
};
groups = mkIf (cfgApi.group == defaultGroup) { ${defaultGroup} = { }; };
};
services.nginx = mkIf cfgApi.nginx.enable {
enable = true;
upstreams.museum = {
servers."localhost:8080" = { };
extraConfig = ''
zone museum 64k;
keepalive 20;
'';
};
virtualHosts.${cfgApi.domain} = {
forceSSL = mkDefault true;
locations."/".proxyPass = "http://museum";
extraConfig = ''
client_max_body_size 4M;
'';
};
};
})
(mkIf cfgWeb.enable {
services.ente.api.settings = mkIf cfgApi.enable {
apps = {
accounts = "https://${cfgWeb.domains.accounts}";
cast = "https://${cfgWeb.domains.cast}";
public-albums = "https://${cfgWeb.domains.albums}";
};
webauthn = {
rpid = cfgWeb.domains.accounts;
rporigins = [ "https://${cfgWeb.domains.accounts}" ];
};
};
services.nginx =
let
domainFor = app: cfgWeb.domains.${app};
in
{
enable = true;
virtualHosts.${domainFor "accounts"} = {
forceSSL = mkDefault true;
locations."/" = {
root = webPackage "accounts";
tryFiles = "$uri $uri.html /index.html";
extraConfig = ''
add_header Access-Control-Allow-Origin 'https://${cfgWeb.domains.api}';
'';
};
};
virtualHosts.${domainFor "cast"} = {
forceSSL = mkDefault true;
locations."/" = {
root = webPackage "cast";
tryFiles = "$uri $uri.html /index.html";
extraConfig = ''
add_header Access-Control-Allow-Origin 'https://${cfgWeb.domains.api}';
'';
};
};
virtualHosts.${domainFor "photos"} = {
serverAliases = [
(domainFor "albums") # the albums app is shared with the photos frontend
];
forceSSL = mkDefault true;
locations."/" = {
root = webPackage "photos";
tryFiles = "$uri $uri.html /index.html";
extraConfig = ''
add_header Access-Control-Allow-Origin 'https://${cfgWeb.domains.api}';
'';
};
};
};
})
];
meta.maintainers = with lib.maintainers; [ oddlama ];
}

View File

@@ -0,0 +1,63 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.ethercalc;
in
{
options = {
services.ethercalc = {
enable = mkOption {
default = false;
type = types.bool;
description = ''
ethercalc, an online collaborative spreadsheet server.
Persistent state will be maintained under
{file}`/var/lib/ethercalc`. Upstream supports using a
redis server for storage and recommends the redis backend for
intensive use; however, the Nix module doesn't currently support
redis.
Note that while ethercalc is a good and robust project with an active
issue tracker, there haven't been new commits since the end of 2020.
'';
};
package = mkPackageOption pkgs "ethercalc" { };
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Address to listen on (use 0.0.0.0 to allow access from any address).";
};
port = mkOption {
type = types.port;
default = 8000;
description = "Port to bind to.";
};
};
};
config = mkIf cfg.enable {
systemd.services.ethercalc = {
description = "Ethercalc service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
DynamicUser = true;
ExecStart = "${cfg.package}/bin/ethercalc --host ${cfg.host} --port ${toString cfg.port}";
Restart = "always";
StateDirectory = "ethercalc";
WorkingDirectory = "/var/lib/ethercalc";
};
};
};
}

View File

@@ -0,0 +1,128 @@
{
lib,
pkgs,
config,
...
}:
let
cfg = config.services.fediwall;
pkg = cfg.package.override { conf = cfg.settings; };
format = pkgs.formats.json { };
in
{
options.services.fediwall = {
enable = lib.mkEnableOption "fediwall, a social media wall for the fediverse";
package = lib.mkPackageOption pkgs "fediwall" { };
hostName = lib.mkOption {
type = lib.types.str;
default = config.networking.fqdnOrHostName;
defaultText = lib.literalExpression "config.networking.fqdnOrHostName";
example = "fediwall.example.org";
description = "The hostname to serve fediwall on.";
};
settings = lib.mkOption {
default = { };
description = ''
Fediwall configuration. See
https://github.com/defnull/fediwall/blob/main/public/wall-config.json.example
for information on supported values.
'';
type = lib.types.submodule {
freeformType = format.type;
options = {
servers = lib.mkOption {
type = with lib.types; listOf str;
default = [ "mastodon.social" ];
description = "Servers to load posts from";
};
tags = lib.mkOption {
type = with lib.types; listOf str;
default = [ ];
example = lib.literalExpression "[ \"cats\" \"dogs\"]";
description = "Tags to follow";
};
loadPublic = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Load public posts";
};
loadFederated = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Load federated posts";
};
loadTrends = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Load trending posts";
};
hideSensitive = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Hide sensitive (potentially NSFW) posts";
};
hideBots = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Hide posts from bot accounts";
};
hideReplies = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Hide replies";
};
hideBoosts = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Hide boosts";
};
showMedia = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Show media in posts";
};
playVideos = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Autoplay videos in posts";
};
};
};
};
nginx = lib.mkOption {
type = lib.types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { }
);
default = { };
example = lib.literalExpression ''
{
serverAliases = [
"fedi.''${config.networking.domain}"
];
# Enable TLS and use let's encrypt for ACME
forceSSL = true;
enableACME = true;
}
'';
description = "Allows customizing the nginx virtualHost settings";
};
};
config = lib.mkIf cfg.enable {
services.nginx = {
enable = lib.mkDefault true;
virtualHosts."${cfg.hostName}" = lib.mkMerge [
cfg.nginx
{
root = lib.mkForce "${pkg}";
locations = {
"/" = {
index = "index.html";
};
};
}
];
};
};
}

View File

@@ -0,0 +1,124 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.fider;
fiderCmd = lib.getExe cfg.package;
in
{
options = {
services.fider = {
enable = lib.mkEnableOption "the Fider server";
package = lib.mkPackageOption pkgs "fider" { };
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/fider";
description = "Default data folder for Fider.";
example = "/mnt/fider";
};
database = {
url = lib.mkOption {
type = lib.types.str;
default = "local";
description = ''
URI to use for the main PostgreSQL database. If this needs to include
credentials that shouldn't be world-readable in the Nix store, set an
environment file on the systemd service and override the
`DATABASE_URL` entry. Pass the string
`local` to setup a database on the local server.
'';
};
};
environment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = {
PORT = "31213";
BASE_URL = "https://fider.example.com";
EMAIL = "smtp";
EMAIL_NOREPLY = "fider@example.com";
EMAIL_SMTP_USERNAME = "fider@example.com";
EMAIL_SMTP_HOST = "mail.example.com";
EMAIL_SMTP_PORT = "587";
BLOB_STORAGE = "fs";
};
description = ''
Environment variables to set for the service. Secrets should be
specified using {option}`environmentFiles`.
Refer to <https://github.com/getfider/fider/blob/stable/.example.env>
and <https://github.com/getfider/fider/blob/stable/app/pkg/env/env.go>
for available options.
'';
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
example = "/run/secrets/fider.env";
description = ''
Files to load environment variables from. Loaded variables override
values set in {option}`environment`.
'';
};
};
};
config = lib.mkIf cfg.enable {
services.postgresql = lib.mkIf (cfg.database.url == "local") {
enable = true;
ensureUsers = [
{
name = "fider";
ensureDBOwnership = true;
}
];
ensureDatabases = [ "fider" ];
};
systemd.services.fider = {
description = "Fider server";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
]
++ lib.optionals (cfg.database.url == "local") [ "postgresql.target" ];
requires = lib.optionals (cfg.database.url == "local") [ "postgresql.target" ];
environment =
let
localPostgresqlUrl = "postgres:///fider?host=/run/postgresql";
in
{
DATABASE_URL = if (cfg.database.url == "local") then localPostgresqlUrl else cfg.database.url;
BLOB_STORAGE_FS_PATH = "${cfg.dataDir}";
}
// cfg.environment;
serviceConfig = {
ExecStartPre = "${fiderCmd} migrate";
ExecStart = fiderCmd;
StateDirectory = "fider";
DynamicUser = true;
PrivateTmp = "yes";
Restart = "on-failure";
RuntimeDirectory = "fider";
RuntimeDirectoryPreserve = true;
CacheDirectory = "fider";
WorkingDirectory = "${cfg.package}";
EnvironmentFile = cfg.environmentFiles;
};
};
};
meta = {
maintainers = with lib.maintainers; [
niklaskorz
];
# doc = ./fider.md;
};
}

View File

@@ -0,0 +1,167 @@
{
config,
pkgs,
lib,
utils,
...
}:
let
cfg = config.services.filebrowser;
format = pkgs.formats.json { };
inherit (lib) types;
in
{
options = {
services.filebrowser = {
enable = lib.mkEnableOption "FileBrowser";
package = lib.mkPackageOption pkgs "filebrowser" { };
user = lib.mkOption {
type = types.str;
default = "filebrowser";
description = "User account under which FileBrowser runs.";
};
group = lib.mkOption {
type = types.str;
default = "filebrowser";
description = "Group under which FileBrowser runs.";
};
openFirewall = lib.mkEnableOption "opening firewall ports for FileBrowser";
settings = lib.mkOption {
default = { };
description = ''
Settings for FileBrowser.
Refer to <https://filebrowser.org/cli/filebrowser#options> for all supported values.
'';
type = types.submodule {
freeformType = format.type;
options = {
address = lib.mkOption {
default = "localhost";
description = ''
The address to listen on.
'';
type = types.str;
};
port = lib.mkOption {
default = 8080;
description = ''
The port to listen on.
'';
type = types.port;
};
root = lib.mkOption {
default = "/var/lib/filebrowser/data";
description = ''
The directory where FileBrowser stores files.
'';
type = types.path;
};
database = lib.mkOption {
default = "/var/lib/filebrowser/database.db";
description = ''
The path to FileBrowser's Bolt database.
'';
type = types.path;
};
cache-dir = lib.mkOption {
default = "/var/cache/filebrowser";
description = ''
The directory where FileBrowser stores its cache.
'';
type = types.path;
readOnly = true;
};
};
};
};
};
};
config = lib.mkIf cfg.enable {
systemd = {
services.filebrowser = {
after = [ "network.target" ];
description = "FileBrowser";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart =
let
args = [
(lib.getExe cfg.package)
"--config"
(format.generate "config.json" cfg.settings)
];
in
utils.escapeSystemdExecArgs args;
StateDirectory = "filebrowser";
CacheDirectory = "filebrowser";
WorkingDirectory = cfg.settings.root;
User = cfg.user;
Group = cfg.group;
UMask = "0077";
NoNewPrivileges = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
MemoryDenyWriteExecute = true;
LockPersonality = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
DevicePolicy = "closed";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
};
};
tmpfiles.settings.filebrowser = {
"${cfg.settings.root}".d = {
inherit (cfg) user group;
mode = "0700";
};
"${cfg.settings.cache-dir}".d = {
inherit (cfg) user group;
mode = "0700";
};
"${builtins.dirOf cfg.settings.database}".d = {
inherit (cfg) user group;
mode = "0700";
};
};
};
users.users = lib.mkIf (cfg.user == "filebrowser") {
filebrowser = {
inherit (cfg) group;
isSystemUser = true;
};
};
users.groups = lib.mkIf (cfg.group == "filebrowser") {
filebrowser = { };
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.settings.port ];
};
meta.maintainers = [
lib.maintainers.lukaswrz
];
}

View File

@@ -0,0 +1,55 @@
# FileSender {#module-services-filesender}
[FileSender](https://filesender.org/software/) is a software that makes it easy to send and receive big files.
## Quickstart {#module-services-filesender-quickstart}
FileSender uses [SimpleSAMLphp](https://simplesamlphp.org/) for authentication, which needs to be configured separately.
Minimal working instance of FileSender that uses password-authentication would look like this:
```nix
let
format = pkgs.formats.php { };
in
{
networking.firewall.allowedTCPPorts = [
80
443
];
services.filesender = {
enable = true;
localDomain = "filesender.example.com";
configureNginx = true;
database.createLocally = true;
settings = {
auth_sp_saml_authentication_source = "default";
auth_sp_saml_uid_attribute = "uid";
storage_filesystem_path = "<STORAGE PATH FOR UPLOADED FILES>";
admin = "admin";
admin_email = "admin@example.com";
email_reply_to = "noreply@example.com";
};
};
services.simplesamlphp.filesender = {
settings = {
"module.enable".exampleauth = true;
};
authSources = {
admin = [ "core:AdminPassword" ];
default = format.lib.mkMixedArray [ "exampleauth:UserPass" ] {
"admin:admin123" = {
uid = [ "admin" ];
cn = [ "admin" ];
mail = [ "admin@example.com" ];
};
};
};
};
}
```
::: {.warning}
Example above uses hardcoded clear-text password, in production you should use other authentication method like LDAP. You can check supported authentication methods [in SimpleSAMLphp documentation](https://simplesamlphp.org/docs/stable/simplesamlphp-idp.html).
:::

View File

@@ -0,0 +1,254 @@
{
config,
lib,
pkgs,
...
}:
let
format = pkgs.formats.php { finalVariable = "config"; };
cfg = config.services.filesender;
simpleSamlCfg = config.services.simplesamlphp.filesender;
fpm = config.services.phpfpm.pools.filesender;
filesenderConfigDirectory = pkgs.runCommand "filesender-config" { } ''
mkdir $out
cp ${format.generate "config.php" cfg.settings} $out/config.php
'';
in
{
meta = {
maintainers = with lib.maintainers; [ nhnn ];
doc = ./filesender.md;
};
options.services.filesender = with lib; {
enable = mkEnableOption "FileSender";
package = mkPackageOption pkgs "filesender" { };
user = mkOption {
description = "User under which filesender runs.";
type = types.str;
default = "filesender";
};
database = {
createLocally = mkOption {
type = types.bool;
default = true;
description = ''
Create the PostgreSQL database and database user locally.
'';
};
hostname = mkOption {
type = types.str;
default = "/run/postgresql";
description = "Database hostname.";
};
port = mkOption {
type = types.port;
default = 5432;
description = "Database port.";
};
name = mkOption {
type = types.str;
default = "filesender";
description = "Database name.";
};
user = mkOption {
type = types.str;
default = "filesender";
description = "Database user.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/filesender-dbpassword";
description = ''
A file containing the password corresponding to
[](#opt-services.filesender.database.user).
'';
};
};
settings = mkOption {
type = types.submodule {
freeformType = format.type;
options = {
site_url = mkOption {
type = types.str;
description = "Site URL. Used in emails, to build URLs for logging in, logging out, build URL for upload endpoint for web workers, to include scripts etc.";
};
admin = mkOption {
type = types.commas;
description = ''
UIDs (as per the configured saml_uid_attribute) of FileSender administrators.
Accounts with these UIDs can access the Admin page through the web UI.
'';
};
admin_email = mkOption {
type = types.commas;
description = ''
Email address of FileSender administrator(s).
Emails regarding disk full etc. are sent here.
You should use a role-address here.
'';
};
storage_filesystem_path = mkOption {
type = types.nullOr types.str;
description = "When using storage type filesystem this is the absolute path to the file system where uploaded files are stored until they expire. Your FileSender storage root.";
};
log_facilities = mkOption {
type = format.type;
default = [ { type = "error_log"; } ];
description = "Defines where FileSender logging is sent. You can sent logging to a file, to syslog or to the default PHP log facility (as configured through your webserver's PHP module). The directive takes an array of one or more logging targets. Logging can be sent to multiple targets simultaneously. Each logging target is a list containing the name of the logging target and a number of attributes which vary per log target. See below for the exact definiation of each log target.";
};
};
};
default = { };
description = ''
Configuration options used by FileSender.
See [](https://docs.filesender.org/filesender/v2.0/admin/configuration/)
for available options.
'';
};
configureNginx = mkOption {
type = types.bool;
default = true;
description = "Configure nginx as a reverse proxy for FileSender.";
};
localDomain = mkOption {
type = types.str;
example = "filesender.example.org";
description = "The domain serving your FileSender instance.";
};
poolSettings = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = "32";
"pm.start_servers" = "2";
"pm.min_spare_servers" = "2";
"pm.max_spare_servers" = "4";
"pm.max_requests" = "500";
};
description = ''
Options for FileSender's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives.
'';
};
};
config = lib.mkIf cfg.enable {
services.simplesamlphp.filesender = {
phpfpmPool = "filesender";
localDomain = cfg.localDomain;
settings.baseurlpath = lib.mkDefault "https://${cfg.localDomain}/saml";
};
services.phpfpm = {
pools.filesender = {
user = cfg.user;
group = config.services.nginx.group;
phpEnv = {
FILESENDER_CONFIG_DIR = toString filesenderConfigDirectory;
SIMPLESAMLPHP_CONFIG_DIR = toString simpleSamlCfg.configDir;
};
settings = {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
// cfg.poolSettings;
};
};
services.nginx = lib.mkIf cfg.configureNginx {
enable = true;
virtualHosts.${cfg.localDomain} = {
root = "${cfg.package}/www";
extraConfig = ''
index index.php;
'';
locations = {
"/".extraConfig = ''
try_files $uri $uri/ /index.php?args;
'';
"~ [^/]\\.php(/|$)" = {
extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${fpm.socket};
include ${pkgs.nginx}/conf/fastcgi.conf;
fastcgi_intercept_errors on;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
'';
};
"~ /\\.".extraConfig = "deny all;";
};
};
};
services.postgresql = lib.mkIf cfg.database.createLocally {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensureDBOwnership = true;
}
];
};
services.filesender.settings = lib.mkMerge [
(lib.mkIf cfg.database.createLocally {
db_host = "/run/postgresql";
db_port = "5432";
db_password = "."; # FileSender requires it even when on UNIX socket auth.
})
(lib.mkIf (!cfg.database.createLocally) {
db_host = cfg.database.hostname;
db_port = toString cfg.database.port;
db_password = format.lib.mkRaw "file_get_contents('${cfg.database.passwordFile}')";
})
{
site_url = lib.mkDefault "https://${cfg.localDomain}";
db_type = "pgsql";
db_username = cfg.database.user;
db_database = cfg.database.name;
"auth_sp_saml_simplesamlphp_url" = "/saml";
"auth_sp_saml_simplesamlphp_location" = "${simpleSamlCfg.libDir}";
}
];
systemd.services.filesender-initdb = {
description = "Init filesender DB";
wantedBy = [
"multi-user.target"
"phpfpm-filesender.service"
];
after = [ "postgresql.target" ];
restartIfChanged = true;
serviceConfig = {
Environment = [
"FILESENDER_CONFIG_DIR=${toString filesenderConfigDirectory}"
"SIMPLESAMLPHP_CONFIG_DIR=${toString simpleSamlCfg.configDir}"
];
Type = "oneshot";
Group = config.services.nginx.group;
User = "filesender";
ExecStart = "${fpm.phpPackage}/bin/php ${cfg.package}/scripts/upgrade/database.php";
};
};
users.extraUsers.filesender = lib.mkIf (cfg.user == "filesender") {
home = "/var/lib/filesender";
group = config.services.nginx.group;
createHome = true;
isSystemUser = true;
};
};
}

View File

@@ -0,0 +1,303 @@
{
pkgs,
config,
lib,
...
}:
let
cfg = config.services.firefly-iii-data-importer;
user = cfg.user;
group = cfg.group;
defaultUser = "firefly-iii-data-importer";
defaultGroup = "firefly-iii-data-importer";
artisan = "${cfg.package}/artisan";
env-file-values = lib.attrsets.mapAttrs' (
n: v: lib.attrsets.nameValuePair (lib.strings.removeSuffix "_FILE" n) v
) (lib.attrsets.filterAttrs (n: v: lib.strings.hasSuffix "_FILE" n) cfg.settings);
env-nonfile-values = lib.attrsets.filterAttrs (n: v: !lib.strings.hasSuffix "_FILE" n) cfg.settings;
data-importer-maintenance = pkgs.writeShellScript "data-importer-maintenance.sh" ''
set -a
${lib.strings.toShellVars env-nonfile-values}
${lib.strings.concatLines (
lib.attrsets.mapAttrsToList (n: v: "${n}=\"$(< ${v})\"") env-file-values
)}
set +a
${artisan} package:discover
rm ${cfg.dataDir}/cache/*.php
${artisan} config:cache
'';
commonServiceConfig = {
Type = "oneshot";
User = user;
Group = group;
StateDirectory = "firefly-iii-data-importer";
ReadWritePaths = [ cfg.dataDir ];
WorkingDirectory = cfg.package;
PrivateTmp = true;
PrivateDevices = true;
CapabilityBoundingSet = "";
AmbientCapabilities = "";
ProtectSystem = "strict";
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
ProtectHome = "tmpfs";
ProtectKernelLogs = true;
ProtectProc = "invisible";
ProcSubset = "pid";
PrivateNetwork = false;
RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service @resources"
"~@obsolete @privileged"
];
RestrictSUIDSGID = true;
RemoveIPC = true;
NoNewPrivileges = true;
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
PrivateUsers = true;
};
in
{
options.services.firefly-iii-data-importer = {
enable = lib.mkEnableOption "Firefly III Data Importer";
user = lib.mkOption {
type = lib.types.str;
default = defaultUser;
description = "User account under which firefly-iii-data-importer runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = if cfg.enableNginx then "nginx" else defaultGroup;
defaultText = "If `services.firefly-iii-data-importer.enableNginx` is true then `nginx` else ${defaultGroup}";
description = ''
Group under which firefly-iii-data-importer runs. It is best to set this to the group
of whatever webserver is being used as the frontend.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/firefly-iii-data-importer";
description = ''
The place where firefly-iii data importer stores its state.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.firefly-iii-data-importer;
defaultText = lib.literalExpression "pkgs.firefly-iii-data-importer";
description = ''
The firefly-iii-data-importer package served by php-fpm and the webserver of choice.
This option can be used to point the webserver to the correct root. It
may also be used to set the package to a different version, say a
development version.
'';
apply =
firefly-iii-data-importer:
firefly-iii-data-importer.override (prev: {
dataDir = cfg.dataDir;
});
};
enableNginx = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable nginx or not. If enabled, an nginx virtual host will
be created for access to firefly-iii data importer. If not enabled, then you may use
`''${config.services.firefly-iii-data-importer.package}` as your document root in
whichever webserver you wish to setup.
'';
};
virtualHost = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
The hostname at which you wish firefly-iii-data-importer to be served. If you have
enabled nginx using `services.firefly-iii-data-importer.enableNginx` then this will
be used.
'';
};
poolConfig = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = { };
defaultText = lib.literalExpression ''
{
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
}
'';
description = ''
Options for the Firefly III Data Importer PHP pool. See the documentation on <literal>php-fpm.conf</literal>
for details on configuration directives.
'';
};
settings = lib.mkOption {
default = { };
description = ''
Options for firefly-iii data importer configuration. Refer to
<https://github.com/firefly-iii/data-importer/blob/main/.env.example> for
details on supported values. All <option>_FILE values supported by
upstream are supported here.
APP_URL will be the same as `services.firefly-iii-data-importer.virtualHost` if the
former is unset in `services.firefly-iii-data-importer.settings`.
'';
example = lib.literalExpression ''
{
APP_ENV = "local";
LOG_CHANNEL = "syslog";
FIREFLY_III_ACCESS_TOKEN= = "/var/secrets/firefly-iii-access-token.txt";
}
'';
type = lib.types.submodule {
freeformType = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
};
};
};
config = lib.mkIf cfg.enable {
services.phpfpm.pools.firefly-iii-data-importer = {
inherit user group;
phpPackage = cfg.package.phpPackage;
phpOptions = ''
log_errors = on
'';
settings = {
"listen.mode" = "0660";
"listen.owner" = user;
"listen.group" = group;
"pm" = lib.mkDefault "dynamic";
"pm.max_children" = lib.mkDefault 32;
"pm.start_servers" = lib.mkDefault 2;
"pm.min_spare_servers" = lib.mkDefault 2;
"pm.max_spare_servers" = lib.mkDefault 4;
"pm.max_requests" = lib.mkDefault 500;
}
// cfg.poolConfig;
};
systemd.services.firefly-iii-data-importer-setup = {
requiredBy = [ "phpfpm-firefly-iii-data-importer.service" ];
before = [ "phpfpm-firefly-iii-data-importer.service" ];
serviceConfig = {
ExecStart = data-importer-maintenance;
RemainAfterExit = true;
}
// commonServiceConfig;
unitConfig.JoinsNamespaceOf = "phpfpm-firefly-iii-data-importer.service";
restartTriggers = [ cfg.package ];
};
services.nginx = lib.mkIf cfg.enableNginx {
enable = true;
recommendedTlsSettings = lib.mkDefault true;
recommendedOptimisation = lib.mkDefault true;
recommendedGzipSettings = lib.mkDefault true;
virtualHosts.${cfg.virtualHost} = {
root = "${cfg.package}/public";
locations = {
"/" = {
tryFiles = "$uri $uri/ /index.php?$query_string";
index = "index.php";
extraConfig = ''
sendfile off;
'';
};
"~ \\.php$" = {
extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params ;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_param modHeadersAvailable true;
fastcgi_pass unix:${config.services.phpfpm.pools.firefly-iii-data-importer.socket};
'';
};
};
};
};
systemd.tmpfiles.settings."10-firefly-iii-data-importer" =
lib.attrsets.genAttrs
[
"${cfg.dataDir}/storage"
"${cfg.dataDir}/storage/app"
"${cfg.dataDir}/storage/app/public"
"${cfg.dataDir}/storage/configurations"
"${cfg.dataDir}/storage/conversion-routines"
"${cfg.dataDir}/storage/debugbar"
"${cfg.dataDir}/storage/framework"
"${cfg.dataDir}/storage/framework/cache"
"${cfg.dataDir}/storage/framework/sessions"
"${cfg.dataDir}/storage/framework/testing"
"${cfg.dataDir}/storage/framework/views"
"${cfg.dataDir}/storage/jobs"
"${cfg.dataDir}/storage/logs"
"${cfg.dataDir}/storage/submission-routines"
"${cfg.dataDir}/storage/uploads"
"${cfg.dataDir}/cache"
]
(n: {
d = {
group = group;
mode = "0710";
user = user;
};
})
// {
"${cfg.dataDir}".d = {
group = group;
mode = "0700";
user = user;
};
};
users = {
users = lib.mkIf (user == defaultUser) {
${defaultUser} = {
description = "Firefly-iii Data Importer service user";
inherit group;
isSystemUser = true;
home = cfg.dataDir;
};
};
groups = lib.mkIf (group == defaultGroup) { ${defaultGroup} = { }; };
};
};
}

View File

@@ -0,0 +1,421 @@
{
pkgs,
config,
lib,
...
}:
let
cfg = config.services.firefly-iii;
user = cfg.user;
group = cfg.group;
defaultUser = "firefly-iii";
defaultGroup = "firefly-iii";
artisan = "${cfg.package}/artisan";
env-file-values = lib.attrsets.mapAttrs' (
n: v: lib.attrsets.nameValuePair (lib.strings.removeSuffix "_FILE" n) v
) (lib.attrsets.filterAttrs (n: v: lib.strings.hasSuffix "_FILE" n) cfg.settings);
env-nonfile-values = lib.attrsets.filterAttrs (n: v: !lib.strings.hasSuffix "_FILE" n) cfg.settings;
firefly-iii-maintenance = pkgs.writeShellScript "firefly-iii-maintenance.sh" ''
set -a
${lib.strings.toShellVars env-nonfile-values}
${lib.strings.concatLines (
lib.attrsets.mapAttrsToList (n: v: "${n}=\"$(< ${v})\"") env-file-values
)}
set +a
${lib.optionalString (
cfg.settings.DB_CONNECTION == "sqlite"
) "touch ${cfg.dataDir}/storage/database/database.sqlite"}
${artisan} optimize:clear
rm ${cfg.dataDir}/cache/*.php
${artisan} package:discover
${artisan} firefly-iii:upgrade-database
${artisan} firefly-iii:laravel-passport-keys
${artisan} view:cache
${artisan} route:cache
${artisan} config:cache
'';
commonServiceConfig = {
Type = "oneshot";
User = user;
Group = group;
StateDirectory = "firefly-iii";
ReadWritePaths = [ cfg.dataDir ];
WorkingDirectory = cfg.package;
PrivateTmp = true;
PrivateDevices = true;
CapabilityBoundingSet = "";
AmbientCapabilities = "";
ProtectSystem = "strict";
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
ProtectHome = "tmpfs";
ProtectKernelLogs = true;
ProtectProc = "invisible";
ProcSubset = "pid";
PrivateNetwork = false;
RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service @resources"
"~@obsolete @privileged"
];
RestrictSUIDSGID = true;
RemoveIPC = true;
NoNewPrivileges = true;
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
PrivateUsers = true;
};
in
{
options.services.firefly-iii = {
enable = lib.mkEnableOption "Firefly III: A free and open source personal finance manager";
user = lib.mkOption {
type = lib.types.str;
default = defaultUser;
description = "User account under which firefly-iii runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = if cfg.enableNginx then "nginx" else defaultGroup;
defaultText = "If `services.firefly-iii.enableNginx` is true then `nginx` else ${defaultGroup}";
description = ''
Group under which firefly-iii runs. It is best to set this to the group
of whatever webserver is being used as the frontend.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/firefly-iii";
description = ''
The place where firefly-iii stores its state.
'';
};
package =
lib.mkPackageOption pkgs "firefly-iii" { }
// lib.mkOption {
apply =
firefly-iii:
firefly-iii.override (prev: {
dataDir = cfg.dataDir;
});
};
enableNginx = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable nginx or not. If enabled, an nginx virtual host will
be created for access to firefly-iii. If not enabled, then you may use
`''${config.services.firefly-iii.package}` as your document root in
whichever webserver you wish to setup.
'';
};
virtualHost = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
The hostname at which you wish firefly-iii to be served. If you have
enabled nginx using `services.firefly-iii.enableNginx` then this will
be used.
'';
};
poolConfig = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = { };
defaultText = ''
{
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
}
'';
description = ''
Options for the Firefly III PHP pool. See the documentation on <literal>php-fpm.conf</literal>
for details on configuration directives.
'';
};
settings = lib.mkOption {
default = { };
description = ''
Options for firefly-iii configuration. Refer to
<https://github.com/firefly-iii/firefly-iii/blob/main/.env.example> for
details on supported values. All <option>_FILE values supported by
upstream are supported here.
APP_URL will be the same as `services.firefly-iii.virtualHost` if the
former is unset in `services.firefly-iii.settings`.
'';
example = lib.literalExpression ''
{
APP_ENV = "production";
APP_KEY_FILE = "/var/secrets/firefly-iii-app-key.txt";
SITE_OWNER = "mail@example.com";
DB_CONNECTION = "mysql";
DB_HOST = "db";
DB_PORT = 3306;
DB_DATABASE = "firefly";
DB_USERNAME = "firefly";
DB_PASSWORD_FILE = "/var/secrets/firefly-iii-mysql-password.txt";
}
'';
type = lib.types.submodule {
freeformType = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
options = {
DB_CONNECTION = lib.mkOption {
type = lib.types.enum [
"sqlite"
"pgsql"
"mysql"
];
default = "sqlite";
example = "pgsql";
description = ''
The type of database you wish to use. Can be one of "sqlite",
"mysql" or "pgsql".
'';
};
APP_ENV = lib.mkOption {
type = lib.types.enum [
"local"
"production"
"testing"
];
default = "local";
example = "production";
description = ''
The app environment. It is recommended to keep this at "local".
Possible values are "local", "production" and "testing"
'';
};
DB_PORT = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default =
if cfg.settings.DB_CONNECTION == "pgsql" then
5432
else if cfg.settings.DB_CONNECTION == "mysql" then
3306
else
null;
defaultText = ''
`null` if DB_CONNECTION is "sqlite", `3306` if "mysql", `5432` if "pgsql"
'';
description = ''
The port your database is listening at. sqlite does not require
this value to be filled.
'';
};
DB_HOST = lib.mkOption {
type = lib.types.str;
default = if cfg.settings.DB_CONNECTION == "pgsql" then "/run/postgresql" else "localhost";
defaultText = ''
"localhost" if DB_CONNECTION is "sqlite" or "mysql", "/run/postgresql" if "pgsql".
'';
description = ''
The machine which hosts your database. This is left at the
default value for "mysql" because we use the "DB_SOCKET" option
to connect to a unix socket instead. "pgsql" requires that the
unix socket location be specified here instead of at "DB_SOCKET".
This option does not affect "sqlite".
'';
};
APP_KEY_FILE = lib.mkOption {
type = lib.types.path;
description = ''
The path to your appkey. The file should contain a 32 character
random app key. This may be set using `echo "base64:$(head -c 32
/dev/urandom | base64)" > /path/to/key-file`.
'';
};
APP_URL = lib.mkOption {
type = lib.types.str;
default =
if cfg.virtualHost == "localhost" then
"http://${cfg.virtualHost}"
else
"https://${cfg.virtualHost}";
defaultText = ''
http(s)://''${config.services.firefly-iii.virtualHost}
'';
description = ''
The APP_URL used by firefly-iii internally. Please make sure this
URL matches the external URL of your Firefly III installation. It
is used to validate specific requests and to generate URLs in
emails.
'';
};
};
};
};
};
config = lib.mkIf cfg.enable {
services.phpfpm.pools.firefly-iii = {
inherit user group;
phpPackage = cfg.package.phpPackage;
phpOptions = ''
log_errors = on
'';
settings = {
"listen.mode" = lib.mkDefault "0660";
"listen.owner" = lib.mkDefault user;
"listen.group" = lib.mkDefault group;
"pm" = lib.mkDefault "dynamic";
"pm.max_children" = lib.mkDefault 32;
"pm.start_servers" = lib.mkDefault 2;
"pm.min_spare_servers" = lib.mkDefault 2;
"pm.max_spare_servers" = lib.mkDefault 4;
"pm.max_requests" = lib.mkDefault 500;
}
// cfg.poolConfig;
};
systemd.services.firefly-iii-setup = {
after = [
"postgresql.target"
"mysql.service"
];
requiredBy = [ "phpfpm-firefly-iii.service" ];
before = [ "phpfpm-firefly-iii.service" ];
serviceConfig = {
ExecStart = firefly-iii-maintenance;
RemainAfterExit = true;
}
// commonServiceConfig;
unitConfig.JoinsNamespaceOf = "phpfpm-firefly-iii.service";
restartTriggers = [ cfg.package ];
partOf = [ "phpfpm-firefly-iii.service" ];
};
systemd.services.firefly-iii-cron = {
after = [
"firefly-iii-setup.service"
"postgresql.target"
"mysql.service"
];
wants = [ "firefly-iii-setup.service" ];
description = "Daily Firefly III cron job";
serviceConfig = {
ExecStart = "${artisan} firefly-iii:cron";
}
// commonServiceConfig;
};
systemd.timers.firefly-iii-cron = {
description = "Trigger Firefly Cron";
timerConfig = {
OnCalendar = "Daily";
RandomizedDelaySec = "1800s";
Persistent = true;
};
wantedBy = [ "timers.target" ];
restartTriggers = [ cfg.package ];
};
services.nginx = lib.mkIf cfg.enableNginx {
enable = true;
recommendedTlsSettings = lib.mkDefault true;
recommendedOptimisation = lib.mkDefault true;
recommendedGzipSettings = lib.mkDefault true;
virtualHosts.${cfg.virtualHost} = {
root = "${cfg.package}/public";
locations = {
"/" = {
tryFiles = "$uri $uri/ /index.php?$query_string";
index = "index.php";
extraConfig = ''
sendfile off;
'';
};
"~ \\.php$" = {
extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params ;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_param modHeadersAvailable true; #Avoid sending the security headers twice
fastcgi_pass unix:${config.services.phpfpm.pools.firefly-iii.socket};
'';
};
};
};
};
systemd.tmpfiles.settings."10-firefly-iii" =
lib.attrsets.genAttrs
[
"${cfg.dataDir}/storage"
"${cfg.dataDir}/storage/app"
"${cfg.dataDir}/storage/database"
"${cfg.dataDir}/storage/export"
"${cfg.dataDir}/storage/framework"
"${cfg.dataDir}/storage/framework/cache"
"${cfg.dataDir}/storage/framework/sessions"
"${cfg.dataDir}/storage/framework/views"
"${cfg.dataDir}/storage/logs"
"${cfg.dataDir}/storage/upload"
"${cfg.dataDir}/cache"
]
(n: {
d = {
group = group;
mode = "0700";
user = user;
};
})
// {
"${cfg.dataDir}".d = {
group = group;
mode = "0710";
user = user;
};
};
users = {
users = lib.mkIf (user == defaultUser) {
${defaultUser} = {
description = "Firefly-iii service user";
inherit group;
isSystemUser = true;
home = cfg.dataDir;
};
};
groups = lib.mkIf (group == defaultGroup) { ${defaultGroup} = { }; };
};
};
}

View File

@@ -0,0 +1,241 @@
{
pkgs,
lib,
config,
...
}:
with lib;
let
cfg = config.services.flarum;
flarumInstallConfig = pkgs.writeText "config.json" (
builtins.toJSON {
debug = false;
offline = false;
baseUrl = cfg.baseUrl;
databaseConfiguration = cfg.database;
adminUser = {
username = cfg.adminUser;
password = cfg.initialAdminPassword;
email = cfg.adminEmail;
};
settings = {
forum_title = cfg.forumTitle;
};
}
);
in
{
options.services.flarum = {
enable = mkEnableOption "Flarum discussion platform";
package = mkPackageOption pkgs "flarum" { };
forumTitle = mkOption {
type = types.str;
default = "A Flarum Forum on NixOS";
description = "Title of the forum.";
};
domain = mkOption {
type = types.str;
default = "localhost";
example = "forum.example.com";
description = "Domain to serve on.";
};
baseUrl = mkOption {
type = types.str;
default = "http://localhost";
example = "https://forum.example.com";
description = "Change `domain` instead.";
};
adminUser = mkOption {
type = types.str;
default = "flarum";
description = "Username for first web application administrator";
};
adminEmail = mkOption {
type = types.str;
default = "admin@example.com";
description = "Email for first web application administrator";
};
initialAdminPassword = mkOption {
type = types.str;
default = "flarum";
description = "Initial password for the adminUser";
};
user = mkOption {
type = types.str;
default = "flarum";
description = "System user to run Flarum";
};
group = mkOption {
type = types.str;
default = "flarum";
description = "System group to run Flarum";
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/flarum";
description = "Home directory for writable storage";
};
database = mkOption {
type =
with types;
attrsOf (oneOf [
str
bool
int
]);
description = "MySQL database parameters";
default = {
# the database driver; i.e. MySQL; MariaDB...
driver = "mysql";
# the host of the connection; localhost in most cases unless using an external service
host = "localhost";
# the name of the database in the instance
database = "flarum";
# database username
username = "flarum";
# database password
password = "";
# the prefix for the tables; useful if you are sharing the same database with another service
prefix = "";
# the port of the connection; defaults to 3306 with MySQL
port = 3306;
strict = false;
};
};
createDatabaseLocally = mkOption {
type = types.bool;
default = false;
description = ''
Create the database and database user locally, and run installation.
WARNING: Due to <https://github.com/flarum/framework/issues/4018>, this option is set
to false by default. The 'flarum install' command may delete existing database tables.
Only set this to true if you are certain you are working with a fresh, empty database.
'';
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
home = cfg.stateDir;
createHome = true;
homeMode = "755";
group = cfg.group;
};
users.groups.${cfg.group} = { };
services.phpfpm.pools.flarum = {
user = cfg.user;
settings = {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
"listen.mode" = "0600";
"pm" = mkDefault "dynamic";
"pm.max_children" = mkDefault 10;
"pm.max_requests" = mkDefault 500;
"pm.start_servers" = mkDefault 2;
"pm.min_spare_servers" = mkDefault 1;
"pm.max_spare_servers" = mkDefault 3;
};
phpOptions = ''
error_log = syslog
log_errors = on
'';
};
services.nginx = {
enable = true;
virtualHosts."${cfg.domain}" = {
root = "${cfg.stateDir}/public";
locations."~ \\.php$".extraConfig = ''
fastcgi_pass unix:${config.services.phpfpm.pools.flarum.socket};
fastcgi_index site.php;
'';
extraConfig = ''
index index.php;
include ${cfg.package}/share/php/flarum/.nginx.conf;
'';
};
};
services.mysql = mkIf cfg.enable {
enable = true;
package = pkgs.mariadb;
ensureDatabases = [ cfg.database.database ];
ensureUsers = [
{
name = cfg.database.username;
ensurePermissions = {
"${cfg.database.database}.*" = "ALL PRIVILEGES";
};
}
];
};
assertions = [
{
assertion = !cfg.createDatabaseLocally || cfg.database.driver == "mysql";
message = "Flarum can only be automatically installed in MySQL/MariaDB.";
}
];
systemd.services."phpfpm-flarum" = {
restartTriggers = [ cfg.package ];
};
systemd.services.flarum-install = {
description = "Flarum installation";
requiredBy = [ "phpfpm-flarum.service" ];
before = [ "phpfpm-flarum.service" ];
requires = [ "mysql.service" ];
after = [ "mysql.service" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
};
path = [ config.services.phpfpm.phpPackage ];
script = ''
mkdir -p ${cfg.stateDir}/{extensions,public/assets/avatars}
mkdir -p ${cfg.stateDir}/storage/{cache,formatter,sessions,views}
cd ${cfg.stateDir}
cp -f ${cfg.package}/share/php/flarum/{extend.php,site.php,flarum} .
ln -sf ${cfg.package}/share/php/flarum/vendor .
ln -sf ${cfg.package}/share/php/flarum/public/index.php public/
''
+ optionalString (cfg.createDatabaseLocally && cfg.database.driver == "mysql") ''
if [ ! -f config.php ]; then
php flarum install --file=${flarumInstallConfig}
fi
''
+ ''
if [ -f config.php ]; then
php flarum migrate
php flarum cache:clear
fi
'';
};
};
meta.maintainers = with lib.maintainers; [
fsagbuya
jasonodoom
];
}

View File

@@ -0,0 +1,65 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.fluidd;
moonraker = config.services.moonraker;
in
{
options.services.fluidd = {
enable = mkEnableOption "Fluidd, a Klipper web interface for managing your 3d printer";
package = mkPackageOption pkgs "fluidd" { };
hostName = mkOption {
type = types.str;
default = "localhost";
description = "Hostname to serve fluidd on";
};
nginx = mkOption {
type = types.submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; });
default = { };
example = literalExpression ''
{
serverAliases = [ "fluidd.''${config.networking.domain}" ];
}
'';
description = "Extra configuration for the nginx virtual host of fluidd.";
};
};
config = mkIf cfg.enable {
services.nginx = {
enable = true;
upstreams.fluidd-apiserver.servers."${moonraker.address}:${toString moonraker.port}" = { };
virtualHosts."${cfg.hostName}" = mkMerge [
cfg.nginx
{
root = mkForce "${cfg.package}/share/fluidd/htdocs";
locations = {
"/" = {
index = "index.html";
tryFiles = "$uri $uri/ /index.html";
};
"/index.html".extraConfig = ''
add_header Cache-Control "no-store, no-cache, must-revalidate";
'';
"/websocket" = {
proxyWebsockets = true;
proxyPass = "http://fluidd-apiserver/websocket";
};
"~ ^/(printer|api|access|machine|server)/" = {
proxyWebsockets = true;
proxyPass = "http://fluidd-apiserver$request_uri";
};
};
}
];
};
};
}

View File

@@ -0,0 +1,393 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.freshrss;
webserver = config.services.${cfg.webserver};
extension-env = pkgs.buildEnv {
name = "freshrss-extensions";
paths = cfg.extensions;
};
env-vars = {
DATA_PATH = cfg.dataDir;
}
// lib.optionalAttrs (cfg.extensions != [ ]) {
THIRDPARTY_EXTENSIONS_PATH = "${extension-env}/share/freshrss";
};
in
{
meta.maintainers = with maintainers; [
etu
stunkymonkey
mattchrist
];
options.services.freshrss = {
enable = mkEnableOption "FreshRSS RSS aggregator and reader with php-fpm backend";
package = mkPackageOption pkgs "freshrss" { };
extensions = mkOption {
type = types.listOf types.package;
default = [ ];
defaultText = literalExpression "[]";
example = literalExpression ''
with freshrss-extensions; [
youtube
] ++ [
(freshrss-extensions.buildFreshRssExtension {
FreshRssExtUniqueId = "ReadingTime";
pname = "reading-time";
version = "1.5";
src = pkgs.fetchFromGitLab {
domain = "framagit.org";
owner = "Lapineige";
repo = "FreshRSS_Extension-ReadingTime";
rev = "fb6e9e944ef6c5299fa56ffddbe04c41e5a34ebf";
hash = "sha256-C5cRfaphx4Qz2xg2z+v5qRji8WVSIpvzMbethTdSqsk=";
};
})
]
'';
description = "Additional extensions to be used.";
};
defaultUser = mkOption {
type = types.str;
default = "admin";
description = "Default username for FreshRSS.";
example = "eva";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Password for the defaultUser for FreshRSS.";
example = "/run/secrets/freshrss";
};
baseUrl = mkOption {
type = types.str;
description = "Default URL for FreshRSS.";
example = "https://freshrss.example.com";
};
language = mkOption {
type = types.str;
default = "en";
description = "Default language for FreshRSS.";
example = "de";
};
database = {
type = mkOption {
type = types.enum [
"sqlite"
"pgsql"
"mysql"
];
default = "sqlite";
description = "Database type.";
example = "pgsql";
};
host = mkOption {
type = types.nullOr types.str;
default = "localhost";
description = "Database host for FreshRSS.";
};
port = mkOption {
type = types.nullOr types.port;
default = null;
description = "Database port for FreshRSS.";
example = 3306;
};
user = mkOption {
type = types.nullOr types.str;
default = "freshrss";
description = "Database user for FreshRSS.";
};
passFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Database password file for FreshRSS.";
example = "/run/secrets/freshrss";
};
name = mkOption {
type = types.nullOr types.str;
default = "freshrss";
description = "Database name for FreshRSS.";
};
tableprefix = mkOption {
type = types.nullOr types.str;
default = null;
description = "Database table prefix for FreshRSS.";
example = "freshrss";
};
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/freshrss";
description = "Default data folder for FreshRSS.";
example = "/mnt/freshrss";
};
webserver = mkOption {
type = types.enum [
"nginx"
"caddy"
];
default = "nginx";
description = ''
Whether to use nginx or caddy for virtual host management.
Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
See [](#opt-services.nginx.virtualHosts) for further information.
Further caddy configuration can be done by adapting `services.caddy.virtualHosts.<name>`.
See [](#opt-services.caddy.virtualHosts) for further information.
'';
};
virtualHost = mkOption {
type = types.str;
default = "freshrss";
description = ''
Name of the caddy/nginx virtualhost to use and setup.
'';
};
pool = mkOption {
type = types.nullOr types.str;
default = "freshrss";
description = ''
Name of the php-fpm pool to use and setup. If not specified, a pool will be created
with default values.
'';
};
user = mkOption {
type = types.str;
default = "freshrss";
description = "User under which FreshRSS runs.";
};
authType = mkOption {
type = types.enum [
"form"
"http_auth"
"none"
];
default = "form";
description = "Authentication type for FreshRSS.";
};
};
config =
let
defaultServiceConfig = {
ReadWritePaths = "${cfg.dataDir}";
DeviceAllow = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@resources"
"~@privileged"
];
UMask = "0007";
Type = "oneshot";
User = cfg.user;
Group = config.users.users.${cfg.user}.group;
StateDirectory = "freshrss";
WorkingDirectory = cfg.package;
};
in
mkIf cfg.enable {
assertions = mkIf (cfg.authType == "form") [
{
assertion = cfg.passwordFile != null;
message = ''
`passwordFile` must be supplied when using "form" authentication!
'';
}
];
# Set up a Caddy virtual host.
services.caddy = mkIf (cfg.webserver == "caddy") {
enable = true;
virtualHosts.${cfg.virtualHost}.extraConfig = ''
root * ${config.services.freshrss.package}/p
php_fastcgi unix/${config.services.phpfpm.pools.freshrss.socket} {
env FRESHRSS_DATA_PATH ${config.services.freshrss.dataDir}
}
file_server
'';
};
# Set up a Nginx virtual host.
services.nginx = mkIf (cfg.webserver == "nginx") {
enable = true;
virtualHosts.${cfg.virtualHost} = {
root = "${cfg.package}/p";
# php files handling
# this regex is mandatory because of the API
locations."~ ^.+?\\.php(/.*)?$".extraConfig = ''
fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
fastcgi_split_path_info ^(.+\.php)(/.*)$;
# By default, the variable PATH_INFO is not set under PHP-FPM
# But FreshRSS API greader.php need it. If you have a Bad Request error, double check this var!
# NOTE: the separate $path_info variable is required. For more details, see:
# https://trac.nginx.org/nginx/ticket/321
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
include ${pkgs.nginx}/conf/fastcgi_params;
include ${pkgs.nginx}/conf/fastcgi.conf;
'';
locations."/" = {
tryFiles = "$uri $uri/ index.php";
index = "index.php index.html index.htm";
};
};
};
# Set up phpfpm pool
services.phpfpm.pools = mkIf (cfg.pool != null) {
${cfg.pool} = {
user = "freshrss";
settings = {
"listen.owner" = webserver.user;
"listen.group" = webserver.group;
"listen.mode" = "0600";
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.max_requests" = 500;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 5;
"catch_workers_output" = true;
};
phpEnv = env-vars;
};
};
users.users."${cfg.user}" = {
description = "FreshRSS service user";
isSystemUser = true;
group = "${cfg.user}";
home = cfg.dataDir;
};
users.groups."${cfg.user}" = { };
systemd.tmpfiles.settings."10-freshrss".${cfg.dataDir}.d = {
inherit (cfg) user;
group = config.users.users.${cfg.user}.group;
};
systemd.services.freshrss-config =
let
settingsFlags = concatStringsSep " \\\n " (
mapAttrsToList (k: v: "${k} ${toString v}") {
"--default-user" = ''"${cfg.defaultUser}"'';
"--auth-type" = ''"${cfg.authType}"'';
"--base-url" = ''"${cfg.baseUrl}"'';
"--language" = ''"${cfg.language}"'';
"--db-type" = ''"${cfg.database.type}"'';
# The following attributes are optional depending on the type of
# database. Those that evaluate to null on the left hand side
# will be omitted.
${if cfg.database.name != null then "--db-base" else null} = ''"${cfg.database.name}"'';
${if cfg.database.passFile != null then "--db-password" else null} =
''"$(cat ${cfg.database.passFile})"'';
${if cfg.database.user != null then "--db-user" else null} = ''"${cfg.database.user}"'';
${if cfg.database.tableprefix != null then "--db-prefix" else null} =
''"${cfg.database.tableprefix}"'';
# hostname:port e.g. "localhost:5432"
${if cfg.database.host != null && cfg.database.port != null then "--db-host" else null} =
''"${cfg.database.host}:${toString cfg.database.port}"'';
# socket path e.g. "/run/postgresql"
${if cfg.database.host != null && cfg.database.port == null then "--db-host" else null} =
''"${cfg.database.host}"'';
}
);
in
{
description = "Set up the state directory for FreshRSS before use";
wantedBy = [ "multi-user.target" ];
serviceConfig = defaultServiceConfig // {
RemainAfterExit = true;
};
restartIfChanged = true;
environment = env-vars;
script =
let
userScriptArgs = ''--user ${cfg.defaultUser} ${
optionalString (cfg.authType == "form") ''--password "$(cat ${cfg.passwordFile})"''
}'';
updateUserScript = optionalString (cfg.authType == "form" || cfg.authType == "none") ''
./cli/update-user.php ${userScriptArgs}
'';
createUserScript = optionalString (cfg.authType == "form" || cfg.authType == "none") ''
./cli/create-user.php ${userScriptArgs}
'';
in
''
# do installation or reconfigure
if test -f ${cfg.dataDir}/config.php; then
# reconfigure with settings
./cli/reconfigure.php ${settingsFlags}
${updateUserScript}
else
# check correct folders in data folder
./cli/prepare.php
# install with settings
./cli/do-install.php ${settingsFlags}
${createUserScript}
fi
'';
};
systemd.services.freshrss-updater = {
description = "FreshRSS feed updater";
after = [ "freshrss-config.service" ];
startAt = "*:0/5";
environment = env-vars;
serviceConfig = defaultServiceConfig // {
ExecStart = "${cfg.package}/app/actualize_script.php";
};
};
};
}

View File

@@ -0,0 +1,235 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.froide-govplan;
pythonFmt = pkgs.formats.pythonVars { };
settingsFile = pythonFmt.generate "extra_settings.py" cfg.settings;
pkg = cfg.package.overridePythonAttrs (old: {
postInstall = old.postInstall + ''
ln -s ${settingsFile} $out/${pkg.python.sitePackages}/froide_govplan/project/extra_settings.py
'';
});
froide-govplan = pkgs.writeShellApplication {
name = "froide-govplan";
runtimeInputs = [ pkgs.coreutils ];
text = ''
SUDO="exec"
if [[ "$USER" != govplan ]]; then
SUDO="exec /run/wrappers/bin/sudo -u govplan"
fi
$SUDO env ${lib.getExe pkg} "$@"
'';
};
# Service hardening
defaultServiceConfig = {
# Secure the services
ReadWritePaths = [ cfg.dataDir ];
CacheDirectory = "froide-govplan";
CapabilityBoundingSet = "";
# ProtectClock adds DeviceAllow=char-rtc r
DeviceAllow = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectHome = true;
ProtectHostname = true;
ProtectSystem = "strict";
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @setuid @keyring"
];
UMask = "0066";
};
in
{
options.services.froide-govplan = {
enable = lib.mkEnableOption "Gouvernment planer web app Govplan";
package = lib.mkPackageOption pkgs "froide-govplan" { };
hostName = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "FQDN for the froide-govplan instance.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/froide-govplan";
description = "Directory to store the Froide-Govplan server data.";
};
secretKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to a file containing the secret key.
'';
};
settings = lib.mkOption {
description = ''
Configuration options to set in `extra_settings.py`.
'';
default = { };
type = lib.types.submodule {
freeformType = pythonFmt.type;
options = {
ALLOWED_HOSTS = lib.mkOption {
type = with lib.types; listOf str;
default = [ "*" ];
description = ''
A list of valid fully-qualified domain names (FQDNs) and/or IP
addresses that can be used to reach the Froide-Govplan service.
'';
};
};
};
};
};
config = lib.mkIf cfg.enable {
services.froide-govplan = {
settings = {
STATIC_ROOT = "${cfg.dataDir}/static";
DEBUG = false;
DATABASES.default = {
ENGINE = "django.contrib.gis.db.backends.postgis";
NAME = "govplan";
USER = "govplan";
HOST = "/run/postgresql";
};
};
};
services.postgresql = {
enable = true;
ensureDatabases = [ "govplan" ];
ensureUsers = [
{
name = "govplan";
ensureDBOwnership = true;
}
];
extensions = ps: with ps; [ postgis ];
};
services.nginx = {
enable = lib.mkDefault true;
virtualHosts."${cfg.hostName}".locations = {
"/".extraConfig = "proxy_pass http://unix:/run/froide-govplan/froide-govplan.socket;";
"/static/".alias = "${cfg.dataDir}/static/";
};
proxyTimeout = lib.mkDefault "120s";
};
systemd = {
services = {
postgresql-setup.serviceConfig.ExecStartPost =
let
sqlFile = pkgs.writeText "froide-govplan-postgis-setup.sql" ''
CREATE EXTENSION IF NOT EXISTS postgis;
'';
in
[
''
${lib.getExe' config.services.postgresql.package "psql"} -d govplan -f "${sqlFile}"
''
];
froide-govplan = {
description = "Gouvernment planer Govplan";
serviceConfig = defaultServiceConfig // {
WorkingDirectory = cfg.dataDir;
StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/froide-govplan") "froide-govplan";
User = "govplan";
Group = "govplan";
TimeoutStartSec = "5m";
};
after = [
"postgresql.target"
"network.target"
"systemd-tmpfiles-setup.service"
];
wantedBy = [ "multi-user.target" ];
environment = {
PYTHONPATH = "${pkg.pythonPath}:${pkg}/${pkg.python.sitePackages}";
GDAL_LIBRARY_PATH = "${pkgs.gdal}/lib/libgdal.so";
GEOS_LIBRARY_PATH = "${pkgs.geos}/lib/libgeos_c.so";
}
// lib.optionalAttrs (cfg.secretKeyFile != null) {
SECRET_KEY_FILE = cfg.secretKeyFile;
};
preStart = ''
# Auto-migrate on first run or if the package has changed
versionFile="${cfg.dataDir}/src-version"
version=$(cat "$versionFile" 2>/dev/null || echo 0)
if [[ $version != ${pkg.version} ]]; then
${lib.getExe pkg} migrate --no-input
${lib.getExe pkg} collectstatic --no-input --clear
echo ${pkg.version} > "$versionFile"
fi
'';
script = ''
${pkg.python.pkgs.uvicorn}/bin/uvicorn --uds /run/froide-govplan/froide-govplan.socket \
--app-dir ${pkg}/${pkg.python.sitePackages}/froide_govplan \
project.asgi:application
'';
};
};
};
systemd.tmpfiles.rules = [ "d /run/froide-govplan - govplan govplan - -" ];
environment.systemPackages = [ froide-govplan ];
users.users.govplan = {
home = "${cfg.dataDir}";
isSystemUser = true;
group = "govplan";
};
users.groups.govplan = { };
};
meta.maintainers = with lib.maintainers; [ onny ];
}

View File

@@ -0,0 +1,220 @@
{
config,
lib,
options,
pkgs,
...
}:
with lib;
let
cfg = config.services.galene;
opt = options.services.galene;
defaultstateDir = "/var/lib/galene";
defaultrecordingsDir = "${cfg.stateDir}/recordings";
defaultgroupsDir = "${cfg.stateDir}/groups";
defaultdataDir = "${cfg.stateDir}/data";
in
{
options = {
services.galene = {
enable = mkEnableOption "Galene Service";
stateDir = mkOption {
default = defaultstateDir;
type = types.path;
description = ''
The directory where Galene stores its internal state. If left as the default
value this directory will automatically be created before the Galene server
starts, otherwise the sysadmin is responsible for ensuring the directory
exists with appropriate ownership and permissions.
'';
};
user = mkOption {
type = types.str;
default = "galene";
description = "User account under which galene runs.";
};
group = mkOption {
type = types.str;
default = "galene";
description = "Group under which galene runs.";
};
insecure = mkOption {
type = types.bool;
default = false;
description = ''
Whether Galene should listen in http or in https. If left as the default
value (false), Galene needs to be fed a private key and a certificate.
'';
};
certFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/path/to/your/cert.pem";
description = ''
Path to the server's certificate. The file is copied at runtime to
Galene's data directory where it needs to reside.
'';
};
keyFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/path/to/your/key.pem";
description = ''
Path to the server's private key. The file is copied at runtime to
Galene's data directory where it needs to reside.
'';
};
httpAddress = mkOption {
type = types.str;
default = "";
description = "HTTP listen address for galene.";
};
httpPort = mkOption {
type = types.port;
default = 8443;
description = "HTTP listen port.";
};
turnAddress = mkOption {
type = types.str;
default = "auto";
example = "127.0.0.1:1194";
description = "Built-in TURN server listen address and port. Set to \"\" to disable.";
};
staticDir = mkOption {
type = types.path;
default = "${cfg.package.static}/static";
defaultText = literalExpression ''"''${package.static}/static"'';
example = "/var/lib/galene/static";
description = "Web server directory.";
};
recordingsDir = mkOption {
type = types.path;
default = defaultrecordingsDir;
defaultText = literalExpression ''"''${config.${opt.stateDir}}/recordings"'';
example = "/var/lib/galene/recordings";
description = "Recordings directory.";
};
dataDir = mkOption {
type = types.path;
default = defaultdataDir;
defaultText = literalExpression ''"''${config.${opt.stateDir}}/data"'';
example = "/var/lib/galene/data";
description = "Data directory.";
};
groupsDir = mkOption {
type = types.path;
default = defaultgroupsDir;
defaultText = literalExpression ''"''${config.${opt.stateDir}}/groups"'';
example = "/var/lib/galene/groups";
description = "Web server directory.";
};
package = mkPackageOption pkgs "galene" { };
};
};
config = mkIf cfg.enable {
systemd.services.galene = {
description = "galene";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = ''
${optionalString (cfg.insecure != true && cfg.certFile != null && cfg.keyFile != null) ''
install -m 700 -o '${cfg.user}' -g '${cfg.group}' ${cfg.certFile} ${cfg.dataDir}/cert.pem
install -m 700 -o '${cfg.user}' -g '${cfg.group}' ${cfg.keyFile} ${cfg.dataDir}/key.pem
''}
'';
serviceConfig = mkMerge [
{
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
ExecStart = ''
${cfg.package}/bin/galene \
${optionalString (cfg.insecure) "-insecure"} \
-http ${cfg.httpAddress}:${toString cfg.httpPort} \
-turn ${cfg.turnAddress} \
-data ${cfg.dataDir} \
-groups ${cfg.groupsDir} \
-recordings ${cfg.recordingsDir} \
-static ${cfg.staticDir}'';
Restart = "always";
# Upstream Requirements
LimitNOFILE = 65536;
StateDirectory =
[ ]
++ optional (cfg.stateDir == defaultstateDir) "galene"
++ optional (cfg.dataDir == defaultdataDir) "galene/data"
++ optional (cfg.groupsDir == defaultgroupsDir) "galene/groups"
++ optional (cfg.recordingsDir == defaultrecordingsDir) "galene/recordings";
# Hardening
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
ReadWritePaths = cfg.recordingsDir;
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
}
];
};
users.users = mkIf (cfg.user == "galene") {
galene = {
description = "galene Service";
group = cfg.group;
isSystemUser = true;
};
};
users.groups = mkIf (cfg.group == "galene") {
galene = { };
};
};
meta.maintainers = with lib.maintainers; [ rgrunbla ];
}

View File

@@ -0,0 +1,302 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.gancio;
settingsFormat = pkgs.formats.json { };
inherit (lib)
mkEnableOption
mkPackageOption
mkOption
types
literalExpression
mkIf
optional
mapAttrsToList
concatStringsSep
concatMapStringsSep
getExe
mkMerge
mkDefault
;
in
{
options.services.gancio = {
enable = mkEnableOption "Gancio, a shared agenda for local communities";
package = mkPackageOption pkgs "gancio" { };
plugins = mkOption {
type = with types; listOf package;
default = [ ];
example = literalExpression "[ pkgs.gancioPlugins.telegram-bridge ]";
description = ''
Paths of gancio plugins to activate (linked under $WorkingDirectory/plugins/).
'';
};
user = mkOption {
type = types.str;
description = "The user (and PostgreSQL database name) used to run the gancio server";
default = "gancio";
};
settings = mkOption {
type = types.submodule {
freeformType = settingsFormat.type;
options = {
hostname = mkOption {
type = types.str;
description = "The domain name under which the server is reachable.";
};
baseurl = mkOption {
type = types.str;
default = "http${
lib.optionalString config.services.nginx.virtualHosts."${cfg.settings.hostname}".enableACME "s"
}://${cfg.settings.hostname}";
defaultText = lib.literalExpression ''"https://''${config.services.gancio.settings.hostname}"'';
example = "https://demo.gancio.org/gancio";
description = "The full URL under which the server is reachable.";
};
server = {
socket = mkOption {
type = types.path;
readOnly = true;
default = "/run/gancio/socket";
description = ''
The unix socket for the gancio server to listen on.
'';
};
};
db = {
dialect = mkOption {
type = types.enum [
"sqlite"
"postgres"
];
default = "sqlite";
description = ''
The database dialect to use
'';
};
storage = mkOption {
description = ''
Location for the SQLite database.
'';
readOnly = true;
type = types.nullOr types.str;
default = if cfg.settings.db.dialect == "sqlite" then "/var/lib/gancio/db.sqlite" else null;
defaultText = ''if config.services.gancio.settings.db.dialect == "sqlite" then "/var/lib/gancio/db.sqlite" else null'';
};
host = mkOption {
description = ''
Connection string for the PostgreSQL database
'';
readOnly = true;
type = types.nullOr types.str;
default = if cfg.settings.db.dialect == "postgres" then "/run/postgresql" else null;
defaultText = ''if config.services.gancio.settings.db.dialect == "postgres" then "/run/postgresql" else null'';
};
database = mkOption {
description = ''
Name of the PostgreSQL database
'';
readOnly = true;
type = types.nullOr types.str;
default = if cfg.settings.db.dialect == "postgres" then cfg.user else null;
defaultText = ''if config.services.gancio.settings.db.dialect == "postgres" then cfg.user else null'';
};
};
log_level = mkOption {
description = "Gancio log level.";
type = types.enum [
"debug"
"info"
"warning"
"error"
];
default = "info";
};
# FIXME upstream proper journald logging
log_path = mkOption {
description = "Directory Gancio logs into";
readOnly = true;
type = types.str;
default = "/var/log/gancio";
};
};
};
description = ''
Configuration for Gancio, see <https://gancio.org/install/config> for supported values.
'';
};
userLocale = mkOption {
type = with types; attrsOf (attrsOf (attrsOf str));
default = { };
example = {
en.register.description = "My new registration page description";
};
description = ''
Override default locales within gancio.
See [default languages and locales](https://framagit.org/les/gancio/tree/master/locales).
'';
};
nginx = mkOption {
type = types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
# enable encryption by default,
# as sensitive login credentials should not be transmitted in clear text.
options.forceSSL.default = true;
options.enableACME.default = true;
}
);
default = { };
example = {
enableACME = false;
forceSSL = false;
};
description = "Extra configuration for the nginx virtual host of gancio.";
};
};
config = mkIf cfg.enable {
environment.systemPackages = [
(pkgs.runCommand "gancio" { } ''
mkdir -p $out/bin
echo '#!${pkgs.runtimeShell}
cd /var/lib/gancio/
sudo=exec
if [[ "$USER" != ${cfg.user} ]]; then
sudo="exec /run/wrappers/bin/sudo -u ${cfg.user}"
fi
$sudo ${lib.getExe cfg.package} "''${@:--help}"
' > $out/bin/gancio
chmod +x $out/bin/gancio
'')
];
users.users.gancio = lib.mkIf (cfg.user == "gancio") {
isSystemUser = true;
group = cfg.user;
home = "/var/lib/gancio";
};
users.groups.gancio = lib.mkIf (cfg.user == "gancio") { };
systemd.tmpfiles.settings."10-gancio" =
let
rules = {
mode = "0755";
user = cfg.user;
group = config.users.users.${cfg.user}.group;
};
in
{
"/var/lib/gancio/user_locale".d = rules;
"/var/lib/gancio/plugins".d = rules;
};
systemd.services.gancio =
let
configFile = settingsFormat.generate "gancio-config.json" cfg.settings;
in
{
description = "Gancio server";
documentation = [ "https://gancio.org/" ];
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
]
++ optional (cfg.settings.db.dialect == "postgres") "postgresql.target";
environment = {
NODE_ENV = "production";
};
path = [
# required for sendmail
"/run/wrappers"
];
preStart = ''
# We need this so the gancio executable run by the user finds the right settings.
ln -sf ${configFile} config.json
rm -f user_locale/*
${concatStringsSep "\n" (
mapAttrsToList (
l: c: "ln -sf ${settingsFormat.generate "gancio-${l}-locale.json" c} user_locale/${l}.json"
) cfg.userLocale
)}
rm -f plugins/*
${concatMapStringsSep "\n" (p: "ln -sf ${p} plugins/") cfg.plugins}
'';
serviceConfig = {
ExecStart = "${getExe cfg.package} start ${configFile}";
# set umask so that nginx can write to the server socket
# FIXME: upstream socket permission configuration in Nuxt
UMask = "0002";
RuntimeDirectory = "gancio";
StateDirectory = "gancio";
WorkingDirectory = "/var/lib/gancio";
LogsDirectory = "gancio";
User = cfg.user;
# hardening
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectClock = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
CapabilityBoundingSet = "";
ProtectProc = "invisible";
};
};
services.postgresql = mkIf (cfg.settings.db.dialect == "postgres") {
enable = true;
ensureDatabases = [ cfg.user ];
ensureUsers = [
{
name = cfg.user;
ensureDBOwnership = true;
}
];
};
services.nginx = {
enable = true;
virtualHosts."${cfg.settings.hostname}" = mkMerge [
cfg.nginx
{
locations = {
"/" = {
index = "index.html";
tryFiles = "$uri $uri @proxy";
};
"@proxy" = {
proxyWebsockets = true;
proxyPass = "http://unix:${cfg.settings.server.socket}";
recommendedProxySettings = true;
};
};
}
];
};
# for nginx to access gancio socket
users.users."${config.services.nginx.user}" = lib.mkIf (config.services.nginx.enable) {
extraGroups = [ config.users.users.${cfg.user}.group ];
};
};
}

View File

@@ -0,0 +1,271 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.gerrit;
# NixOS option type for git-like configs
gitIniType =
let
primitiveType = lib.types.either lib.types.str (lib.types.either lib.types.bool lib.types.int);
multipleType = lib.types.either primitiveType (lib.types.listOf primitiveType);
sectionType = lib.types.lazyAttrsOf multipleType;
supersectionType = lib.types.lazyAttrsOf (lib.types.either multipleType sectionType);
in
lib.types.lazyAttrsOf supersectionType;
gerritConfig = pkgs.writeText "gerrit.conf" (lib.generators.toGitINI cfg.settings);
replicationConfig = pkgs.writeText "replication.conf" (
lib.generators.toGitINI cfg.replicationSettings
);
# Wrap the gerrit java with all the java options so it can be called
# like a normal CLI app
gerrit-cli = pkgs.writeShellScriptBin "gerrit" ''
set -euo pipefail
jvmOpts=(
${lib.escapeShellArgs cfg.jvmOpts}
-Xmx${cfg.jvmHeapLimit}
)
exec ${cfg.jvmPackage}/bin/java \
"''${jvmOpts[@]}" \
-jar ${cfg.package}/webapps/${cfg.package.name}.war \
"$@"
'';
gerrit-plugins =
pkgs.runCommand "gerrit-plugins"
{
buildInputs = [ gerrit-cli ];
}
''
shopt -s nullglob
mkdir $out
for name in ${toString cfg.builtinPlugins}; do
echo "Installing builtin plugin $name.jar"
gerrit cat plugins/$name.jar > $out/$name.jar
done
for file in ${toString cfg.plugins}; do
name=$(echo "$file" | cut -d - -f 2-)
echo "Installing plugin $name"
ln -sf "$file" $out/$name
done
'';
in
{
options = {
services.gerrit = {
enable = lib.mkEnableOption "Gerrit service";
package = lib.mkPackageOption pkgs "gerrit" { };
jvmPackage = lib.mkPackageOption pkgs "jdk21_headless" { };
jvmOpts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"-Dflogger.backend_factory=com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance"
"-Dflogger.logging_context=com.google.gerrit.server.logging.LoggingContext#getInstance"
];
description = "A list of JVM options to start gerrit with.";
};
jvmHeapLimit = lib.mkOption {
type = lib.types.str;
default = "1024m";
description = ''
How much memory to allocate to the JVM heap
'';
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "[::]:8080";
description = ''
`hostname:port` to listen for HTTP traffic.
This is bound using the systemd socket activation.
'';
};
settings = lib.mkOption {
type = gitIniType;
default = { };
description = ''
Gerrit configuration. This will be generated to the
`etc/gerrit.config` file.
'';
};
replicationSettings = lib.mkOption {
type = gitIniType;
default = { };
description = ''
Replication configuration. This will be generated to the
`etc/replication.config` file.
'';
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = ''
List of plugins to add to Gerrit. Each derivation is a jar file
itself where the name of the derivation is the name of plugin.
'';
};
builtinPlugins = lib.mkOption {
type = lib.types.listOf (lib.types.enum cfg.package.passthru.plugins);
default = [ ];
description = ''
List of builtins plugins to install. Those are shipped in the
`gerrit.war` file.
'';
};
serverId = lib.mkOption {
type = lib.types.str;
description = ''
Set a UUID that uniquely identifies the server.
This can be generated with
`nix-shell -p util-linux --run uuidgen`.
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.replicationSettings != { } -> lib.elem "replication" cfg.builtinPlugins;
message = "Gerrit replicationSettings require enabling the replication plugin";
}
];
services.gerrit.settings = {
cache.directory = "/var/cache/gerrit";
container.heapLimit = cfg.jvmHeapLimit;
gerrit.basePath = lib.mkDefault "git";
gerrit.serverId = cfg.serverId;
httpd.inheritChannel = "true";
httpd.listenUrl = lib.mkDefault "http://${cfg.listenAddress}";
index.type = lib.mkDefault "lucene";
};
# Add the gerrit CLI to the system to run `gerrit init` and friends.
environment.systemPackages = [ gerrit-cli ];
systemd.sockets.gerrit = {
unitConfig.Description = "Gerrit HTTP socket";
wantedBy = [ "sockets.target" ];
listenStreams = [ cfg.listenAddress ];
};
systemd.services.gerrit = {
description = "Gerrit";
wantedBy = [ "multi-user.target" ];
requires = [ "gerrit.socket" ];
after = [
"gerrit.socket"
"network.target"
];
path = [
gerrit-cli
pkgs.bash
pkgs.coreutils
pkgs.git
pkgs.openssh
];
environment = {
GERRIT_HOME = "%S/gerrit";
GERRIT_TMP = "%T";
HOME = "%S/gerrit";
XDG_CONFIG_HOME = "%S/gerrit/.config";
};
preStart = ''
set -euo pipefail
# bootstrap if nothing exists
if [[ ! -d git ]]; then
gerrit init --batch --no-auto-start
fi
# install gerrit.war for the plugin manager
rm -rf bin
mkdir bin
ln -sfv ${cfg.package}/webapps/${cfg.package.name}.war bin/gerrit.war
# copy the config, keep it mutable because Gerrit
ln -sfv ${gerritConfig} etc/gerrit.config
ln -sfv ${replicationConfig} etc/replication.config
# install the plugins
rm -rf plugins
ln -sv ${gerrit-plugins} plugins
'';
serviceConfig = {
DynamicUser = true;
ExecStart = "${gerrit-cli}/bin/gerrit daemon --console-log";
LimitNOFILE = 4096;
StandardInput = "socket";
StandardOutput = "journal";
StateDirectory = "gerrit";
StateDirectoryMode = "750";
CacheDirectory = "gerrit";
CacheDirectoryMode = "750";
WorkingDirectory = "%S/gerrit";
AmbientCapabilities = "";
CapabilityBoundingSet = "";
LockPersonality = true;
MountAPIVFS = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = "strict";
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "full";
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
UMask = 27;
};
};
};
meta.maintainers = with lib.maintainers; [
edef
zimbatm
felixsinger
];
# uses attributes of the linked package
meta.buildDocsInSandbox = false;
}

View File

@@ -0,0 +1,39 @@
# Glance {#module-services-glance}
Glance is a self-hosted dashboard that puts all your feeds in one place.
Visit [the Glance project page](https://github.com/glanceapp/glance) to learn
more about it.
## Quickstart {#module-services-glance-quickstart}
Check out the [configuration docs](https://github.com/glanceapp/glance/blob/main/docs/configuration.md) to learn more.
Use the following configuration to start a public instance of Glance locally:
```nix
{
services.glance = {
enable = true;
settings = {
pages = [
{
name = "Home";
columns = [
{
size = "full";
widgets = [
{ type = "calendar"; }
{
type = "weather";
location = "Nivelles, Belgium";
}
];
}
];
}
];
};
openFirewall = true;
};
}
```

View File

@@ -0,0 +1,241 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.glance;
inherit (lib)
catAttrs
concatMapStrings
getExe
mkEnableOption
mkIf
mkOption
mkPackageOption
types
;
inherit (builtins)
concatLists
isAttrs
isList
attrNames
getAttr
;
settingsFormat = pkgs.formats.yaml { };
settingsFile = settingsFormat.generate "glance.yaml" cfg.settings;
mergedSettingsFile = "/run/glance/glance.yaml";
in
{
options.services.glance = {
enable = mkEnableOption "glance";
package = mkPackageOption pkgs "glance" { };
settings = mkOption {
type = types.submodule {
freeformType = settingsFormat.type;
options = {
server = {
host = mkOption {
description = "Glance bind address";
default = "127.0.0.1";
example = "0.0.0.0";
type = types.str;
};
port = mkOption {
description = "Glance port to listen on";
default = 8080;
example = 5678;
type = types.port;
};
};
pages = mkOption {
type = settingsFormat.type;
description = ''
List of pages to be present on the dashboard.
See <https://github.com/glanceapp/glance/blob/main/docs/configuration.md#pages--columns>
'';
default = [
{
name = "Calendar";
columns = [
{
size = "full";
widgets = [ { type = "calendar"; } ];
}
];
}
];
example = [
{
name = "Home";
columns = [
{
size = "full";
widgets = [
{ type = "calendar"; }
{
type = "weather";
location = {
_secret = "/var/lib/secrets/glance/location";
};
}
];
}
];
}
];
};
};
};
default = { };
description = ''
Configuration written to a yaml file that is read by glance. See
<https://github.com/glanceapp/glance/blob/main/docs/configuration.md>
for more.
Settings containing secret data should be set to an
attribute set with this format: `{ _secret = "/path/to/secret"; }`.
See the example in `services.glance.settings.pages` at the weather widget
with a location secret to get a better picture of this.
Alternatively, you can use a single file with environment variables,
see `services.glance.environmentFile`.
'';
};
environmentFile = mkOption {
type = types.nullOr types.path;
description =
let
singleQuotes = "''";
in
''
Path to an environment file as defined in {manpage}`systemd.exec(5)`.
See upstream documentation
<https://github.com/glanceapp/glance/blob/main/docs/configuration.md#environment-variables>.
Example content of the file:
```
TIMEZONE=Europe/Paris
```
Example `services.glance.settings.pages` configuration:
```nix
[
{
name = "Home";
columns = [
{
size = "full";
widgets = [
{
type = "clock";
timezone = "\''${TIMEZONE}";
label = "Local Time";
}
];
}
];
}
];
```
Note that when using Glance's `''${ENV_VAR}` syntax in Nix,
you need to escape it as follows: use `\''${ENV_VAR}` in `"` strings
and `${singleQuotes}''${ENV_VAR}` in `${singleQuotes}` strings.
Alternatively, you can put each secret in it's own file,
see `services.glance.settings`.
'';
default = "/dev/null";
example = "/var/lib/secrets/glance";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Whether to open the firewall for Glance.
This adds `services.glance.settings.server.port` to `networking.firewall.allowedTCPPorts`.
'';
};
};
config = mkIf cfg.enable {
systemd.services.glance = {
description = "Glance feed dashboard server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
path = [ pkgs.replace-secret ];
serviceConfig = {
ExecStartPre =
let
findSecrets =
data:
if isAttrs data then
if data ? _secret then
[ data ]
else
concatLists (map (attr: findSecrets (getAttr attr data)) (attrNames data))
else if isList data then
concatLists (map findSecrets data)
else
[ ];
secretPaths = catAttrs "_secret" (findSecrets cfg.settings);
mkSecretReplacement = secretPath: ''
replace-secret ${
lib.escapeShellArgs [
"_secret: ${secretPath}"
secretPath
mergedSettingsFile
]
}
'';
secretReplacements = concatMapStrings mkSecretReplacement secretPaths;
in
# Use "+" to run as root because the secrets may not be accessible to glance
"+"
+ pkgs.writeShellScript "glance-start-pre" ''
install -m 600 -o $USER ${settingsFile} ${mergedSettingsFile}
${secretReplacements}
'';
ExecStart = "${getExe cfg.package} --config ${mergedSettingsFile}";
WorkingDirectory = "/var/lib/glance";
EnvironmentFile = cfg.environmentFile;
StateDirectory = "glance";
RuntimeDirectory = "glance";
RuntimeDirectoryMode = "0755";
PrivateTmp = true;
DynamicUser = true;
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateUsers = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
ProcSubset = "all";
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
UMask = "0077";
};
};
networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.settings.server.port ]; };
};
meta.doc = ./glance.md;
meta.maintainers = [ ];
}

View File

@@ -0,0 +1,303 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.glitchtip;
pkg = cfg.package;
inherit (pkg.passthru) python;
environment = lib.mapAttrs (
_: value:
if value == true then
"True"
else if value == false then
"False"
else
toString value
) cfg.settings;
in
{
meta.maintainers = with lib.maintainers; [
defelo
felbinger
];
options = {
services.glitchtip = {
enable = lib.mkEnableOption "GlitchTip";
package = lib.mkPackageOption pkgs "glitchtip" { };
user = lib.mkOption {
type = lib.types.str;
description = "The user account under which GlitchTip runs.";
default = "glitchtip";
};
group = lib.mkOption {
type = lib.types.str;
description = "The group under which GlitchTip runs.";
default = "glitchtip";
};
listenAddress = lib.mkOption {
type = lib.types.str;
description = "The address to listen on.";
default = "127.0.0.1";
example = "0.0.0.0";
};
port = lib.mkOption {
type = lib.types.port;
description = "The port to listen on.";
default = 8000;
};
stateDir = lib.mkOption {
type = lib.types.path;
description = "State directory of glitchtip.";
default = "/var/lib/glitchtip";
};
settings = lib.mkOption {
description = ''
Configuration of GlitchTip. See <https://glitchtip.com/documentation/install#configuration> for more information.
'';
default = { };
defaultText = lib.literalExpression ''
{
DEBUG = 0;
DEBUG_TOOLBAR = 0;
DATABASE_URL = lib.mkIf config.services.glitchtip.database.createLocally "postgresql://@/glitchtip";
REDIS_URL = lib.mkIf config.services.glitchtip.redis.createLocally "unix://''${config.services.redis.servers.glitchtip.unixSocket}";
CELERY_BROKER_URL = lib.mkIf config.services.glitchtip.redis.createLocally "redis+socket://''${config.services.redis.servers.glitchtip.unixSocket}";
}
'';
example = {
GLITCHTIP_DOMAIN = "https://glitchtip.example.com";
DATABASE_URL = "postgres://postgres:postgres@postgres/postgres";
};
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
options = {
GLITCHTIP_DOMAIN = lib.mkOption {
type = lib.types.str;
description = "The URL under which GlitchTip is externally reachable.";
example = "https://glitchtip.example.com";
};
ENABLE_USER_REGISTRATION = lib.mkOption {
type = lib.types.bool;
description = ''
When true, any user will be able to register. When false, user self-signup is disabled after the first user is registered. Subsequent users must be created by a superuser on the backend and organization invitations may only be sent to existing users.
'';
default = false;
};
ENABLE_ORGANIZATION_CREATION = lib.mkOption {
type = lib.types.bool;
description = ''
When false, only superusers will be able to create new organizations after the first. When true, any user can create a new organization.
'';
default = false;
};
};
};
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
example = [ "/run/secrets/glitchtip.env" ];
description = ''
Files to load environment variables from in addition to [](#opt-services.glitchtip.settings).
This is useful to avoid putting secrets into the nix store.
See <https://glitchtip.com/documentation/install#configuration> for more information.
'';
};
database.createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to enable and configure a local PostgreSQL database server.
'';
};
redis.createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to enable and configure a local Redis instance.
'';
};
gunicorn.extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Extra arguments for gunicorn.";
};
celery.extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Extra arguments for celery.";
};
};
};
config = lib.mkIf cfg.enable {
services.glitchtip.settings = {
DEBUG = lib.mkDefault 0;
DEBUG_TOOLBAR = lib.mkDefault 0;
PYTHONPATH = "${python.pkgs.makePythonPath pkg.propagatedBuildInputs}:${pkg}/lib/glitchtip";
DATABASE_URL = lib.mkIf cfg.database.createLocally "postgresql://@/glitchtip";
REDIS_URL = lib.mkIf cfg.redis.createLocally "unix://${config.services.redis.servers.glitchtip.unixSocket}";
CELERY_BROKER_URL = lib.mkIf cfg.redis.createLocally "redis+socket://${config.services.redis.servers.glitchtip.unixSocket}";
GLITCHTIP_VERSION = pkg.version;
};
systemd.services =
let
commonService = {
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
requires =
lib.optional cfg.database.createLocally "postgresql.target"
++ lib.optional cfg.redis.createLocally "redis-glitchtip.service";
after = [
"network-online.target"
]
++ lib.optional cfg.database.createLocally "postgresql.target"
++ lib.optional cfg.redis.createLocally "redis-glitchtip.service";
inherit environment;
};
commonServiceConfig = {
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = "glitchtip";
StateDirectory = "glitchtip";
EnvironmentFile = cfg.environmentFiles;
WorkingDirectory = "${pkg}/lib/glitchtip";
BindPaths = [ "${cfg.stateDir}/uploads:${pkg}/lib/glitchtip/uploads" ];
# hardening
AmbientCapabilities = "";
CapabilityBoundingSet = [ "" ];
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET AF_INET6 AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
"@chown"
];
UMask = "0077";
};
in
{
glitchtip = commonService // {
description = "GlitchTip";
preStart = ''
${lib.getExe pkg} migrate
'';
serviceConfig = commonServiceConfig // {
ExecStart = ''
${lib.getExe python.pkgs.gunicorn} \
--bind=${cfg.listenAddress}:${toString cfg.port} \
${lib.concatStringsSep " " cfg.gunicorn.extraArgs} \
glitchtip.wsgi
'';
};
};
glitchtip-worker = commonService // {
description = "GlitchTip Job Runner";
serviceConfig = commonServiceConfig // {
ExecStart = ''
${lib.getExe python.pkgs.celery} \
-A glitchtip worker \
-B -s /run/glitchtip/celerybeat-schedule \
${lib.concatStringsSep " " cfg.celery.extraArgs}
'';
};
};
};
services.postgresql = lib.mkIf cfg.database.createLocally {
enable = true;
ensureDatabases = [ "glitchtip" ];
ensureUsers = [
{
name = "glitchtip";
ensureDBOwnership = true;
}
];
};
services.redis.servers.glitchtip.enable = cfg.redis.createLocally;
users.users = lib.mkIf (cfg.user == "glitchtip") {
glitchtip = {
group = cfg.group;
extraGroups = lib.optionals cfg.redis.createLocally [ "redis-glitchtip" ];
isSystemUser = true;
};
};
users.groups = lib.mkIf (cfg.group == "glitchtip") { glitchtip = { }; };
systemd.tmpfiles.settings.glitchtip."${cfg.stateDir}/uploads".d = { inherit (cfg) user group; };
environment.systemPackages =
let
glitchtip-manage = pkgs.writeShellScriptBin "glitchtip-manage" ''
set -o allexport
${lib.toShellVars environment}
${lib.concatMapStringsSep "\n" (f: "source ${f}") cfg.environmentFiles}
${config.security.wrapperDir}/sudo -E -u ${cfg.user} ${lib.getExe pkg} "$@"
'';
in
[ glitchtip-manage ];
};
}

View File

@@ -0,0 +1,111 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.go-httpbin;
environment = lib.mapAttrs (
_: value: if lib.isBool value then lib.boolToString value else toString value
) cfg.settings;
in
{
meta.maintainers = with lib.maintainers; [ defelo ];
options.services.go-httpbin = {
enable = lib.mkEnableOption "go-httpbin";
package = lib.mkPackageOption pkgs "go-httpbin" { };
settings = lib.mkOption {
description = ''
Configuration of go-httpbin.
See <https://github.com/mccutchen/go-httpbin#configuration> for a list of options.
'';
example = {
HOST = "0.0.0.0";
PORT = 8080;
};
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
options = {
HOST = lib.mkOption {
type = lib.types.str;
description = "The host to listen on.";
default = "127.0.0.1";
example = "0.0.0.0";
};
PORT = lib.mkOption {
type = lib.types.port;
description = "The port to listen on.";
example = 8080;
};
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.go-httpbin = {
wantedBy = [ "multi-user.target" ];
inherit environment;
serviceConfig = {
User = "go-httpbin";
Group = "go-httpbin";
DynamicUser = true;
ExecStart = lib.getExe cfg.package;
# hardening
AmbientCapabilities = "";
CapabilityBoundingSet = [ "" ];
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SocketBindAllow = "tcp:${toString cfg.settings.PORT}";
SocketBindDeny = "any";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
UMask = "0077";
};
};
};
}

View File

@@ -0,0 +1,80 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib) types;
cfg = config.services.goatcounter;
stateDir = "goatcounter";
in
{
options = {
services.goatcounter = {
enable = lib.mkEnableOption "goatcounter";
package = lib.mkPackageOption pkgs "goatcounter" { };
address = lib.mkOption {
type = types.str;
default = "127.0.0.1";
description = "Web interface address.";
};
port = lib.mkOption {
type = types.port;
default = 8081;
description = "Web interface port.";
};
proxy = lib.mkOption {
type = types.bool;
default = false;
description = ''
Whether Goatcounter service is running behind a reverse proxy. Will listen for HTTPS if `false`.
Refer to [documentation](https://github.com/arp242/goatcounter?tab=readme-ov-file#running) for more details.
'';
};
extraArgs = lib.mkOption {
type = with types; listOf str;
default = [ ];
description = ''
List of extra arguments to be passed to goatcounter cli.
See {command}`goatcounter help serve` for more information.
'';
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.goatcounter = {
description = "Easy web analytics. No tracking of personal data.";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = lib.escapeShellArgs (
[
(lib.getExe cfg.package)
"serve"
"-listen"
"${cfg.address}:${toString cfg.port}"
]
++ lib.optionals cfg.proxy [
"-tls"
"proxy"
]
++ cfg.extraArgs
);
DynamicUser = true;
StateDirectory = stateDir;
WorkingDirectory = "%S/${stateDir}";
Restart = "always";
};
};
};
meta.maintainers = with lib.maintainers; [ bhankas ];
}

View File

@@ -0,0 +1,90 @@
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.gotify;
in
{
imports = [
(lib.mkRenamedOptionModule
[
"services"
"gotify"
"port"
]
[
"services"
"gotify"
"environment"
"GOTIFY_SERVER_PORT"
]
)
];
options.services.gotify = {
enable = lib.mkEnableOption "Gotify webserver";
package = lib.mkPackageOption pkgs "gotify-server" { };
environment = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
]
);
default = { };
example = {
GOTIFY_SERVER_PORT = 8080;
GOTIFY_DATABASE_DIALECT = "sqlite3";
};
description = ''
Config environment variables for the gotify-server.
See <https://gotify.net/docs/config> for more details.
'';
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
description = ''
Files containing additional config environment variables for gotify-server.
Secrets should be set in environmentFiles instead of environment.
'';
};
stateDirectoryName = lib.mkOption {
type = lib.types.str;
default = "gotify-server";
description = ''
The name of the directory below {file}`/var/lib` where
gotify stores its runtime data.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.gotify-server = {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
description = "Simple server for sending and receiving messages";
environment = lib.mapAttrs (_: toString) cfg.environment;
serviceConfig = {
WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}";
StateDirectory = cfg.stateDirectoryName;
EnvironmentFile = cfg.environmentFiles;
Restart = "always";
DynamicUser = true;
ExecStart = lib.getExe cfg.package;
};
};
};
meta.maintainers = with lib.maintainers; [ DCsunset ];
}

View File

@@ -0,0 +1,71 @@
# GoToSocial {#module-services-gotosocial}
[GoToSocial](https://gotosocial.org/) is an ActivityPub social network server, written in Golang.
## Service configuration {#modules-services-gotosocial-service-configuration}
The following configuration sets up the PostgreSQL as database backend and binds
GoToSocial to `127.0.0.1:8080`, expecting to be run behind a HTTP proxy on `gotosocial.example.com`.
```nix
{
services.gotosocial = {
enable = true;
setupPostgresqlDB = true;
settings = {
application-name = "My GoToSocial";
host = "gotosocial.example.com";
protocol = "https";
bind-address = "127.0.0.1";
port = 8080;
};
};
}
```
Please refer to the [GoToSocial Documentation](https://docs.gotosocial.org/en/latest/configuration/general/)
for additional configuration options.
## Proxy configuration {#modules-services-gotosocial-proxy-configuration}
Although it is possible to expose GoToSocial directly, it is common practice to operate it behind an
HTTP reverse proxy such as nginx.
```nix
{
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
enable = true;
clientMaxBodySize = "40M";
virtualHosts = with config.services.gotosocial.settings; {
"${host}" = {
enableACME = true;
forceSSL = true;
locations = {
"/" = {
recommendedProxySettings = true;
proxyWebsockets = true;
proxyPass = "http://${bind-address}:${toString port}";
};
};
};
};
};
}
```
Please refer to [](#module-security-acme) for details on how to provision an SSL/TLS certificate.
## User management {#modules-services-gotosocial-user-management}
After the GoToSocial service is running, the `gotosocial-admin` utility can be used to manage users. In particular an
administrative user can be created with
```ShellSession
$ sudo gotosocial-admin account create --username <nickname> --email <email> --password <password>
$ sudo gotosocial-admin account confirm --username <nickname>
$ sudo gotosocial-admin account promote --username <nickname>
```

View File

@@ -0,0 +1,178 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.gotosocial;
settingsFormat = pkgs.formats.yaml { };
configFile = settingsFormat.generate "config.yml" cfg.settings;
defaultSettings = {
application-name = "gotosocial";
protocol = "https";
bind-address = "127.0.0.1";
port = 8080;
storage-local-base-path = "/var/lib/gotosocial/storage";
db-type = "sqlite";
db-address = "/var/lib/gotosocial/database.sqlite";
};
gotosocial-admin = pkgs.writeShellScriptBin "gotosocial-admin" ''
exec systemd-run \
-u gotosocial-admin.service \
-p Group=gotosocial \
-p User=gotosocial \
-q -t -G --wait --service-type=exec \
${cfg.package}/bin/gotosocial --config-path ${configFile} admin "$@"
'';
in
{
meta.doc = ./gotosocial.md;
meta.maintainers = with lib.maintainers; [ blakesmith ];
options.services.gotosocial = {
enable = lib.mkEnableOption "ActivityPub social network server";
package = lib.mkPackageOption pkgs "gotosocial" { };
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open the configured port in the firewall.
Using a reverse proxy instead is highly recommended.
'';
};
setupPostgresqlDB = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to setup a local postgres database and populate the
`db-type` fields in `services.gotosocial.settings`.
'';
};
settings = lib.mkOption {
type = settingsFormat.type;
default = defaultSettings;
example = {
application-name = "My GoToSocial";
host = "gotosocial.example.com";
};
description = ''
Contents of the GoToSocial YAML config.
Please refer to the
[documentation](https://docs.gotosocial.org/en/latest/configuration/)
and
[example config](https://github.com/superseriousbusiness/gotosocial/blob/main/example/config.yaml).
Please note that the `host` option cannot be changed later so it is important to configure this correctly before you start GoToSocial.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
File path containing environment variables for configuring the GoToSocial service
in the format of an EnvironmentFile as described by {manpage}`systemd.exec(5)`.
This option could be used to pass sensitive configuration to the GoToSocial daemon.
Please refer to the Environment Variables section in the
[documentation](https://docs.gotosocial.org/en/latest/configuration/).
'';
default = null;
example = "/root/nixos/secrets/gotosocial.env";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.settings.host or null != null;
message = ''
You have to define a hostname for GoToSocial (`services.gotosocial.settings.host`), it cannot be changed later without starting over!
'';
}
];
services.gotosocial.settings =
(lib.mapAttrs (name: lib.mkDefault) (
defaultSettings
// {
web-asset-base-dir = "${cfg.package}/share/gotosocial/web/assets/";
web-template-base-dir = "${cfg.package}/share/gotosocial/web/template/";
}
))
// (lib.optionalAttrs cfg.setupPostgresqlDB {
db-type = "postgres";
db-address = "/run/postgresql";
db-database = "gotosocial";
db-user = "gotosocial";
});
environment.systemPackages = [ gotosocial-admin ];
users.groups.gotosocial = { };
users.users.gotosocial = {
group = "gotosocial";
isSystemUser = true;
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.settings.port ];
};
services.postgresql = lib.mkIf cfg.setupPostgresqlDB {
enable = true;
ensureDatabases = [ "gotosocial" ];
ensureUsers = [
{
name = "gotosocial";
ensureDBOwnership = true;
}
];
};
systemd.services.gotosocial = {
description = "ActivityPub social network server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ] ++ lib.optional cfg.setupPostgresqlDB "postgresql.target";
requires = lib.optional cfg.setupPostgresqlDB "postgresql.target";
restartTriggers = [ configFile ];
serviceConfig = {
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
ExecStart = "${cfg.package}/bin/gotosocial --config-path ${configFile} server start";
Restart = "on-failure";
Group = "gotosocial";
User = "gotosocial";
StateDirectory = "gotosocial";
WorkingDirectory = "/var/lib/gotosocial";
# Security options:
# Based on https://github.com/superseriousbusiness/gotosocial/blob/v0.8.1/example/gotosocial.service
AmbientCapabilities = lib.optional (cfg.settings.port < 1024) "CAP_NET_BIND_SERVICE";
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
RestrictNamespaces = true;
RestrictRealtime = true;
DevicePolicy = "closed";
ProtectSystem = "full";
ProtectControlGroups = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
LockPersonality = true;
};
};
};
}

View File

@@ -0,0 +1,320 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
generators
mapAttrs
mkDefault
mkEnableOption
mkIf
mkPackageOption
mkOption
types
;
cfg = config.services.grav;
yamlFormat = pkgs.formats.yaml { };
poolName = "grav";
servedRoot = pkgs.runCommand "grav-served-root" { } ''
cp --reflink=auto --no-preserve=mode -r ${cfg.package} $out
for p in assets images user system/config; do
rm -rf $out/$p
ln -sf /var/lib/grav/$p $out/$p
done
'';
systemSettingsYaml = yamlFormat.generate "grav-settings.yaml" cfg.systemSettings;
in
{
options.services.grav = {
enable = mkEnableOption "grav";
package = mkPackageOption pkgs "grav" { };
root = mkOption {
type = types.path;
default = "/var/lib/grav";
description = ''
Root of the application.
'';
};
pool = mkOption {
type = types.str;
default = "${poolName}";
description = ''
Name of existing phpfpm pool that is used to run web-application.
If not specified a pool will be created automatically with
default values.
'';
};
virtualHost = mkOption {
type = types.nullOr types.str;
default = "grav";
description = ''
Name of the nginx virtualhost to use and setup. If null, do not setup
any virtualhost.
'';
};
phpPackage = mkPackageOption pkgs "php83" { };
maxUploadSize = mkOption {
type = types.str;
default = "128M";
description = ''
The upload limit for files. This changes the relevant options in
{file}`php.ini` and nginx if enabled.
'';
};
systemSettings = mkOption {
type = yamlFormat.type;
default = {
log = {
handler = "syslog";
};
};
description = ''
Settings written to {file}`user/config/system.yaml`.
'';
};
};
config = mkIf cfg.enable {
services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
${poolName} = {
user = "grav";
group = "grav";
phpPackage = cfg.phpPackage.buildEnv {
extensions =
{ all, enabled }:
enabled
++ (with all; [
apcu
xml
yaml
]);
extraConfig = generators.toKeyValue { mkKeyValue = generators.mkKeyValueDefault { } " = "; } {
output_buffering = "0";
short_open_tag = "Off";
expose_php = "Off";
error_reporting = "E_ALL";
display_errors = "stderr";
"opcache.interned_strings_buffer" = "8";
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.revalidate_freq" = "1";
"opcache.fast_shutdown" = "1";
"openssl.cafile" = config.security.pki.caBundle;
catch_workers_output = "yes";
upload_max_filesize = cfg.maxUploadSize;
post_max_size = cfg.maxUploadSize;
memory_limit = cfg.maxUploadSize;
"apc.enable_cli" = "1";
};
};
phpEnv = {
GRAV_ROOT = toString servedRoot;
GRAV_SYSTEM_PATH = "${servedRoot}/system";
GRAV_CACHE_PATH = "/var/cache/grav";
GRAV_BACKUP_PATH = "/var/lib/grav/backup";
GRAV_LOG_PATH = "/var/log/grav";
GRAV_TMP_PATH = "/var/tmp/grav";
};
settings = mapAttrs (name: mkDefault) {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
"listen.mode" = "0600";
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"catch_workers_output" = 1;
};
};
};
services.nginx = mkIf (cfg.virtualHost != null) {
enable = true;
virtualHosts = {
${cfg.virtualHost} = {
root = "${servedRoot}";
locations = {
"= /robots.txt" = {
priority = 100;
extraConfig = ''
allow all;
access_log off;
'';
};
"~ \\.php$" = {
priority = 200;
extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
fastcgi_index index.php;
'';
};
"~* /(\\.git|cache|bin|logs|backup|tests)/.*$" = {
priority = 300;
extraConfig = ''
return 403;
'';
};
# deny running scripts inside core system folders
"~* /(system|vendor)/.*\\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$" =
{
priority = 300;
extraConfig = ''
return 403;
'';
};
# deny running scripts inside user folder
"~* /user/.*\\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$" = {
priority = 300;
extraConfig = ''
return 403;
'';
};
# deny access to specific files in the root folder
"~ /(LICENSE\\.txt|composer\\.lock|composer\\.json|nginx\\.conf|web\\.config|htaccess\\.txt|\\.htaccess)" =
{
priority = 300;
extraConfig = ''
return 403;
'';
};
# deny all files and folder beginning with a dot (hidden files & folders)
"~ (^|/)\\." = {
priority = 300;
extraConfig = ''
return 403;
'';
};
"/" = {
priority = 400;
index = "index.php";
extraConfig = ''
try_files $uri $uri/ /index.php?$query_string;
'';
};
};
extraConfig = ''
index index.php index.html /index.php$request_uri;
add_header X-Content-Type-Options nosniff;
add_header X-Download-Options noopen;
add_header X-Permitted-Cross-Domain-Policies none;
add_header X-Frame-Options sameorigin;
add_header Referrer-Policy no-referrer;
client_max_body_size ${cfg.maxUploadSize};
fastcgi_buffers 64 4K;
fastcgi_hide_header X-Powered-By;
gzip on;
gzip_vary on;
gzip_comp_level 4;
gzip_min_length 256;
gzip_proxied expired no-cache no-store private no_last_modified no_etag auth;
gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
'';
};
};
};
systemd.tmpfiles.rules =
let
datadir = "/var/lib/grav";
in
map (dir: "d '${dir}' 0750 grav grav - -") [
"/var/cache/grav"
"${datadir}/assets"
"${datadir}/backup"
"${datadir}/images"
"${datadir}/system/config"
"${datadir}/user/accounts"
"${datadir}/user/config"
"${datadir}/user/data"
"/var/log/grav"
]
++ [ "L+ ${datadir}/user/config/system.yaml - - - - ${systemSettingsYaml}" ];
systemd.services = {
"phpfpm-${poolName}" = mkIf (cfg.pool == "${poolName}") {
restartTriggers = [
servedRoot
systemSettingsYaml
];
serviceConfig = {
ExecStartPre = pkgs.writeShellScript "grav-pre-start" ''
function setPermits() {
chmod -R o-rx "$1"
chown -R grav:grav "$1"
}
tmpDir=/var/tmp/grav
dataDir=/var/lib/grav
mkdir $tmpDir
setPermits $tmpDir
for path in config/site.yaml pages plugins themes; do
fullPath="$dataDir/user/$path"
if [[ ! -e $fullPath ]]; then
cp --reflink=auto --no-preserve=mode -r \
${cfg.package}/user/$path $fullPath
fi
setPermits $fullPath
done
systemConfigDir=$dataDir/system/config
if [[ ! -e $systemConfigDir/system.yaml ]]; then
cp --reflink=auto --no-preserve=mode -r \
${cfg.package}/system/config/* $systemConfigDir/
fi
setPermits $systemConfigDir
'';
};
};
};
users.users.grav = {
isSystemUser = true;
description = "Grav service user";
home = "/var/lib/grav";
group = "grav";
};
users.groups.grav = {
members = [ config.services.nginx.user ];
};
};
}

View File

@@ -0,0 +1,66 @@
# Grocy {#module-services-grocy}
[Grocy](https://grocy.info/) is a web-based self-hosted groceries
& household management solution for your home.
## Basic usage {#module-services-grocy-basic-usage}
A very basic configuration may look like this:
```nix
{ pkgs, ... }:
{
services.grocy = {
enable = true;
hostName = "grocy.tld";
};
}
```
This configures a simple vhost using [nginx](#opt-services.nginx.enable)
which listens to `grocy.tld` with fully configured ACME/LE (this can be
disabled by setting [services.grocy.nginx.enableSSL](#opt-services.grocy.nginx.enableSSL)
to `false`). After the initial setup the credentials `admin:admin`
can be used to login.
The application's state is persisted at `/var/lib/grocy/grocy.db` in a
`sqlite3` database. The migration is applied when requesting the `/`-route
of the application.
## Settings {#module-services-grocy-settings}
The configuration for `grocy` is located at `/etc/grocy/config.php`.
By default, the following settings can be defined in the NixOS-configuration:
```nix
{ pkgs, ... }:
{
services.grocy.settings = {
# The default currency in the system for invoices etc.
# Please note that exchange rates aren't taken into account, this
# is just the setting for what's shown in the frontend.
currency = "EUR";
# The display language (and locale configuration) for grocy.
culture = "de";
calendar = {
# Whether or not to show the week-numbers
# in the calendar.
showWeekNumber = true;
# Index of the first day to be shown in the calendar (0=Sunday, 1=Monday,
# 2=Tuesday and so on).
firstDayOfWeek = 2;
};
};
}
```
If you want to alter the configuration file on your own, you can do this manually with
an expression like this:
```nix
{ lib, ... }:
{
environment.etc."grocy/config.php".text = lib.mkAfter ''
// Arbitrary PHP code in grocy's configuration file
'';
}
```

View File

@@ -0,0 +1,216 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.grocy;
in
{
options.services.grocy = {
enable = mkEnableOption "grocy";
package = mkPackageOption pkgs "grocy" { };
hostName = mkOption {
type = types.str;
description = ''
FQDN for the grocy instance.
'';
};
nginx.enableSSL = mkOption {
type = types.bool;
default = true;
description = ''
Whether or not to enable SSL (with ACME and let's encrypt)
for the grocy vhost.
'';
};
phpfpm.settings = mkOption {
type =
with types;
attrsOf (oneOf [
int
str
bool
]);
default = {
"pm" = "dynamic";
"php_admin_value[error_log]" = "stderr";
"php_admin_flag[log_errors]" = true;
"listen.owner" = "nginx";
"catch_workers_output" = true;
"pm.max_children" = "32";
"pm.start_servers" = "2";
"pm.min_spare_servers" = "2";
"pm.max_spare_servers" = "4";
"pm.max_requests" = "500";
};
description = ''
Options for grocy's PHPFPM pool.
'';
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/grocy";
description = ''
Home directory of the `grocy` user which contains
the application's state.
'';
};
settings = {
currency = mkOption {
type = types.str;
default = "USD";
example = "EUR";
description = ''
ISO 4217 code for the currency to display.
'';
};
culture = mkOption {
type = types.enum [
"de"
"en"
"da"
"en_GB"
"es"
"fr"
"hu"
"it"
"nl"
"no"
"pl"
"pt_BR"
"ru"
"sk_SK"
"sv_SE"
"tr"
];
default = "en";
description = ''
Display language of the frontend.
'';
};
calendar = {
showWeekNumber = mkOption {
default = true;
type = types.bool;
description = ''
Show the number of the weeks in the calendar views.
'';
};
firstDayOfWeek = mkOption {
default = null;
type = types.nullOr (types.enum (range 0 6));
description = ''
Which day of the week (0=Sunday, 1=Monday etc.) should be the
first day.
'';
};
};
};
};
config = mkIf cfg.enable {
environment.etc."grocy/config.php".text = ''
<?php
Setting('CULTURE', '${cfg.settings.culture}');
Setting('CURRENCY', '${cfg.settings.currency}');
Setting('CALENDAR_FIRST_DAY_OF_WEEK', '${toString cfg.settings.calendar.firstDayOfWeek}');
Setting('CALENDAR_SHOW_WEEK_OF_YEAR', ${boolToString cfg.settings.calendar.showWeekNumber});
'';
users.users.grocy = {
isSystemUser = true;
createHome = true;
home = cfg.dataDir;
group = "nginx";
};
systemd.tmpfiles.rules = map (dirName: "d '${cfg.dataDir}/${dirName}' - grocy nginx - -") [
"viewcache"
"plugins"
"settingoverrides"
"storage"
];
services.phpfpm.pools.grocy = {
user = "grocy";
group = "nginx";
# PHP 8.1 and 8.2 are the only version which are supported/tested by upstream:
# https://github.com/grocy/grocy/blob/v4.0.2/README.md#platform-support
phpPackage = pkgs.php82;
inherit (cfg.phpfpm) settings;
phpEnv = {
GROCY_CONFIG_FILE = "/etc/grocy/config.php";
GROCY_DB_FILE = "${cfg.dataDir}/grocy.db";
GROCY_STORAGE_DIR = "${cfg.dataDir}/storage";
GROCY_PLUGIN_DIR = "${cfg.dataDir}/plugins";
GROCY_CACHE_DIR = "${cfg.dataDir}/viewcache";
};
};
# After an update of grocy, the viewcache needs to be deleted. Otherwise grocy will not work
# https://github.com/grocy/grocy#how-to-update
systemd.services.grocy-setup = {
wantedBy = [ "multi-user.target" ];
before = [ "phpfpm-grocy.service" ];
script = ''
rm -rf ${cfg.dataDir}/viewcache/*
'';
};
services.nginx = {
enable = true;
virtualHosts."${cfg.hostName}" = mkMerge [
{
root = "${cfg.package}/public";
locations."/".extraConfig = ''
rewrite ^ /index.php;
'';
locations."~ \\.php$".extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.grocy.socket};
include ${config.services.nginx.package}/conf/fastcgi.conf;
include ${config.services.nginx.package}/conf/fastcgi_params;
'';
locations."~ \\.(js|css|ttf|woff2?|png|jpe?g|svg)$".extraConfig = ''
add_header Cache-Control "public, max-age=15778463";
add_header X-Content-Type-Options nosniff;
add_header X-Robots-Tag none;
add_header X-Download-Options noopen;
add_header X-Permitted-Cross-Domain-Policies none;
add_header Referrer-Policy no-referrer;
access_log off;
'';
extraConfig = ''
try_files $uri /index.php;
'';
}
(mkIf cfg.nginx.enableSSL {
enableACME = true;
forceSSL = true;
})
];
};
};
meta = {
maintainers = with maintainers; [ diogotcorreia ];
doc = ./grocy.md;
};
}

View File

@@ -0,0 +1,61 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.guacamole-client;
settingsFormat = pkgs.formats.javaProperties { };
in
{
options = {
services.guacamole-client = {
enable = lib.mkEnableOption "Apache Guacamole Client (Tomcat)";
package = lib.mkPackageOption pkgs "guacamole-client" { };
settings = lib.mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
};
default = {
guacd-hostname = "localhost";
guacd-port = 4822;
};
description = ''
Configuration written to `guacamole.properties`.
::: {.note}
The Guacamole web application uses one main configuration file called
`guacamole.properties`. This file is the common location for all
configuration properties read by Guacamole or any extension of
Guacamole, including authentication providers.
:::
'';
};
enableWebserver = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable the Guacamole web application in a Tomcat webserver.
'';
};
};
};
config = lib.mkIf cfg.enable {
environment.etc."guacamole/guacamole.properties" = lib.mkIf (cfg.settings != { }) {
source = (settingsFormat.generate "guacamole.properties" cfg.settings);
};
services = lib.mkIf cfg.enableWebserver {
tomcat = {
enable = true;
webapps = [
cfg.package
];
};
};
};
}

View File

@@ -0,0 +1,89 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.guacamole-server;
in
{
options = {
services.guacamole-server = {
enable = lib.mkEnableOption "Apache Guacamole Server (guacd)";
package = lib.mkPackageOption pkgs "guacamole-server" { };
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
ENVIRONMENT = "production";
}
'';
description = "Environment variables to pass to guacd.";
};
host = lib.mkOption {
default = "127.0.0.1";
description = ''
The host name or IP address the server should listen to.
'';
type = lib.types.str;
};
port = lib.mkOption {
default = 4822;
description = ''
The port the guacd server should listen to.
'';
type = lib.types.port;
};
logbackXml = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/path/to/logback.xml";
description = ''
Configuration file that correspond to `logback.xml`.
'';
};
userMappingXml = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/path/to/user-mapping.xml";
description = ''
Configuration file that correspond to `user-mapping.xml`.
'';
};
};
};
config = lib.mkIf cfg.enable {
# Setup configuration files.
environment.etc."guacamole/logback.xml" = lib.mkIf (cfg.logbackXml != null) {
source = cfg.logbackXml;
};
environment.etc."guacamole/user-mapping.xml" = lib.mkIf (cfg.userMappingXml != null) {
source = cfg.userMappingXml;
};
systemd.services.guacamole-server = {
description = "Apache Guacamole server (guacd)";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
HOME = "/run/guacamole-server";
}
// cfg.extraEnvironment;
serviceConfig = {
ExecStart = "${lib.getExe cfg.package} -f -b ${cfg.host} -l ${toString cfg.port}";
RuntimeDirectory = "guacamole-server";
DynamicUser = true;
PrivateTmp = "yes";
Restart = "on-failure";
};
};
};
}

View File

@@ -0,0 +1,23 @@
# Hatsu {#module-services-hatsu}
[Hatsu](https://github.com/importantimport/hatsu) is an fully-automated ActivityPub bridge for static sites.
## Quickstart {#module-services-hatsu-quickstart}
The minimum configuration to start hatsu server would look like this:
```nix
{
services.hatsu = {
enable = true;
settings = {
HATSU_DOMAIN = "hatsu.local";
HATSU_PRIMARY_ACCOUNT = "example.com";
};
};
}
```
this will start the hatsu server on port 3939 and save the database in `/var/lib/hatsu/hatsu.sqlite3`.
Please refer to the [Hatsu Documentation](https://hatsu.cli.rs) for additional configuration options.

View File

@@ -0,0 +1,97 @@
{
lib,
pkgs,
config,
...
}:
let
cfg = config.services.hatsu;
in
{
meta.doc = ./hatsu.md;
meta.maintainers = with lib.maintainers; [ kwaa ];
options.services.hatsu = {
enable = lib.mkEnableOption "Self-hosted and fully-automated ActivityPub bridge for static sites";
package = lib.mkPackageOption pkgs "hatsu" { };
settings = lib.mkOption {
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (
nullOr (oneOf [
bool
int
port
str
])
);
options = {
HATSU_DATABASE_URL = lib.mkOption {
type = lib.types.str;
default = "sqlite:///var/lib/hatsu/hatsu.sqlite?mode=rwc";
example = "postgres://username:password@host/database";
description = "Database URL.";
};
HATSU_DOMAIN = lib.mkOption {
type = lib.types.str;
description = "The domain name of your instance (eg 'hatsu.local').";
};
HATSU_LISTEN_HOST = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Host where hatsu should listen for incoming requests.";
};
HATSU_LISTEN_PORT = lib.mkOption {
type = lib.types.port;
apply = toString;
default = 3939;
description = "Port where hatsu should listen for incoming requests.";
};
HATSU_PRIMARY_ACCOUNT = lib.mkOption {
type = lib.types.str;
description = "The primary account of your instance (eg 'example.com').";
};
};
};
default = { };
description = ''
Configuration for Hatsu, see
<link xlink:href="https://hatsu.cli.rs/admins/environments.html"/>
for supported values.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.hatsu = {
environment = cfg.settings;
description = "Hatsu server";
documentation = [ "https://hatsu.cli.rs/" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
DynamicUser = true;
ExecStart = "${lib.getExe cfg.package}";
Restart = "on-failure";
StateDirectory = "hatsu";
Type = "simple";
WorkingDirectory = "%S/hatsu";
};
};
};
}

View File

@@ -0,0 +1,137 @@
{
config,
pkgs,
lib,
...
}:
let
# Load default values from package. See https://github.com/bitvora/haven/blob/master/.env.example
defaultSettings = builtins.fromTOML (builtins.readFile "${cfg.package}/share/haven/.env.example");
import_relays_file = "${pkgs.writeText "import_relays.json" (builtins.toJSON cfg.importRelays)}";
blastr_relays_file = "${pkgs.writeText "blastr_relays.json" (builtins.toJSON cfg.blastrRelays)}";
mergedSettings = cfg.settings // {
IMPORT_SEED_RELAYS_FILE = import_relays_file;
BLASTR_RELAYS_FILE = blastr_relays_file;
};
cfg = config.services.haven;
in
{
options.services.haven = {
enable = lib.mkEnableOption "haven";
package = lib.mkPackageOption pkgs "haven" { };
blastrRelays = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of relay configurations for blastr";
example = lib.literalExpression ''
[
"relay.example.com"
]
'';
};
importRelays = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of relay configurations for importing historical events";
example = lib.literalExpression ''
[
"relay.example.com"
]
'';
};
settings = lib.mkOption {
default = defaultSettings;
defaultText = "See <https://github.com/bitvora/haven/blob/master/.env.example>";
apply = lib.recursiveUpdate defaultSettings;
description = "See <https://github.com/bitvora/haven> for documentation.";
example = lib.literalExpression ''
{
RELAY_URL = "relay.example.com";
OWNER_NPUB = "npub1...";
}
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to a file containing sensitive environment variables. See <https://github.com/bitvora/haven> for documentation.
The file should contain environment-variable assignments like:
S3_SECRET_KEY=mysecretkey
S3_ACCESS_KEY_ID=myaccesskey
'';
example = "/var/lib/haven/secrets.env";
};
};
config = lib.mkIf cfg.enable {
users.users.haven = {
description = "Haven daemon user";
group = "haven";
isSystemUser = true;
};
users.groups.haven = { };
systemd.services.haven = {
description = "haven";
wants = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = lib.attrsets.mapAttrs (
name: value: if builtins.isBool value then if value then "true" else "false" else toString value
) mergedSettings;
serviceConfig = {
ExecStart = "${cfg.package}/bin/haven";
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
User = "haven";
Group = "haven";
Restart = "on-failure";
RuntimeDirectory = "haven";
StateDirectory = "haven";
WorkingDirectory = "/var/lib/haven";
# Create symlink to templates in the working directory
ExecStartPre = "+${pkgs.coreutils}/bin/ln -sfT ${cfg.package}/share/haven/templates /var/lib/haven/templates";
PrivateTmp = true;
PrivateUsers = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
MemoryDenyWriteExecute = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectClock = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectControlGroups = true;
LockPersonality = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
RestrictRealtime = true;
ProtectHostname = true;
CapabilityBoundingSet = "";
SystemCallFilter = [
"@system-service"
];
SystemCallArchitectures = "native";
};
};
};
meta.maintainers = with lib.maintainers; [
felixzieger
];
}

View File

@@ -0,0 +1,297 @@
{
config,
lib,
options,
pkgs,
buildEnv,
...
}:
with lib;
let
defaultUser = "healthchecks";
cfg = config.services.healthchecks;
opt = options.services.healthchecks;
pkg = cfg.package;
boolToPython = b: if b then "True" else "False";
environment = {
PYTHONPATH = pkg.pythonPath;
STATIC_ROOT = cfg.dataDir + "/static";
}
// lib.filterAttrs (_: v: !builtins.isNull v) cfg.settings;
environmentFile = pkgs.writeText "healthchecks-environment" (
lib.generators.toKeyValue { } environment
);
healthchecksManageScript = pkgs.writeShellScriptBin "healthchecks-manage" ''
sudo=exec
if [[ "$USER" != "${cfg.user}" ]]; then
sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} --preserve-env --preserve-env=PYTHONPATH'
fi
export $(cat ${environmentFile} | xargs)
${lib.optionalString (cfg.settingsFile != null) "export $(cat ${cfg.settingsFile} | xargs)"}
$sudo ${pkg}/opt/healthchecks/manage.py "$@"
'';
in
{
options.services.healthchecks = {
enable = mkEnableOption "healthchecks" // {
description = ''
Enable healthchecks.
It is expected to be run behind a HTTP reverse proxy.
'';
};
package = mkPackageOption pkgs "healthchecks" { };
user = mkOption {
default = defaultUser;
type = types.str;
description = ''
User account under which healthchecks runs.
::: {.note}
If left as the default value this user will automatically be created
on system activation, otherwise you are responsible for
ensuring the user exists before the healthchecks service starts.
:::
'';
};
group = mkOption {
default = defaultUser;
type = types.str;
description = ''
Group account under which healthchecks runs.
::: {.note}
If left as the default value this group will automatically be created
on system activation, otherwise you are responsible for
ensuring the group exists before the healthchecks service starts.
:::
'';
};
listenAddress = mkOption {
type = types.str;
default = "localhost";
description = "Address the server will listen on.";
};
port = mkOption {
type = types.port;
default = 8000;
description = "Port the server will listen on.";
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/healthchecks";
description = ''
The directory used to store all data for healthchecks.
::: {.note}
If left as the default value this directory will automatically be created before
the healthchecks server starts, otherwise you are responsible for ensuring the
directory exists with appropriate ownership and permissions.
:::
'';
};
settingsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = opt.settings.description;
};
settings = lib.mkOption {
description = ''
Environment variables which are read by healthchecks `(local)_settings.py`.
Settings which are explicitly covered in options below, are type-checked and/or transformed
before added to the environment, everything else is passed as a string.
See <https://healthchecks.io/docs/self_hosted_configuration/>
for a full documentation of settings.
We add additional variables to this list inside the packages `local_settings.py.`
- `STATIC_ROOT` to set a state directory for dynamically generated static files.
- `SECRET_KEY_FILE` to read `SECRET_KEY` from a file at runtime and keep it out of
/nix/store.
- `_FILE` variants for several values that hold sensitive information in
[Healthchecks configuration](https://healthchecks.io/docs/self_hosted_configuration/) so
that they also can be read from a file and kept out of /nix/store. To see which values
have support for a `_FILE` variant, run:
- `nix-instantiate --eval --expr '(import <nixpkgs> {}).healthchecks.secrets'`
- or `nix eval 'nixpkgs#healthchecks.secrets'` if the flake support has been enabled.
If the same variable is set in both `settings` and `settingsFile` the value from `settingsFile` has priority.
'';
type = types.submodule (settings: {
freeformType = types.attrsOf types.str;
options = {
ALLOWED_HOSTS = lib.mkOption {
type = types.listOf types.str;
default = [ "*" ];
description = "The host/domain names that this site can serve.";
apply = lib.concatStringsSep ",";
};
SECRET_KEY_FILE = mkOption {
type = types.nullOr types.path;
description = "Path to a file containing the secret key.";
default = null;
};
DEBUG = mkOption {
type = types.bool;
default = false;
description = "Enable debug mode.";
apply = boolToPython;
};
REGISTRATION_OPEN = mkOption {
type = types.bool;
default = false;
description = ''
A boolean that controls whether site visitors can create new accounts.
Set it to false if you are setting up a private Healthchecks instance,
but it needs to be publicly accessible (so, for example, your cloud
services can send pings to it).
If you close new user registration, you can still selectively invite
users to your team account.
'';
apply = boolToPython;
};
DB = mkOption {
type = types.enum [
"sqlite"
"postgres"
"mysql"
];
default = "sqlite";
description = "Database engine to use.";
};
DB_NAME = mkOption {
type = types.str;
default = if settings.config.DB == "sqlite" then "${cfg.dataDir}/healthchecks.sqlite" else "hc";
defaultText = lib.literalExpression ''
if config.${settings.options.DB} == "sqlite"
then "''${config.${opt.dataDir}}/healthchecks.sqlite"
else "hc"
'';
description = "Database name.";
};
};
});
};
};
config = mkIf cfg.enable {
environment.systemPackages = [ healthchecksManageScript ];
systemd.targets.healthchecks = {
description = "Target for all Healthchecks services";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [
"network.target"
"network-online.target"
];
};
systemd.services =
let
commonConfig = {
WorkingDirectory = cfg.dataDir;
User = cfg.user;
Group = cfg.group;
EnvironmentFile = [
environmentFile
]
++ lib.optional (cfg.settingsFile != null) cfg.settingsFile;
StateDirectory = mkIf (cfg.dataDir == "/var/lib/healthchecks") "healthchecks";
StateDirectoryMode = mkIf (cfg.dataDir == "/var/lib/healthchecks") "0750";
};
in
{
healthchecks-migration = {
description = "Healthchecks migrations";
wantedBy = [ "healthchecks.target" ];
serviceConfig = commonConfig // {
Restart = "on-failure";
Type = "oneshot";
ExecStart = ''
${pkg}/opt/healthchecks/manage.py migrate
'';
};
};
healthchecks = {
description = "Healthchecks WSGI Service";
wantedBy = [ "healthchecks.target" ];
after = [ "healthchecks-migration.service" ];
preStart = ''
${pkg}/opt/healthchecks/manage.py collectstatic --no-input
${pkg}/opt/healthchecks/manage.py remove_stale_contenttypes --no-input
''
+ lib.optionalString (cfg.settings.DEBUG != "True") "${pkg}/opt/healthchecks/manage.py compress";
serviceConfig = commonConfig // {
Restart = "always";
ExecStart = ''
${pkgs.python3Packages.gunicorn}/bin/gunicorn hc.wsgi \
--bind ${cfg.listenAddress}:${toString cfg.port} \
--pythonpath ${pkg}/opt/healthchecks
'';
};
};
healthchecks-sendalerts = {
description = "Healthchecks Alert Service";
wantedBy = [ "healthchecks.target" ];
after = [ "healthchecks.service" ];
serviceConfig = commonConfig // {
Restart = "always";
ExecStart = ''
${pkg}/opt/healthchecks/manage.py sendalerts
'';
};
};
healthchecks-sendreports = {
description = "Healthchecks Reporting Service";
wantedBy = [ "healthchecks.target" ];
after = [ "healthchecks.service" ];
serviceConfig = commonConfig // {
Restart = "always";
ExecStart = ''
${pkg}/opt/healthchecks/manage.py sendreports --loop
'';
};
};
};
users.users = optionalAttrs (cfg.user == defaultUser) {
${defaultUser} = {
description = "healthchecks service owner";
isSystemUser = true;
group = defaultUser;
};
};
users.groups = optionalAttrs (cfg.user == defaultUser) {
${defaultUser} = {
members = [ defaultUser ];
};
};
};
}

View File

@@ -0,0 +1,365 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib) mkOption types literalExpression;
cfg = config.services.hedgedoc;
# 21.03 will not be an official release - it was instead 21.05. This
# versionAtLeast statement remains set to 21.03 for backwards compatibility.
# See https://github.com/NixOS/nixpkgs/pull/108899 and
# https://github.com/NixOS/rfcs/blob/master/rfcs/0080-nixos-release-schedule.md.
name = if lib.versionAtLeast config.system.stateVersion "21.03" then "hedgedoc" else "codimd";
settingsFormat = pkgs.formats.json { };
in
{
meta.maintainers = with lib.maintainers; [
SuperSandro2000
h7x4
];
imports = [
(lib.mkRenamedOptionModule [ "services" "codimd" ] [ "services" "hedgedoc" ])
(lib.mkRenamedOptionModule
[ "services" "hedgedoc" "configuration" ]
[ "services" "hedgedoc" "settings" ]
)
(lib.mkRenamedOptionModule
[ "services" "hedgedoc" "groups" ]
[ "users" "users" "hedgedoc" "extraGroups" ]
)
(lib.mkRemovedOptionModule [ "services" "hedgedoc" "workDir" ] ''
This option has been removed in favor of systemd managing the state directory.
If you have set this option without specifying `services.hedgedoc.settings.uploadsPath`,
please move these files to `/var/lib/hedgedoc/uploads`, or set the option to point
at the correct location.
'')
];
options.services.hedgedoc = {
package = lib.mkPackageOption pkgs "hedgedoc" { };
enable = lib.mkEnableOption "the HedgeDoc Markdown Editor";
configureNginx = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to configure nginx as a reverse proxy.";
};
settings = mkOption {
type = types.submodule {
freeformType = settingsFormat.type;
options = {
domain = mkOption {
type = with types; nullOr str;
default = null;
example = "hedgedoc.org";
description = ''
Domain to use for website.
This is useful if you are trying to run hedgedoc behind
a reverse proxy.
'';
};
urlPath = mkOption {
type = with types; nullOr str;
default = null;
example = "hedgedoc";
description = ''
URL path for the website.
This is useful if you are hosting hedgedoc on a path like
`www.example.com/hedgedoc`
'';
};
host = mkOption {
type = with types; nullOr str;
default = "localhost";
description = ''
Address to listen on.
'';
};
port = mkOption {
type = types.port;
default = 3000;
example = 80;
description = ''
Port to listen on.
'';
};
path = mkOption {
type = with types; nullOr path;
default = null;
example = "/run/hedgedoc/hedgedoc.sock";
description = ''
Path to UNIX domain socket to listen on
::: {.note}
If specified, {option}`host` and {option}`port` will be ignored.
:::
'';
};
protocolUseSSL = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Use `https://` for all links.
This is useful if you are trying to run hedgedoc behind
a reverse proxy.
::: {.note}
Only applied if {option}`domain` is set.
:::
'';
};
allowOrigin = mkOption {
type = with types; listOf str;
default = with cfg.settings; [ host ] ++ lib.optionals (domain != null) [ domain ];
defaultText = literalExpression ''
with config.services.hedgedoc.settings; [ host ] ++ lib.optionals (domain != null) [ domain ]
'';
example = [
"localhost"
"hedgedoc.org"
];
description = ''
List of domains to whitelist.
'';
};
db = mkOption {
type = types.attrs;
default = {
dialect = "sqlite";
storage = "/var/lib/${name}/db.sqlite";
};
defaultText = literalExpression ''
{
dialect = "sqlite";
storage = "/var/lib/hedgedoc/db.sqlite";
}
'';
example = literalExpression ''
db = {
username = "hedgedoc";
database = "hedgedoc";
host = "localhost:5432";
# or via socket
# host = "/run/postgresql";
dialect = "postgresql";
};
'';
description = ''
Specify the configuration for sequelize.
HedgeDoc supports `mysql`, `postgres`, `sqlite` and `mssql`.
See <https://sequelize.readthedocs.io/en/v3/>
for more information.
::: {.note}
The relevant parts will be overriden if you set {option}`dbURL`.
:::
'';
};
useSSL = mkOption {
type = types.bool;
default = false;
description = ''
Enable to use SSL server.
::: {.note}
This will also enable {option}`protocolUseSSL`.
It will also require you to set the following:
- {option}`sslKeyPath`
- {option}`sslCertPath`
- {option}`sslCAPath`
- {option}`dhParamPath`
:::
'';
};
uploadsPath = mkOption {
type = types.path;
default = "/var/lib/${name}/uploads";
defaultText = "/var/lib/hedgedoc/uploads";
description = ''
Directory for storing uploaded images.
'';
};
# Declared because we change the default to false.
allowGravatar = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Whether to enable [Libravatar](https://wiki.libravatar.org/) as
profile picture source on your instance.
Despite the naming of the setting, Hedgedoc replaced Gravatar
with Libravatar in [CodiMD 1.4.0](https://hedgedoc.org/releases/1.4.0/)
'';
};
};
};
description = ''
HedgeDoc configuration, see
<https://docs.hedgedoc.org/configuration/>
for documentation.
'';
};
environmentFile = mkOption {
type = with types; nullOr path;
default = null;
example = "/var/lib/hedgedoc/hedgedoc.env";
description = ''
Environment file as defined in {manpage}`systemd.exec(5)`.
Secrets may be passed to the service without adding them to the world-readable
Nix store, by specifying placeholder variables as the option value in Nix and
setting these variables accordingly in the environment file.
```
# snippet of HedgeDoc-related config
services.hedgedoc.settings.dbURL = "postgres://hedgedoc:\''${DB_PASSWORD}@db-host:5432/hedgedocdb";
services.hedgedoc.settings.minio.secretKey = "$MINIO_SECRET_KEY";
```
```
# content of the environment file
DB_PASSWORD=verysecretdbpassword
MINIO_SECRET_KEY=verysecretminiokey
```
Note that this file needs to be available on the host on which
`HedgeDoc` is running.
'';
};
};
config = lib.mkIf cfg.enable {
users.groups.${name} = { };
users.users.${name} = {
description = "HedgeDoc service user";
group = name;
isSystemUser = true;
};
services = {
hedgedoc.settings = {
defaultNotePath = lib.mkDefault "${cfg.package}/share/hedgedoc/public/default.md";
docsPath = lib.mkDefault "${cfg.package}/share/hedgedoc/public/docs";
viewPath = lib.mkDefault "${cfg.package}/share/hedgedoc/public/views";
};
nginx = lib.mkIf cfg.configureNginx {
enable = true;
upstreams.hedgedoc.servers."unix:${config.services.hedgedoc.settings.path}" = { };
virtualHosts."${cfg.settings.domain}" = {
enableACME = true;
forceSSL = true;
locations = {
"/" = {
proxyPass = "http://hedgedoc";
recommendedProxySettings = true;
};
"/socket.io/" = {
proxyPass = "http://hedgedoc";
proxyWebsockets = true;
recommendedProxySettings = true;
};
};
};
};
};
systemd.services.hedgedoc = {
description = "HedgeDoc Service";
documentation = [ "https://docs.hedgedoc.org/" ];
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
preStart =
let
configFile = settingsFormat.generate "hedgedoc-config.json" {
production = cfg.settings;
};
in
''
${pkgs.envsubst}/bin/envsubst \
-o /run/${name}/config.json \
-i ${configFile}
${pkgs.coreutils}/bin/mkdir -p ${cfg.settings.uploadsPath}
'';
serviceConfig = {
User = name;
Group = name;
Restart = "always";
ExecStart = lib.getExe cfg.package;
RuntimeDirectory = [ name ];
StateDirectory = [ name ];
WorkingDirectory = "/run/${name}";
ReadWritePaths = [
"-${cfg.settings.uploadsPath}"
]
++ lib.optionals (cfg.settings.db ? "storage") [ "-${cfg.settings.db.storage}" ];
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ];
Environment = [
"CMD_CONFIG_FILE=/run/${name}/config.json"
"NODE_ENV=production"
];
# Hardening
AmbientCapabilities = "";
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
# Required for connecting to database sockets,
# and listening to unix socket at `cfg.settings.path`
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SocketBindAllow = lib.mkIf (cfg.settings.path == null) cfg.settings.port;
SocketBindDeny = "any";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @obsolete"
"@pkey"
"fchown" # needed for filesystem image backend
];
UMask = "0007";
};
};
};
}

View File

@@ -0,0 +1,159 @@
{
lib,
pkgs,
config,
...
}:
with lib;
let
cfg = config.services.hledger-web;
in
{
options.services.hledger-web = {
enable = mkEnableOption "hledger-web service";
serveApi = mkEnableOption "serving only the JSON web API, without the web UI";
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
Address to listen on.
'';
};
port = mkOption {
type = types.port;
default = 5000;
example = 80;
description = ''
Port to listen on.
'';
};
allow = mkOption {
type = types.enum [
"view"
"add"
"edit"
"sandstorm"
];
default = "view";
description = ''
User's access level for changing data.
* view: view only permission.
* add: view and add permissions.
* edit: view, add, and edit permissions.
* sandstorm: permissions from the `X-Sandstorm-Permissions` request header.
'';
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/hledger-web";
description = ''
Path the service has access to. If left as the default value this
directory will automatically be created before the hledger-web server
starts, otherwise the sysadmin is responsible for ensuring the
directory exists with appropriate ownership and permissions.
'';
};
journalFiles = mkOption {
type = types.listOf types.str;
default = [ ".hledger.journal" ];
description = ''
Paths to journal files relative to {option}`services.hledger-web.stateDir`.
'';
};
baseUrl = mkOption {
type = with types; nullOr str;
default = null;
example = "https://example.org";
description = ''
Base URL, when sharing over a network.
'';
};
extraOptions = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "--forecast" ];
description = ''
Extra command line arguments to pass to hledger-web.
'';
};
};
imports = [
(mkRemovedOptionModule [
"services"
"hledger-web"
"capabilities"
] "This option has been replaced by new option `services.hledger-web.allow`.")
];
config = mkIf cfg.enable {
users.users.hledger = {
name = "hledger";
group = "hledger";
isSystemUser = true;
home = cfg.stateDir;
useDefaultShell = true;
};
users.groups.hledger = { };
systemd.services.hledger-web =
let
serverArgs =
with cfg;
escapeShellArgs (
[
"--serve"
"--host=${host}"
"--port=${toString port}"
"--allow=${allow}"
(optionalString (cfg.baseUrl != null) "--base-url=${cfg.baseUrl}")
(optionalString (cfg.serveApi) "--serve-api")
]
++ (map (f: "--file=${stateDir}/${f}") cfg.journalFiles)
++ extraOptions
);
in
{
description = "hledger-web - web-app for the hledger accounting tool.";
documentation = [
"info:hledger-web"
"man:hledger-web(1)"
"https://hledger.org/hledger-web.html"
];
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
serviceConfig = mkMerge [
{
ExecStart = "${pkgs.hledger-web}/bin/hledger-web ${serverArgs}";
Restart = "always";
WorkingDirectory = cfg.stateDir;
User = "hledger";
Group = "hledger";
PrivateTmp = true;
}
(mkIf (cfg.stateDir == "/var/lib/hledger-web") {
StateDirectory = "hledger-web";
})
];
};
};
meta.maintainers = with lib.maintainers; [
marijanp
erictapen
];
}

View File

@@ -0,0 +1,169 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.services.homebox;
inherit (lib)
mkEnableOption
mkPackageOption
mkDefault
mkOption
types
mkIf
;
defaultUser = "homebox";
defaultGroup = "homebox";
in
{
options.services.homebox = {
enable = mkEnableOption "homebox";
package = mkPackageOption pkgs "homebox" { };
user = mkOption {
type = types.str;
default = defaultUser;
description = "User account under which Homebox runs.";
};
group = mkOption {
type = types.str;
default = defaultGroup;
description = "Group under which Homebox runs.";
};
settings = mkOption {
type = types.submodule { freeformType = types.attrsOf (types.nullOr types.str); };
defaultText = lib.literalExpression ''
{
HBOX_STORAGE_CONN_STRING = "file:///var/lib/homebox";
HBOX_STORAGE_PREFIX_PATH = "data";
HBOX_DATABASE_DRIVER = "sqlite3";
HBOX_DATABASE_SQLITE_PATH = "/var/lib/homebox/data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1";
HBOX_OPTIONS_ALLOW_REGISTRATION = "false";
HBOX_OPTIONS_CHECK_GITHUB_RELEASE = "false";
HBOX_MODE = "production";
}
'';
description = ''
The homebox configuration as environment variables. For definitions and available options see the upstream
[documentation](https://homebox.software/en/configure/#configure-homebox).
'';
};
database = {
createLocally = mkOption {
type = lib.types.bool;
default = false;
description = ''
Configure local PostgreSQL database server for Homebox.
'';
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !(cfg.settings ? HBOX_STORAGE_DATA);
message = ''
`services.homebox.settings.HBOX_STORAGE_DATA` has been deprecated.
Please use `services.homebox.settings.HBOX_STORAGE_CONN_STRING` and `services.homebox.settings.HBOX_STORAGE_PREFIX_PATH` instead.
'';
}
];
users = {
users = mkIf (cfg.user == defaultUser) {
${defaultUser} = {
description = "homebox service user";
inherit (cfg) group;
isSystemUser = true;
};
};
groups = mkIf (cfg.group == defaultGroup) { ${defaultGroup} = { }; };
};
services.homebox.settings = lib.mkMerge [
(lib.mapAttrs (_: mkDefault) {
HBOX_STORAGE_CONN_STRING = "file:///var/lib/homebox";
HBOX_STORAGE_PREFIX_PATH = "data";
HBOX_DATABASE_DRIVER = "sqlite3";
HBOX_DATABASE_SQLITE_PATH = "/var/lib/homebox/data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1";
HBOX_OPTIONS_ALLOW_REGISTRATION = "false";
HBOX_OPTIONS_CHECK_GITHUB_RELEASE = "false";
HBOX_MODE = "production";
})
(mkIf cfg.database.createLocally {
HBOX_DATABASE_DRIVER = "postgres";
HBOX_DATABASE_HOST = "/run/postgresql";
HBOX_DATABASE_USERNAME = "homebox";
HBOX_DATABASE_DATABASE = "homebox";
HBOX_DATABASE_PORT = toString config.services.postgresql.settings.port;
})
];
services.postgresql = mkIf cfg.database.createLocally {
enable = true;
ensureDatabases = [ "homebox" ];
ensureUsers = [
{
name = "homebox";
ensureDBOwnership = true;
}
];
};
systemd.services.homebox = {
requires = lib.optional cfg.database.createLocally "postgresql.target";
after = lib.optional cfg.database.createLocally "postgresql.target";
environment = lib.filterAttrs (_: v: v != null) cfg.settings;
serviceConfig = {
User = cfg.user;
Group = cfg.group;
ExecStart = lib.getExe cfg.package;
LimitNOFILE = "1048576";
PrivateTmp = true;
PrivateDevices = true;
Restart = "always";
StateDirectory = "homebox";
# Hardening
CapabilityBoundingSet = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"@pkey"
];
RestrictSUIDSGID = true;
PrivateMounts = true;
UMask = "0077";
};
wantedBy = [ "multi-user.target" ];
};
};
meta.maintainers = with lib.maintainers; [
patrickdag
swarsel
];
}

View File

@@ -0,0 +1,184 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.homer;
settingsFormat = pkgs.formats.yaml { };
configFile = settingsFormat.generate "homer-config.yml" cfg.settings;
in
{
options.services.homer = {
enable = lib.mkEnableOption ''
A dead simple static HOMepage for your servER to keep your services on hand, from a simple yaml configuration file.
'';
virtualHost = {
nginx.enable = lib.mkEnableOption "a virtualhost to serve homer through nginx";
caddy.enable = lib.mkEnableOption "a virtualhost to serve homer through caddy";
domain = lib.mkOption {
description = ''
Domain to use for the virtual host.
This can be used to change nginx options like
```nix
services.nginx.virtualHosts."$\{config.services.homer.virtualHost.domain}".listen = [ ... ]
```
or
```nix
services.nginx.virtualHosts."example.com".listen = [ ... ]
```
'';
type = lib.types.str;
};
};
package = lib.mkPackageOption pkgs "homer" { };
settings = lib.mkOption {
default = { };
description = ''
Settings serialized into `config.yml` before build.
If left empty, the default configuration shipped with the package will be used instead.
For more information, see the [official documentation](https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md).
Note that the full configuration will be written to the nix store as world readable, which may include secrets such as [api-keys](https://github.com/bastienwirtz/homer/blob/main/docs/customservices.md).
To add files such as icons or backgrounds, you can reference them in line such as
```nix
icon = "''${./icon.png}";
```
This will add the file to the nix store upon build, referencing it by file path as expected by Homer.
'';
example = ''
{
title = "App dashboard";
subtitle = "Homer";
logo = "assets/logo.png";
header = true;
footer = ${"''"}
<p>Created with <span class="has-text-danger"></span> with
<a href="https://bulma.io/">bulma</a>,
<a href="https://vuejs.org/">vuejs</a> &
<a href="https://fontawesome.com/">font awesome</a> //
Fork me on <a href="https://github.com/bastienwirtz/homer">
<i class="fab fa-github-alt"></i></a></p>
${"''"};
columns = "3";
connectivityCheck = true;
proxy = {
useCredentials = false;
headers = {
Test = "Example";
Test1 = "Example1";
};
};
defaults = {
layout = "columns";
colorTheme = "auto";
};
theme = "default";
message = {
style = "is-warning";
title = "Optional message!";
icon = "fa fa-exclamation-triangle";
content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
};
links = [
{
name = "Link 1";
icon = "fab fa-github";
url = "https://github.com/bastienwirtz/homer";
target = "_blank";
}
{
name = "link 2";
icon = "fas fa-book";
url = "https://github.com/bastienwirtz/homer";
}
];
services = [
{
name = "Application";
icon = "fas fa-code-branch";
items = [
{
name = "Awesome app";
logo = "assets/tools/sample.png";
subtitle = "Bookmark example";
tag = "app";
keywords = "self hosted reddit";
url = "https://www.reddit.com/r/selfhosted/";
target = "_blank";
}
{
name = "Another one";
logo = "assets/tools/sample2.png";
subtitle = "Another application";
tag = "app";
tagstyle = "is-success";
url = "#";
}
];
}
{
name = "Other group";
icon = "fas fa-heartbeat";
items = [
{
name = "Pi-hole";
logo = "assets/tools/sample.png";
tag = "other";
url = "http://192.168.0.151/admin";
type = "PiHole";
target = "_blank";
}
];
}
];
}
'';
inherit (pkgs.formats.yaml { }) type;
};
};
config = lib.mkIf cfg.enable {
services.nginx = lib.mkIf cfg.virtualHost.nginx.enable {
enable = true;
virtualHosts."${cfg.virtualHost.domain}" = {
locations."/" = {
root = cfg.package;
tryFiles = "$uri /index.html";
};
locations."= /assets/config.yml" = {
alias = configFile;
};
};
};
services.caddy = lib.mkIf cfg.virtualHost.caddy.enable {
enable = true;
virtualHosts."${cfg.virtualHost.domain}".extraConfig = ''
root * ${cfg.package}
file_server
handle_path /assets/config.yml {
root * ${configFile}
file_server
}
'';
};
};
meta.maintainers = [
lib.maintainers.stunkymonkey
];
}

View File

@@ -0,0 +1,23 @@
# Honk {#module-services-honk}
With Honk on NixOS you can quickly configure a complete ActivityPub server with
minimal setup and support costs.
## Basic usage {#module-services-honk-basic-usage}
A minimal configuration looks like this:
```nix
{
services.honk = {
enable = true;
host = "0.0.0.0";
port = 8080;
username = "username";
passwordFile = "/etc/honk/password.txt";
servername = "honk.example.com";
};
networking.firewall.allowedTCPPorts = [ 8080 ];
}
```

View File

@@ -0,0 +1,158 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.honk;
honk-initdb-script =
cfg:
pkgs.writeShellApplication {
name = "honk-initdb-script";
runtimeInputs = with pkgs; [ coreutils ];
text = ''
PW=$(cat "$CREDENTIALS_DIRECTORY/honk_passwordFile")
echo -e "${cfg.username}\n''$PW\n${cfg.host}:${toString cfg.port}\n${cfg.servername}" | ${lib.getExe cfg.package} -datadir "$STATE_DIRECTORY" init
'';
};
in
{
options = {
services.honk = {
enable = lib.mkEnableOption "the Honk server";
package = lib.mkPackageOption pkgs "honk" { };
host = lib.mkOption {
default = "127.0.0.1";
description = ''
The host name or IP address the server should listen to.
'';
type = lib.types.str;
};
port = lib.mkOption {
default = 8080;
description = ''
The port the server should listen to.
'';
type = lib.types.port;
};
username = lib.mkOption {
description = ''
The admin account username.
'';
type = lib.types.str;
};
passwordFile = lib.mkOption {
description = ''
Password for admin account.
NOTE: Should be string not a store path, to prevent the password from being world readable
'';
type = lib.types.path;
};
servername = lib.mkOption {
description = ''
The server name.
'';
type = lib.types.str;
};
extraJS = lib.mkOption {
default = null;
description = ''
An extra JavaScript file to be loaded by the client.
'';
type = lib.types.nullOr lib.types.path;
};
extraCSS = lib.mkOption {
default = null;
description = ''
An extra CSS file to be loaded by the client.
'';
type = lib.types.nullOr lib.types.path;
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.username or "" != "";
message = ''
You have to define a username for Honk (`services.honk.username`).
'';
}
{
assertion = cfg.servername or "" != "";
message = ''
You have to define a servername for Honk (`services.honk.servername`).
'';
}
];
systemd.services.honk-initdb = {
description = "Honk server database setup";
requiredBy = [ "honk.service" ];
before = [ "honk.service" ];
serviceConfig = {
LoadCredential = [
"honk_passwordFile:${cfg.passwordFile}"
];
Type = "oneshot";
StateDirectory = "honk";
DynamicUser = true;
RemainAfterExit = true;
ExecStart = lib.getExe (honk-initdb-script cfg);
PrivateTmp = true;
};
unitConfig = {
ConditionPathExists = [
# Skip this service if the database already exists
"!%S/honk/honk.db"
];
};
};
systemd.services.honk = {
description = "Honk server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
bindsTo = [ "honk-initdb.service" ];
preStart = ''
mkdir -p $STATE_DIRECTORY/views
${lib.optionalString (cfg.extraJS != null) "ln -fs ${cfg.extraJS} $STATE_DIRECTORY/views/local.js"}
${lib.optionalString (
cfg.extraCSS != null
) "ln -fs ${cfg.extraCSS} $STATE_DIRECTORY/views/local.css"}
${lib.getExe cfg.package} -datadir $STATE_DIRECTORY -viewdir ${cfg.package}/share/honk backup $STATE_DIRECTORY/backup
${lib.getExe cfg.package} -datadir $STATE_DIRECTORY -viewdir ${cfg.package}/share/honk upgrade
${lib.getExe cfg.package} -datadir $STATE_DIRECTORY -viewdir ${cfg.package}/share/honk cleanup
'';
serviceConfig = {
ExecStart = ''
${lib.getExe cfg.package} -datadir $STATE_DIRECTORY -viewdir ${cfg.package}/share/honk
'';
StateDirectory = "honk";
DynamicUser = true;
PrivateTmp = "yes";
Restart = "on-failure";
};
};
};
meta = {
maintainers = [ ];
doc = ./honk.md;
};
}

View File

@@ -0,0 +1,292 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.icingaweb2;
fpm = config.services.phpfpm.pools.${poolName};
poolName = "icingaweb2";
defaultConfig = {
global = {
module_path = "${pkgs.icingaweb2}/modules";
};
};
in
{
meta.maintainers = teams.helsinki-systems.members;
options.services.icingaweb2 = with types; {
enable = mkEnableOption "the icingaweb2 web interface";
pool = mkOption {
type = str;
default = poolName;
description = ''
Name of existing PHP-FPM pool that is used to run Icingaweb2.
If not specified, a pool will automatically created with default values.
'';
};
libraryPaths = mkOption {
type = attrsOf package;
default = { };
description = ''
Libraries to add to the Icingaweb2 library path.
The name of the attribute is the name of the library, the value
is the package to add.
'';
};
virtualHost = mkOption {
type = nullOr str;
default = "icingaweb2";
description = ''
Name of the nginx virtualhost to use and setup. If null, no virtualhost is set up.
'';
};
timezone = mkOption {
type = str;
default = "UTC";
example = "Europe/Berlin";
description = "PHP-compliant timezone specification";
};
modules = {
doc.enable = mkEnableOption "the icingaweb2 doc module";
migrate.enable = mkEnableOption "the icingaweb2 migrate module";
setup.enable = mkEnableOption "the icingaweb2 setup module";
test.enable = mkEnableOption "the icingaweb2 test module";
translation.enable = mkEnableOption "the icingaweb2 translation module";
};
modulePackages = mkOption {
type = attrsOf package;
default = { };
example = literalExpression ''
{
"snow" = icingaweb2Modules.theme-snow;
}
'';
description = ''
Name-package attrset of Icingaweb 2 modules packages to enable.
If you enable modules manually (e.g. via the web ui), they will not be touched.
'';
};
generalConfig = mkOption {
type = nullOr attrs;
default = null;
example = {
general = {
showStacktraces = 1;
config_resource = "icingaweb_db";
};
logging = {
log = "syslog";
level = "CRITICAL";
};
};
description = ''
config.ini contents.
Will automatically be converted to a .ini file.
If you don't set global.module_path, the module will take care of it.
If the value is null, no config.ini is created and you can
modify it manually (e.g. via the web interface).
Note that you need to update module_path manually.
'';
};
resources = mkOption {
type = nullOr attrs;
default = null;
example = {
icingaweb_db = {
type = "db";
db = "mysql";
host = "localhost";
username = "icingaweb2";
password = "icingaweb2";
dbname = "icingaweb2";
};
};
description = ''
resources.ini contents.
Will automatically be converted to a .ini file.
If the value is null, no resources.ini is created and you can
modify it manually (e.g. via the web interface).
Note that if you set passwords here, they will go into the nix store.
'';
};
authentications = mkOption {
type = nullOr attrs;
default = null;
example = {
icingaweb = {
backend = "db";
resource = "icingaweb_db";
};
};
description = ''
authentication.ini contents.
Will automatically be converted to a .ini file.
If the value is null, no authentication.ini is created and you can
modify it manually (e.g. via the web interface).
'';
};
groupBackends = mkOption {
type = nullOr attrs;
default = null;
example = {
icingaweb = {
backend = "db";
resource = "icingaweb_db";
};
};
description = ''
groups.ini contents.
Will automatically be converted to a .ini file.
If the value is null, no groups.ini is created and you can
modify it manually (e.g. via the web interface).
'';
};
roles = mkOption {
type = nullOr attrs;
default = null;
example = {
Administrators = {
users = "admin";
permissions = "*";
};
};
description = ''
roles.ini contents.
Will automatically be converted to a .ini file.
If the value is null, no roles.ini is created and you can
modify it manually (e.g. via the web interface).
'';
};
};
config = mkIf cfg.enable {
services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
${poolName} = {
user = "icingaweb2";
phpEnv = {
ICINGAWEB_LIBDIR = toString (
pkgs.linkFarm "icingaweb2-libdir" (
mapAttrsToList (name: path: { inherit name path; }) cfg.libraryPaths
)
);
};
phpPackage = pkgs.php83.withExtensions ({ enabled, all }: [ all.imagick ] ++ enabled);
phpOptions = ''
date.timezone = "${cfg.timezone}"
'';
settings = mapAttrs (name: mkDefault) {
"listen.owner" = "nginx";
"listen.group" = "nginx";
"listen.mode" = "0600";
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 10;
};
};
};
services.icingaweb2.libraryPaths = {
ipl = pkgs.icingaweb2-ipl;
thirdparty = pkgs.icingaweb2-thirdparty;
};
systemd.services."phpfpm-${poolName}".serviceConfig.ReadWritePaths = [ "/etc/icingaweb2" ];
services.nginx = {
enable = true;
virtualHosts = mkIf (cfg.virtualHost != null) {
${cfg.virtualHost} = {
root = "${pkgs.icingaweb2}/public";
extraConfig = ''
index index.php;
try_files $1 $uri $uri/ /index.php$is_args$args;
'';
locations."~ ..*/.*.php$".extraConfig = ''
return 403;
'';
locations."~ ^/index.php(.*)$".extraConfig = ''
fastcgi_intercept_errors on;
fastcgi_index index.php;
include ${config.services.nginx.package}/conf/fastcgi.conf;
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${fpm.socket};
fastcgi_param SCRIPT_FILENAME ${pkgs.icingaweb2}/public/index.php;
'';
};
};
};
# /etc/icingaweb2
environment.etc =
let
doModule =
name:
optionalAttrs (cfg.modules.${name}.enable) {
"icingaweb2/enabledModules/${name}".source = "${pkgs.icingaweb2}/modules/${name}";
};
in
{ }
# Module packages
// (mapAttrs' (
k: v: nameValuePair "icingaweb2/enabledModules/${k}" { source = v; }
) cfg.modulePackages)
# Built-in modules
// doModule "doc"
// doModule "migrate"
// doModule "setup"
// doModule "test"
// doModule "translation"
# Configs
// optionalAttrs (cfg.generalConfig != null) {
"icingaweb2/config.ini".text = generators.toINI { } (defaultConfig // cfg.generalConfig);
}
// optionalAttrs (cfg.resources != null) {
"icingaweb2/resources.ini".text = generators.toINI { } cfg.resources;
}
// optionalAttrs (cfg.authentications != null) {
"icingaweb2/authentication.ini".text = generators.toINI { } cfg.authentications;
}
// optionalAttrs (cfg.groupBackends != null) {
"icingaweb2/groups.ini".text = generators.toINI { } cfg.groupBackends;
}
// optionalAttrs (cfg.roles != null) {
"icingaweb2/roles.ini".text = generators.toINI { } cfg.roles;
};
# User and group
users.groups.icingaweb2 = { };
users.users.icingaweb2 = {
description = "Icingaweb2 service user";
group = "icingaweb2";
isSystemUser = true;
};
};
}

View File

@@ -0,0 +1,203 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.icingaweb2.modules.monitoring;
configIni = ''
[security]
protected_customvars = "${concatStringsSep "," cfg.generalConfig.protectedVars}"
'';
backendsIni =
let
formatBool = b: if b then "1" else "0";
in
concatStringsSep "\n" (
mapAttrsToList (name: config: ''
[${name}]
type = "ido"
resource = "${config.resource}"
disabled = "${formatBool config.disabled}"
'') cfg.backends
);
transportsIni = concatStringsSep "\n" (
mapAttrsToList (name: config: ''
[${name}]
type = "${config.type}"
${optionalString (config.instance != null) ''instance = "${config.instance}"''}
${optionalString (config.type == "local" || config.type == "remote") ''path = "${config.path}"''}
${optionalString (config.type != "local") ''
host = "${config.host}"
${optionalString (config.port != null) ''port = "${toString config.port}"''}
user${optionalString (config.type == "api") "name"} = "${config.username}"
''}
${optionalString (config.type == "api") ''password = "${config.password}"''}
${optionalString (config.type == "remote") ''resource = "${config.resource}"''}
'') cfg.transports
);
in
{
options.services.icingaweb2.modules.monitoring = with types; {
enable = mkOption {
type = bool;
default = true;
description = "Whether to enable the icingaweb2 monitoring module.";
};
generalConfig = {
mutable = mkOption {
type = bool;
default = false;
description = "Make config.ini of the monitoring module mutable (e.g. via the web interface).";
};
protectedVars = mkOption {
type = listOf str;
default = [
"*pw*"
"*pass*"
"community"
];
description = "List of string patterns for custom variables which should be excluded from users view.";
};
};
mutableBackends = mkOption {
type = bool;
default = false;
description = "Make backends.ini of the monitoring module mutable (e.g. via the web interface).";
};
backends = mkOption {
default = {
icinga = {
resource = "icinga_ido";
};
};
description = "Monitoring backends to define";
type = attrsOf (
submodule (
{ name, ... }:
{
options = {
name = mkOption {
visible = false;
default = name;
type = str;
description = "Name of this backend";
};
resource = mkOption {
type = str;
description = "Name of the IDO resource";
};
disabled = mkOption {
type = bool;
default = false;
description = "Disable this backend";
};
};
}
)
);
};
mutableTransports = mkOption {
type = bool;
default = true;
description = "Make commandtransports.ini of the monitoring module mutable (e.g. via the web interface).";
};
transports = mkOption {
default = { };
description = "Command transports to define";
type = attrsOf (
submodule (
{ name, ... }:
{
options = {
name = mkOption {
visible = false;
default = name;
type = str;
description = "Name of this transport";
};
type = mkOption {
type = enum [
"api"
"local"
"remote"
];
default = "api";
description = "Type of this transport";
};
instance = mkOption {
type = nullOr str;
default = null;
description = "Assign a icinga instance to this transport";
};
path = mkOption {
type = str;
description = "Path to the socket for local or remote transports";
};
host = mkOption {
type = str;
description = "Host for the api or remote transport";
};
port = mkOption {
type = nullOr str;
default = null;
description = "Port to connect to for the api or remote transport";
};
username = mkOption {
type = str;
description = "Username for the api or remote transport";
};
password = mkOption {
type = str;
description = "Password for the api transport";
};
resource = mkOption {
type = str;
description = "SSH identity resource for the remote transport";
};
};
}
)
);
};
};
config = mkIf (config.services.icingaweb2.enable && cfg.enable) {
environment.etc = {
"icingaweb2/enabledModules/monitoring" = {
source = "${pkgs.icingaweb2}/modules/monitoring";
};
}
// optionalAttrs (!cfg.generalConfig.mutable) {
"icingaweb2/modules/monitoring/config.ini".text = configIni;
}
// optionalAttrs (!cfg.mutableBackends) {
"icingaweb2/modules/monitoring/backends.ini".text = backendsIni;
}
// optionalAttrs (!cfg.mutableTransports) {
"icingaweb2/modules/monitoring/commandtransports.ini".text = transportsIni;
};
};
}

View File

@@ -0,0 +1,74 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.ifm;
in
{
options.services.ifm = {
enable = lib.mkEnableOption ''
Improved file manager, a single-file web-based filemanager
Lightweight and minimal, served using PHP's built-in server
'';
dataDir = lib.mkOption {
type = lib.types.str;
description = "Directory to serve throught the file managing service";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address on which the service is listening";
example = "0.0.0.0";
};
port = lib.mkOption {
type = lib.types.port;
default = 9090;
description = "Port on which to serve the IFM service";
};
settings = lib.mkOption {
type = with lib.types; attrsOf anything;
default = { };
description = ''
Configuration of the IFM service.
See [the documentation](https://github.com/misterunknown/ifm/wiki/Configuration)
for available options and default values.
'';
example = {
IFM_GUI_SHOWPATH = 0;
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.ifm = {
description = "Improved file manager, a single-file web based filemanager";
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
}
// (builtins.mapAttrs (_: val: toString val) cfg.settings);
serviceConfig = {
DynamicUser = true;
User = "ifm";
StandardOutput = "journal";
BindPaths = "${cfg.dataDir}:/data";
PrivateTmp = true;
ExecStart = "${lib.getExe pkgs.ifm-web} ${lib.escapeShellArg cfg.listenAddress} ${builtins.toString cfg.port} /data";
};
};
};
meta.maintainers = with lib.maintainers; [ litchipi ];
}

View File

@@ -0,0 +1,97 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.immich-public-proxy;
format = pkgs.formats.json { };
inherit (lib)
types
mkIf
mkOption
mkEnableOption
;
in
{
options.services.immich-public-proxy = {
enable = mkEnableOption "Immich Public Proxy";
package = lib.mkPackageOption pkgs "immich-public-proxy" { };
immichUrl = mkOption {
type = types.str;
description = "URL of the Immich instance";
};
port = mkOption {
type = types.port;
default = 3000;
description = "The port that IPP will listen on.";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Whether to open the IPP port in the firewall";
};
settings = mkOption {
type = types.submodule {
freeformType = format.type;
};
default = { };
description = ''
Configuration for IPP. See <https://github.com/alangrainger/immich-public-proxy/blob/main/README.md#additional-configuration> for options and defaults.
'';
};
};
config = mkIf cfg.enable {
systemd.services.immich-public-proxy = {
description = "Immich public proxy for sharing albums publicly without exposing your Immich instance";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
IMMICH_URL = cfg.immichUrl;
IPP_PORT = builtins.toString cfg.port;
IPP_CONFIG = "${format.generate "config.json" cfg.settings}";
};
serviceConfig = {
ExecStart = lib.getExe cfg.package;
SyslogIdentifier = "ipp";
User = "ipp";
Group = "ipp";
DynamicUser = true;
Type = "simple";
Restart = "on-failure";
RestartSec = 3;
# Hardening
CapabilityBoundingSet = "";
NoNewPrivileges = true;
PrivateUsers = true;
PrivateTmp = true;
PrivateDevices = true;
PrivateMounts = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
};
};
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
};
meta.maintainers = with lib.maintainers; [ jaculabilis ];
}

View File

@@ -0,0 +1,32 @@
# Immich {#module-services-immich}
[Immich](https://immich.app/) is a self-hosted photo and video management
solution, similar to SaaS offerings like Google Photos.
## Migrating from `pgvecto-rs` to VectorChord (pre-25.11 installations) {#module-services-immich-vectorchord-migration}
Immich instances that were setup before 25.11 (as in
`system.stateVersion = 25.11;`) will be automatically migrated to VectorChord.
Note that this migration is not reversible, so database dumps should be created
if desired.
See [Immich documentation][vectorchord-migration-docs] for more details about
the automatic migration.
After a successful migration, `pgvecto-rs` should be removed from the database
installation, unless other applications depend on it.
1. Make sure VectorChord is enabled ([](#opt-services.immich.database.enableVectorChord)) and Immich has completed the migration. Refer to the [Immich documentation][vectorchord-migration-docs] for details.
2. Run the following two statements in the PostgreSQL database using a superuser role in Immich's database.
```sql
DROP EXTENSION vectors;
DROP SCHEMA vectors;
```
- You may use the following command to run these statements against the database: `sudo -u postgres psql immich` (Replace `immich` with the value of [](#opt-services.immich.database.name))
3. Disable `pgvecto-rs` by setting [](#opt-services.immich.database.enableVectors) to `false`.
4. Rebuild and switch.
[vectorchord-migration-docs]: https://immich.app/docs/administration/postgres-standalone/#migrating-to-vectorchord

View File

@@ -0,0 +1,486 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.immich;
format = pkgs.formats.json { };
isPostgresUnixSocket = lib.hasPrefix "/" cfg.database.host;
isRedisUnixSocket = lib.hasPrefix "/" cfg.redis.host;
# convert a Nix attribute path to jq object identifier-index:
# https://jqlang.org/manual/#object-identifier-index
attrPathToIndex = attrPath: "." + lib.concatStringsSep "." attrPath;
commonServiceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 3;
# Hardening
CapabilityBoundingSet = "";
NoNewPrivileges = true;
PrivateUsers = true;
PrivateTmp = true;
PrivateDevices = cfg.accelerationDevices == [ ];
DeviceAllow = mkIf (cfg.accelerationDevices != null) cfg.accelerationDevices;
PrivateMounts = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
UMask = "0077";
};
inherit (lib)
types
mkIf
mkOption
mkEnableOption
;
postgresqlPackage =
if cfg.database.enable then config.services.postgresql.package else pkgs.postgresql;
in
{
options.services.immich = {
enable = mkEnableOption "Immich";
package = lib.mkPackageOption pkgs "immich" { };
mediaLocation = mkOption {
type = types.path;
default = "/var/lib/immich";
description = "Directory used to store media files. If it is not the default, the directory has to be created manually such that the immich user is able to read and write to it.";
};
environment = mkOption {
type = types.submodule { freeformType = types.attrsOf types.str; };
default = { };
example = {
IMMICH_LOG_LEVEL = "verbose";
};
description = ''
Extra configuration environment variables. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options tagged with 'server', 'api' or 'microservices'.
'';
};
secretsFile = mkOption {
type = types.nullOr (
types.str
// {
# We don't want users to be able to pass a path literal here but
# it should look like a path.
check = it: lib.isString it && lib.types.path.check it;
}
);
default = null;
example = "/run/secrets/immich";
description = ''
Path of a file with extra environment variables to be loaded from disk. This file is not added to the nix store, so it can be used to pass secrets to immich. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options.
To set a database password set this to a file containing:
```
DB_PASSWORD=<pass>
```
'';
};
host = mkOption {
type = types.str;
default = "localhost";
description = "The host that immich will listen on.";
};
port = mkOption {
type = types.port;
default = 2283;
description = "The port that immich will listen on.";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Whether to open the immich port in the firewall";
};
user = mkOption {
type = types.str;
default = "immich";
description = "The user immich should run as.";
};
group = mkOption {
type = types.str;
default = "immich";
description = "The group immich should run as.";
};
settings = mkOption {
default = null;
description = ''
Configuration for Immich.
See <https://immich.app/docs/install/config-file/> or navigate to
<https://my.immich.app/admin/system-settings> for
options and defaults.
Setting it to `null` allows configuring Immich in the web interface.
'';
type = types.nullOr (
types.submodule {
freeformType = format.type;
options = {
newVersionCheck.enabled = mkOption {
type = types.bool;
default = false;
description = ''
Check for new versions.
This feature relies on periodic communication with github.com.
'';
};
server.externalDomain = mkOption {
type = types.str;
default = "";
description = "Domain for publicly shared links, including `http(s)://`.";
};
};
}
);
};
secretSettings = mkOption {
default = { };
description = ''
Secrets to to be added to the JSON file generated from {option}`settings`, read from files.
'';
example = lib.literalExpression ''
{
notifications.smtp.transport.password = "/path/to/secret";
oauth.clientSecret = "/path/to/other/secret";
}
'';
type =
let
inherit (types) attrsOf either path;
recursiveType = either (attrsOf recursiveType) path // {
description = "nested " + (attrsOf path).description;
};
in
recursiveType;
};
machine-learning = {
enable =
mkEnableOption "immich's machine-learning functionality to detect faces and search for objects"
// {
default = true;
};
environment = mkOption {
type = types.submodule { freeformType = types.attrsOf types.str; };
default = { };
example = {
MACHINE_LEARNING_MODEL_TTL = "600";
};
description = ''
Extra configuration environment variables. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options tagged with 'machine-learning'.
'';
};
};
accelerationDevices = mkOption {
type = types.nullOr (types.listOf types.str);
default = [ ];
example = [ "/dev/dri/renderD128" ];
description = ''
A list of device paths to hardware acceleration devices that immich should
have access to. This is useful when transcoding media files.
The special value `[ ]` will disallow all devices using `PrivateDevices`. `null` will give access to all devices.
'';
};
database = {
enable =
mkEnableOption "the postgresql database for use with immich. See {option}`services.postgresql`"
// {
default = true;
};
enableVectorChord =
mkEnableOption "the new VectorChord extension for full-text search in Postgres"
// {
default = true;
};
enableVectors =
mkEnableOption "pgvecto.rs in the database. You may disable this, if you have migrated to VectorChord and deleted the `vectors` schema."
// {
default = lib.versionOlder config.system.stateVersion "25.11";
defaultText = lib.literalExpression "lib.versionOlder config.system.stateVersion \"25.11\"";
};
createDB = mkEnableOption "the automatic creation of the database for immich." // {
default = true;
};
name = mkOption {
type = types.str;
default = "immich";
description = "The name of the immich database.";
};
host = mkOption {
type = types.str;
default = "/run/postgresql";
example = "127.0.0.1";
description = "Hostname or address of the postgresql server. If an absolute path is given here, it will be interpreted as a unix socket path.";
};
port = mkOption {
type = types.port;
default = 5432;
description = "Port of the postgresql server.";
};
user = mkOption {
type = types.str;
default = "immich";
description = "The database user for immich.";
};
};
redis = {
enable = mkEnableOption "a redis cache for use with immich" // {
default = true;
};
host = mkOption {
type = types.str;
default = config.services.redis.servers.immich.unixSocket;
defaultText = lib.literalExpression "config.services.redis.servers.immich.unixSocket";
description = "The host that redis will listen on.";
};
port = mkOption {
type = types.port;
default = 0;
description = "The port that redis will listen on. Set to zero to disable TCP.";
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = !isPostgresUnixSocket -> cfg.secretsFile != null;
message = "A secrets file containing at least the database password must be provided when unix sockets are not used.";
}
{
# When removing this assertion, please adjust the nixosTests accordingly.
assertion =
(cfg.database.enable && cfg.database.enableVectors)
-> lib.versionOlder config.services.postgresql.package.version "17";
message = "Immich doesn't support PostgreSQL 17+ when using pgvecto.rs. Consider disabling it using services.immich.database.enableVectors if it is not needed anymore.";
}
{
assertion = cfg.database.enable -> (cfg.database.enableVectorChord || cfg.database.enableVectors);
message = "At least one of services.immich.database.enableVectorChord and services.immich.database.enableVectors has to be enabled.";
}
];
services.postgresql = mkIf cfg.database.enable {
enable = true;
ensureDatabases = mkIf cfg.database.createDB [ cfg.database.name ];
ensureUsers = mkIf cfg.database.createDB [
{
name = cfg.database.user;
ensureDBOwnership = true;
ensureClauses.login = true;
}
];
extensions =
ps:
lib.optionals cfg.database.enableVectors [ ps.pgvecto-rs ]
++ lib.optionals cfg.database.enableVectorChord [
ps.pgvector
ps.vectorchord
];
settings = {
shared_preload_libraries =
lib.optionals cfg.database.enableVectors [
"vectors.so"
]
++ lib.optionals cfg.database.enableVectorChord [ "vchord.so" ];
search_path = "\"$user\", public, vectors";
};
};
systemd.services.postgresql-setup.serviceConfig.ExecStartPost =
let
extensions = [
"unaccent"
"uuid-ossp"
"cube"
"earthdistance"
"pg_trgm"
]
++ lib.optionals cfg.database.enableVectors [
"vectors"
]
++ lib.optionals cfg.database.enableVectorChord [
"vector"
"vchord"
];
sqlFile = pkgs.writeText "immich-pgvectors-setup.sql" ''
${lib.concatMapStringsSep "\n" (ext: "CREATE EXTENSION IF NOT EXISTS \"${ext}\";") extensions}
ALTER SCHEMA public OWNER TO ${cfg.database.user};
${lib.optionalString cfg.database.enableVectors "ALTER SCHEMA vectors OWNER TO ${cfg.database.user};"}
GRANT SELECT ON TABLE pg_vector_index_stat TO ${cfg.database.user};
${lib.concatMapStringsSep "\n" (ext: "ALTER EXTENSION \"${ext}\" UPDATE;") extensions}
'';
in
[
''
${lib.getExe' postgresqlPackage "psql"} -d "${cfg.database.name}" -f "${sqlFile}"
''
];
services.redis.servers = mkIf cfg.redis.enable {
immich = {
enable = true;
port = cfg.redis.port;
bind = mkIf (!isRedisUnixSocket) cfg.redis.host;
};
};
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
services.immich.environment =
let
postgresEnv =
if isPostgresUnixSocket then
{ DB_URL = "postgresql:///${cfg.database.name}?host=${cfg.database.host}"; }
else
{
DB_HOSTNAME = cfg.database.host;
DB_PORT = toString cfg.database.port;
DB_DATABASE_NAME = cfg.database.name;
DB_USERNAME = cfg.database.user;
};
redisEnv =
if isRedisUnixSocket then
{ REDIS_SOCKET = cfg.redis.host; }
else
{
REDIS_PORT = toString cfg.redis.port;
REDIS_HOSTNAME = cfg.redis.host;
};
in
postgresEnv
// redisEnv
// {
IMMICH_HOST = cfg.host;
IMMICH_PORT = toString cfg.port;
IMMICH_MEDIA_LOCATION = cfg.mediaLocation;
IMMICH_MACHINE_LEARNING_URL = "http://localhost:3003";
}
// lib.optionalAttrs (cfg.settings != null) {
IMMICH_CONFIG_FILE = "/run/immich/config.json";
};
services.immich.machine-learning.environment = {
MACHINE_LEARNING_WORKERS = "1";
MACHINE_LEARNING_WORKER_TIMEOUT = "120";
MACHINE_LEARNING_CACHE_FOLDER = "/var/cache/immich";
XDG_CACHE_HOME = "/var/cache/immich";
IMMICH_HOST = "localhost";
IMMICH_PORT = "3003";
};
systemd.slices.system-immich = {
description = "Immich (self-hosted photo and video backup solution) slice";
documentation = [ "https://immich.app/docs" ];
};
systemd.services.immich-server = {
description = "Immich backend server (Self-hosted photo and video backup solution)";
requires = lib.mkIf cfg.database.enable [ "postgresql.target" ];
after = [ "network.target" ] ++ lib.optionals cfg.database.enable [ "postgresql.target" ];
wantedBy = [ "multi-user.target" ];
inherit (cfg) environment;
path = [
# gzip and pg_dumpall are used by the backup service
pkgs.gzip
postgresqlPackage
];
preStart = mkIf (cfg.settings != null) (
''
cat '${format.generate "immich-config.json" cfg.settings}' > /run/immich/config.json
''
+ lib.concatStrings (
lib.mapAttrsToListRecursive (attrPath: _: ''
tmp="$(mktemp)"
${lib.getExe pkgs.jq} --rawfile secret "$CREDENTIALS_DIRECTORY/${attrPathToIndex attrPath}" \
'${attrPathToIndex attrPath} = $secret' /run/immich/config.json > "$tmp"
mv "$tmp" /run/immich/config.json
'') cfg.secretSettings
)
);
serviceConfig = commonServiceConfig // {
LoadCredential = lib.mapAttrsToListRecursive (
attrPath: file: "${attrPathToIndex attrPath}:${file}"
) cfg.secretSettings;
ExecStart = lib.getExe cfg.package;
EnvironmentFile = mkIf (cfg.secretsFile != null) cfg.secretsFile;
Slice = "system-immich.slice";
StateDirectory = "immich";
SyslogIdentifier = "immich";
RuntimeDirectory = "immich";
User = cfg.user;
Group = cfg.group;
# ensure that immich-server has permission to connect to the redis socket.
SupplementaryGroups = mkIf (cfg.redis.enable && isRedisUnixSocket) [
config.services.redis.servers.immich.group
];
};
};
systemd.services.immich-machine-learning = mkIf cfg.machine-learning.enable {
description = "immich machine learning";
requires = lib.mkIf cfg.database.enable [ "postgresql.target" ];
after = [ "network.target" ] ++ lib.optionals cfg.database.enable [ "postgresql.target" ];
wantedBy = [ "multi-user.target" ];
inherit (cfg.machine-learning) environment;
serviceConfig = commonServiceConfig // {
ExecStart = lib.getExe (cfg.package.machine-learning.override { immich = cfg.package; });
Slice = "system-immich.slice";
CacheDirectory = "immich";
User = cfg.user;
Group = cfg.group;
};
};
systemd.tmpfiles.settings = {
immich = {
# Redundant to the `UMask` service config setting on new installs, but installs made in
# early 24.11 created world-readable media storage by default, which is a privacy risk. This
# fixes those installs.
"${cfg.mediaLocation}" = {
e = {
user = cfg.user;
group = cfg.group;
mode = "0700";
};
};
};
};
users.users = mkIf (cfg.user == "immich") {
immich = {
name = "immich";
group = cfg.group;
isSystemUser = true;
};
};
users.groups = mkIf (cfg.group == "immich") { immich = { }; };
};
meta = {
maintainers = with lib.maintainers; [ jvanbruegge ];
doc = ./immich.md;
};
}

View File

@@ -0,0 +1,491 @@
{
lib,
config,
pkgs,
options,
...
}:
let
cfg = config.services.invidious;
# To allow injecting secrets with jq, json (instead of yaml) is used
settingsFormat = pkgs.formats.json { };
inherit (lib) types;
settingsFile = settingsFormat.generate "invidious-settings" cfg.settings;
generatedHmacKeyFile = "/var/lib/invidious/hmac_key";
generateHmac = cfg.hmacKeyFile == null;
commonInvidousServiceConfig = {
description = "Invidious (An alternative YouTube front-end)";
wants = [ "network-online.target" ];
after = [ "network-online.target" ] ++ lib.optional cfg.database.createLocally "postgresql.target";
requires = lib.optional cfg.database.createLocally "postgresql.target";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
RestartSec = "2s";
DynamicUser = true;
User = lib.mkIf (cfg.database.createLocally || cfg.serviceScale > 1) "invidious";
StateDirectory = "invidious";
StateDirectoryMode = "0750";
CapabilityBoundingSet = "";
PrivateDevices = true;
PrivateUsers = true;
ProtectHome = true;
ProtectKernelLogs = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
# Because of various issues Invidious must be restarted often, at least once a day, ideally
# every hour.
# This option enables the automatic restarting of the Invidious instance.
# To ensure multiple instances of Invidious are not restarted at the exact same time, a
# randomized extra offset of up to 5 minutes is added.
Restart = lib.mkDefault "always";
RuntimeMaxSec = lib.mkDefault "1h";
RuntimeRandomizedExtraSec = lib.mkDefault "5min";
};
};
mkInvidiousService =
scaleIndex:
lib.foldl' lib.recursiveUpdate commonInvidousServiceConfig [
# only generate the hmac file in the first service
(lib.optionalAttrs (scaleIndex == 0) {
preStart = lib.optionalString generateHmac ''
if [[ ! -e "${generatedHmacKeyFile}" ]]; then
${pkgs.pwgen}/bin/pwgen 20 1 > "${generatedHmacKeyFile}"
chmod 0600 "${generatedHmacKeyFile}"
fi
'';
})
# configure the secondary services to run after the first service
(lib.optionalAttrs (scaleIndex > 0) {
after = commonInvidousServiceConfig.after ++ [ "invidious.service" ];
wants = commonInvidousServiceConfig.wants ++ [ "invidious.service" ];
})
{
script = ''
configParts=()
''
# autogenerated hmac_key
+ lib.optionalString generateHmac ''
configParts+=("$(${pkgs.jq}/bin/jq -R '{"hmac_key":.}' <"${generatedHmacKeyFile}")")
''
# generated settings file
+ ''
configParts+=("$(< ${lib.escapeShellArg settingsFile})")
''
# optional database password file
+ lib.optionalString (cfg.database.host != null) ''
configParts+=("$(${pkgs.jq}/bin/jq -R '{"db":{"password":.}}' ${lib.escapeShellArg cfg.database.passwordFile})")
''
# optional extra settings file
+ lib.optionalString (cfg.extraSettingsFile != null) ''
configParts+=("$(< ${lib.escapeShellArg cfg.extraSettingsFile})")
''
# explicitly specified hmac key file
+ lib.optionalString (cfg.hmacKeyFile != null) ''
configParts+=("$(< ${lib.escapeShellArg cfg.hmacKeyFile})")
''
# configure threads for secondary instances
+ lib.optionalString (scaleIndex > 0) ''
configParts+=('{"channel_threads":0, "feed_threads":0}')
''
# configure different ports for the instances
+ ''
configParts+=('{"port":${toString (cfg.port + scaleIndex)}}')
''
# merge all parts into a single configuration with later elements overriding previous elements
+ ''
export INVIDIOUS_CONFIG="$(${pkgs.jq}/bin/jq -s 'reduce .[] as $item ({}; . * $item)' <<<"''${configParts[*]}")"
exec ${cfg.package}/bin/invidious
'';
}
];
serviceConfig = {
systemd.services = builtins.listToAttrs (
builtins.genList (scaleIndex: {
name = "invidious" + lib.optionalString (scaleIndex > 0) "-${builtins.toString scaleIndex}";
value = mkInvidiousService scaleIndex;
}) cfg.serviceScale
);
services.invidious.settings = {
# Automatically initialises and migrates the database if necessary
check_tables = true;
db = {
user = lib.mkDefault (
if (lib.versionAtLeast config.system.stateVersion "24.05") then "invidious" else "kemal"
);
dbname = lib.mkDefault "invidious";
port = cfg.database.port;
# Blank for unix sockets, see
# https://github.com/will/crystal-pg/blob/1548bb255210/src/pq/conninfo.cr#L100-L108
host = lib.optionalString (cfg.database.host != null) cfg.database.host;
# Not needed because peer authentication is enabled
password = lib.mkIf (cfg.database.host == null) "";
};
host_binding = cfg.address;
}
// (lib.optionalAttrs (cfg.domain != null) {
inherit (cfg) domain;
});
assertions = [
{
assertion = cfg.database.host != null -> cfg.database.passwordFile != null;
message = "If database host isn't null, database password needs to be set";
}
{
assertion = cfg.serviceScale >= 1;
message = "Service can't be scaled below one instance";
}
];
};
# Settings necessary for running with an automatically managed local database
localDatabaseConfig = lib.mkIf cfg.database.createLocally {
assertions = [
{
assertion = cfg.settings.db.user == cfg.settings.db.dbname;
message = ''
For local automatic database provisioning (services.invidious.database.createLocally == true)
to work, the username used to connect to PostgreSQL must match the database name, that is
services.invidious.settings.db.user must match services.invidious.settings.db.dbname.
This is the default since NixOS 24.05. For older systems, it is normally safe to manually set
the user to "invidious" as the new user will be created with permissions
for the existing database. `REASSIGN OWNED BY kemal TO invidious;` may also be needed, it can be
run as `sudo -u postgres env psql --user=postgres --dbname=invidious -c 'reassign OWNED BY kemal to invidious;'`.
'';
}
];
# Default to using the local database if we create it
services.invidious.database.host = lib.mkDefault null;
services.postgresql = {
enable = true;
ensureUsers = lib.singleton {
name = cfg.settings.db.user;
ensureDBOwnership = true;
};
ensureDatabases = lib.singleton cfg.settings.db.dbname;
};
};
ytproxyConfig = lib.mkIf cfg.http3-ytproxy.enable {
systemd.services.http3-ytproxy = {
description = "HTTP3 ytproxy for Invidious";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
script = ''
mkdir -p socket
exec ${lib.getExe cfg.http3-ytproxy.package};
'';
serviceConfig = {
RestartSec = "2s";
DynamicUser = true;
User = lib.mkIf cfg.nginx.enable config.services.nginx.user;
RuntimeDirectory = "http3-ytproxy";
WorkingDirectory = "/run/http3-ytproxy";
};
};
services.nginx.virtualHosts.${cfg.domain} = lib.mkIf cfg.nginx.enable {
locations."~ (^/videoplayback|^/vi/|^/ggpht/|^/sb/)" = {
proxyPass = "http://unix:/run/http3-ytproxy/socket/http-proxy.sock";
};
};
};
sigHelperConfig = lib.mkIf cfg.sig-helper.enable {
services.invidious.settings.signature_server = "tcp://${cfg.sig-helper.listenAddress}";
systemd.services.invidious-sig-helper = {
script = ''
exec ${lib.getExe cfg.sig-helper.package} --tcp "${cfg.sig-helper.listenAddress}"
'';
wantedBy = [ "multi-user.target" ];
before = [ "invidious.service" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
User = "invidious-sig-helper";
DynamicUser = true;
Restart = "always";
PrivateTmp = true;
PrivateUsers = true;
ProtectSystem = true;
ProtectProc = "invisible";
ProtectHome = true;
PrivateDevices = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
CapabilityBoundingSet = "";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
"@network-io"
];
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
};
};
};
nginxConfig = lib.mkIf cfg.nginx.enable {
services.invidious.settings = {
https_only = config.services.nginx.virtualHosts.${cfg.domain}.forceSSL;
external_port = 80;
};
services.nginx =
let
ip = if cfg.address == "0.0.0.0" then "127.0.0.1" else cfg.address;
in
{
enable = true;
virtualHosts.${cfg.domain} = {
locations."/".proxyPass =
if cfg.serviceScale == 1 then "http://${ip}:${toString cfg.port}" else "http://upstream-invidious";
enableACME = lib.mkDefault true;
forceSSL = lib.mkDefault true;
};
upstreams = lib.mkIf (cfg.serviceScale > 1) {
"upstream-invidious".servers = builtins.listToAttrs (
builtins.genList (scaleIndex: {
name = "${ip}:${toString (cfg.port + scaleIndex)}";
value = { };
}) cfg.serviceScale
);
};
};
assertions = [
{
assertion = cfg.domain != null;
message = "To use services.invidious.nginx, you need to set services.invidious.domain";
}
];
};
in
{
options.services.invidious = {
enable = lib.mkEnableOption "Invidious";
package = lib.mkPackageOption pkgs "invidious" { };
settings = lib.mkOption {
type = settingsFormat.type;
default = { };
description = ''
The settings Invidious should use.
See [config.example.yml](https://github.com/iv-org/invidious/blob/master/config/config.example.yml) for a list of all possible options.
'';
};
hmacKeyFile = lib.mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A path to a file containing the `hmac_key`. If `null`, a key will be generated automatically on first
start.
If non-`null`, this option overrides any `hmac_key` specified in {option}`services.invidious.settings` or
via {option}`services.invidious.extraSettingsFile`.
'';
};
extraSettingsFile = lib.mkOption {
type = types.nullOr types.str;
default = null;
description = ''
A file including Invidious settings.
It gets merged with the settings specified in {option}`services.invidious.settings`
and can be used to store secrets like `hmac_key` outside of the nix store.
'';
};
serviceScale = lib.mkOption {
type = types.int;
default = 1;
description = ''
How many invidious instances to run.
See <https://docs.invidious.io/improve-public-instance/#2-multiple-invidious-processes> for more details
on how this is intended to work. All instances beyond the first one have the options `channel_threads`
and `feed_threads` set to 0 to avoid conflicts with multiple instances refreshing subscriptions. Instances
will be configured to bind to consecutive ports starting with {option}`services.invidious.port` for the
first instance.
'';
};
# This needs to be outside of settings to avoid infinite recursion
# (determining if nginx should be enabled and therefore the settings
# modified).
domain = lib.mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The FQDN Invidious is reachable on.
This is used to configure nginx and for building absolute URLs.
'';
};
address = lib.mkOption {
type = types.str;
# default from https://github.com/iv-org/invidious/blob/master/config/config.example.yml
default = if cfg.nginx.enable then "127.0.0.1" else "0.0.0.0";
defaultText = lib.literalExpression ''if config.services.invidious.nginx.enable then "127.0.0.1" else "0.0.0.0"'';
description = ''
The IP address Invidious should bind to.
'';
};
port = lib.mkOption {
type = types.port;
# Default from https://docs.invidious.io/Configuration.md
default = 3000;
description = ''
The port Invidious should listen on.
To allow access from outside,
you can use either {option}`services.invidious.nginx`
or add `config.services.invidious.port` to {option}`networking.firewall.allowedTCPPorts`.
'';
};
database = {
createLocally = lib.mkOption {
type = types.bool;
default = true;
description = ''
Whether to create a local database with PostgreSQL.
'';
};
host = lib.mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The database host Invidious should use.
If `null`, the local unix socket is used. Otherwise
TCP is used.
'';
};
port = lib.mkOption {
type = types.port;
default = config.services.postgresql.settings.port;
defaultText = lib.literalExpression "config.services.postgresql.settings.port";
description = ''
The port of the database Invidious should use.
Defaults to the the default postgresql port.
'';
};
passwordFile = lib.mkOption {
type = types.nullOr types.str;
apply = lib.mapNullable toString;
default = null;
description = ''
Path to file containing the database password.
'';
};
};
nginx.enable = lib.mkOption {
type = types.bool;
default = false;
description = ''
Whether to configure nginx as a reverse proxy for Invidious.
It serves it under the domain specified in {option}`services.invidious.settings.domain` with enabled TLS and ACME.
Further configuration can be done through {option}`services.nginx.virtualHosts.''${config.services.invidious.settings.domain}.*`,
which can also be used to disable AMCE and TLS.
'';
};
http3-ytproxy = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable http3-ytproxy for faster loading of images and video playback.
If {option}`services.invidious.nginx.enable` is used, nginx will be configured automatically. If not, you
need to configure a reverse proxy yourself according to
<https://docs.invidious.io/improve-public-instance/#3-speed-up-video-playback-with-http3-ytproxy>.
'';
};
package = lib.mkPackageOption pkgs "http3-ytproxy" { };
};
sig-helper = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable and configure inv-sig-helper to emulate the youtube client's javascript. This is required
to make certain videos playable.
This will download and run completely untrusted javascript from youtube! While this service is sandboxed,
this may still be an issue!
'';
};
package = lib.mkPackageOption pkgs "inv-sig-helper" { };
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1:2999";
description = ''
The IP address/port where inv-sig-helper should listen.
'';
};
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
serviceConfig
localDatabaseConfig
nginxConfig
ytproxyConfig
sigHelperConfig
]
);
}

View File

@@ -0,0 +1,110 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkOption
types
literalExpression
;
cfg = config.services.isso;
settingsFormat = pkgs.formats.ini { };
configFile = settingsFormat.generate "isso.conf" cfg.settings;
in
{
options = {
services.isso = {
enable = mkEnableOption ''
isso, a commenting server similar to Disqus.
Note: The application's author suppose to run isso behind a reverse proxy.
The embedded solution offered by NixOS is also only suitable for small installations
below 20 requests per second
'';
settings = mkOption {
description = ''
Configuration for `isso`.
See [Isso Server Configuration](https://posativ.org/isso/docs/configuration/server/)
for supported values.
'';
type = types.submodule {
freeformType = settingsFormat.type;
};
example = literalExpression ''
{
general = {
host = "http://localhost";
};
}
'';
};
};
};
config = mkIf cfg.enable {
services.isso.settings.general.dbpath = lib.mkDefault "/var/lib/isso/comments.db";
systemd.services.isso = {
description = "isso, a commenting server similar to Disqus";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "isso";
Group = "isso";
DynamicUser = true;
StateDirectory = "isso";
ExecStart = ''
${pkgs.isso}/bin/isso -c ${configFile}
'';
Restart = "on-failure";
RestartSec = 1;
# Hardening
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
PrivateDevices = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
UMask = "0077";
};
};
};
}

View File

@@ -0,0 +1,180 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.jirafeau;
group = config.services.nginx.group;
user = config.services.nginx.user;
withTrailingSlash = str: if hasSuffix "/" str then str else "${str}/";
localConfig = pkgs.writeText "config.local.php" ''
<?php
$cfg['admin_password'] = '${cfg.adminPasswordSha256}';
$cfg['web_root'] = 'http://${withTrailingSlash cfg.hostName}';
$cfg['var_root'] = '${withTrailingSlash cfg.dataDir}';
$cfg['maximal_upload_size'] = ${builtins.toString cfg.maxUploadSizeMegabytes};
$cfg['installation_done'] = true;
${cfg.extraConfig}
'';
in
{
options.services.jirafeau = {
adminPasswordSha256 = mkOption {
type = types.str;
default = "";
description = ''
SHA-256 of the desired administration password. Leave blank/unset for no password.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/jirafeau/data/";
description = "Location of Jirafeau storage directory.";
};
enable = mkEnableOption "Jirafeau file upload application";
extraConfig = mkOption {
type = types.lines;
default = "";
example = ''
$cfg['style'] = 'courgette';
$cfg['organisation'] = 'ACME';
'';
description =
let
documentationLink = "https://gitlab.com/mojo42/Jirafeau/-/blob/${cfg.package.version}/lib/config.original.php";
in
''
Jirefeau configuration. Refer to <${documentationLink}> for supported
values.
'';
};
hostName = mkOption {
type = types.str;
default = "localhost";
description = "URL of instance. Must have trailing slash.";
};
maxUploadSizeMegabytes = mkOption {
type = types.int;
default = 0;
description = "Maximum upload size of accepted files.";
};
maxUploadTimeout = mkOption {
type = types.str;
default = "30m";
description =
let
nginxCoreDocumentation = "http://nginx.org/en/docs/http/ngx_http_core_module.html";
in
''
Timeout for reading client request bodies and headers. Refer to
<${nginxCoreDocumentation}#client_body_timeout> and
<${nginxCoreDocumentation}#client_header_timeout> for accepted values.
'';
};
nginxConfig = mkOption {
type = types.submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; });
default = { };
example = literalExpression ''
{
serverAliases = [ "wiki.''${config.networking.domain}" ];
}
'';
description = "Extra configuration for the nginx virtual host of Jirafeau.";
};
package = mkPackageOption pkgs "jirafeau" { };
poolConfig = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for Jirafeau PHP pool. See documentation on `php-fpm.conf` for
details on configuration directives.
'';
};
};
config = mkIf cfg.enable {
services = {
nginx = {
enable = true;
virtualHosts."${cfg.hostName}" = mkMerge [
cfg.nginxConfig
{
extraConfig =
let
clientMaxBodySize =
if cfg.maxUploadSizeMegabytes == 0 then "0" else "${cfg.maxUploadSizeMegabytes}m";
in
''
index index.php;
client_max_body_size ${clientMaxBodySize};
client_body_timeout ${cfg.maxUploadTimeout};
client_header_timeout ${cfg.maxUploadTimeout};
'';
locations = {
"~ \\.php$".extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_index index.php;
fastcgi_pass unix:${config.services.phpfpm.pools.jirafeau.socket};
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
'';
};
root = mkForce "${cfg.package}";
}
];
};
phpfpm.pools.jirafeau = {
inherit group user;
phpEnv."JIRAFEAU_CONFIG" = "${localConfig}";
settings = {
"listen.mode" = "0660";
"listen.owner" = user;
"listen.group" = group;
}
// cfg.poolConfig;
};
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${user} ${group} - -"
"d ${cfg.dataDir}/files/ 0750 ${user} ${group} - -"
"d ${cfg.dataDir}/links/ 0750 ${user} ${group} - -"
"d ${cfg.dataDir}/async/ 0750 ${user} ${group} - -"
];
};
# uses attributes of the linked package
meta.buildDocsInSandbox = false;
}

View File

@@ -0,0 +1,59 @@
# Jitsi Meet {#module-services-jitsi-meet}
With Jitsi Meet on NixOS you can quickly configure a complete,
private, self-hosted video conferencing solution.
## Basic usage {#module-services-jitsi-basic-usage}
A minimal configuration using Let's Encrypt for TLS certificates looks like this:
```nix
{
services.jitsi-meet = {
enable = true;
hostName = "jitsi.example.com";
};
services.jitsi-videobridge.openFirewall = true;
networking.firewall.allowedTCPPorts = [
80
443
];
security.acme.email = "me@example.com";
security.acme.acceptTerms = true;
}
```
Jitsi Meet depends on the Prosody XMPP server only for message passing from
the web browser while the default Prosody configuration is intended for use
with standalone XMPP clients and XMPP federation. If you only use Prosody as
a backend for Jitsi Meet it is therefore recommended to also enable
{option}`services.jitsi-meet.prosody.lockdown` option to disable unnecessary
Prosody features such as federation or the file proxy.
## Configuration {#module-services-jitsi-configuration}
Here is the minimal configuration with additional configurations:
```nix
{
services.jitsi-meet = {
enable = true;
hostName = "jitsi.example.com";
prosody.lockdown = true;
config = {
enableWelcomePage = false;
prejoinPageEnabled = true;
defaultLang = "fi";
};
interfaceConfig = {
SHOW_JITSI_WATERMARK = false;
SHOW_WATERMARK_FOR_GUESTS = false;
};
};
services.jitsi-videobridge.openFirewall = true;
networking.firewall.allowedTCPPorts = [
80
443
];
security.acme.email = "me@example.com";
security.acme.acceptTerms = true;
}
```

View File

@@ -0,0 +1,760 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.jitsi-meet;
# The configuration files are JS of format "var <<string>> = <<JSON>>;". In order to
# override only some settings, we need to extract the JSON, use jq to merge it with
# the config provided by user, and then reconstruct the file.
overrideJs =
source: varName: userCfg: appendExtra:
let
extractor = pkgs.writeText "extractor.js" ''
var fs = require("fs");
eval(fs.readFileSync(process.argv[2], 'utf8'));
process.stdout.write(JSON.stringify(eval(process.argv[3])));
'';
userJson = pkgs.writeText "user.json" (builtins.toJSON userCfg);
in
(pkgs.runCommand "${varName}.js" { } ''
${pkgs.nodejs}/bin/node ${extractor} ${source} ${varName} > default.json
(
echo "var ${varName} = "
${pkgs.jq}/bin/jq -s '.[0] * .[1]' default.json ${userJson}
echo ";"
echo ${escapeShellArg appendExtra}
) > $out
'');
# Essential config - it's probably not good to have these as option default because
# types.attrs doesn't do merging. Let's merge explicitly, can still be overridden if
# user desires.
defaultCfg = {
hosts = {
domain = cfg.hostName;
muc = "conference.${cfg.hostName}";
focus = "focus.${cfg.hostName}";
jigasi = "jigasi.${cfg.hostName}";
};
bosh = "//${cfg.hostName}/http-bind";
websocket = "wss://${cfg.hostName}/xmpp-websocket";
fileRecordingsEnabled = true;
liveStreamingEnabled = true;
hiddenDomain = "recorder.${cfg.hostName}";
};
in
{
options.services.jitsi-meet = with types; {
enable = mkEnableOption "Jitsi Meet - Secure, Simple and Scalable Video Conferences";
hostName = mkOption {
type = str;
example = "meet.example.org";
description = ''
FQDN of the Jitsi Meet instance.
'';
};
config = mkOption {
type = attrs;
default = { };
example = literalExpression ''
{
enableWelcomePage = false;
defaultLang = "fi";
}
'';
description = ''
Client-side web application settings that override the defaults in {file}`config.js`.
See <https://github.com/jitsi/jitsi-meet/blob/master/config.js> for default
configuration with comments.
'';
};
extraConfig = mkOption {
type = lines;
default = "";
description = ''
Text to append to {file}`config.js` web application config file.
Can be used to insert JavaScript logic to determine user's region in cascading bridges setup.
'';
};
interfaceConfig = mkOption {
type = attrs;
default = { };
example = literalExpression ''
{
SHOW_JITSI_WATERMARK = false;
SHOW_WATERMARK_FOR_GUESTS = false;
}
'';
description = ''
Client-side web-app interface settings that override the defaults in {file}`interface_config.js`.
See <https://github.com/jitsi/jitsi-meet/blob/master/interface_config.js> for
default configuration with comments.
'';
};
videobridge = {
enable = mkOption {
type = bool;
default = true;
description = ''
Jitsi Videobridge instance and configure it to connect to Prosody.
Additional configuration is possible with {option}`services.jitsi-videobridge`
'';
};
passwordFile = mkOption {
type = nullOr str;
default = null;
example = "/run/keys/videobridge";
description = ''
File containing password to the Prosody account for videobridge.
If `null`, a file with password will be generated automatically. Setting
this option is useful if you plan to connect additional videobridges to the XMPP server.
'';
};
};
jicofo.enable = mkOption {
type = bool;
default = true;
description = ''
Whether to enable JiCoFo instance and configure it to connect to Prosody.
Additional configuration is possible with {option}`services.jicofo`.
'';
};
jibri.enable = mkOption {
type = bool;
default = false;
description = ''
Whether to enable a Jibri instance and configure it to connect to Prosody.
Additional configuration is possible with {option}`services.jibri`, and
{option}`services.jibri.finalizeScript` is especially useful.
'';
};
jigasi.enable = mkOption {
type = bool;
default = false;
description = ''
Whether to enable jigasi instance and configure it to connect to Prosody.
Additional configuration is possible with <option>services.jigasi</option>.
'';
};
nginx.enable = mkOption {
type = bool;
default = true;
description = ''
Whether to enable nginx virtual host that will serve the javascript application and act as
a proxy for the XMPP server. Further nginx configuration can be done by adapting
{option}`services.nginx.virtualHosts.<hostName>`.
When this is enabled, ACME will be used to retrieve a TLS certificate by default. To disable
this, set the {option}`services.nginx.virtualHosts.<hostName>.enableACME` to
`false` and if appropriate do the same for
{option}`services.nginx.virtualHosts.<hostName>.forceSSL`.
'';
};
caddy.enable = mkEnableOption "caddy reverse proxy to expose jitsi-meet";
prosody.enable = mkOption {
type = bool;
default = true;
example = false;
description = ''
Whether to configure Prosody to relay XMPP messages between Jitsi Meet components. Turn this
off if you want to configure it manually.
'';
};
prosody.allowners_muc = mkOption {
type = bool;
default = false;
description = ''
Add module allowners, any user in chat is able to
kick other. Usefull in jitsi-meet to kick ghosts.
'';
};
prosody.lockdown = mkOption {
type = bool;
default = false;
example = true;
description = ''
Whether to disable Prosody features not needed by Jitsi Meet.
The default Prosody configuration assumes that it will be used as a
general-purpose XMPP server rather than as a companion service for
Jitsi Meet. This option reconfigures Prosody to only listen on
localhost without support for TLS termination, XMPP federation or
the file transfer proxy.
'';
};
excalidraw.enable = mkEnableOption "Excalidraw collaboration backend for Jitsi";
excalidraw.port = mkOption {
type = types.port;
default = 3002;
description = ''The port which the Excalidraw backend for Jitsi should listen to.'';
};
secureDomain = {
enable = mkEnableOption "Authenticated room creation";
authentication = mkOption {
type = types.str;
default = "internal_hashed";
description = ''The authentication type to be used by jitsi'';
};
};
};
config = mkIf cfg.enable {
services.prosody = mkIf cfg.prosody.enable {
# required for muc_breakout_rooms
package = lib.mkDefault (
pkgs.prosody.override {
withExtraLuaPackages = p: with p; [ cjson ];
}
);
enable = mkDefault true;
xmppComplianceSuite = mkDefault false;
modules = {
admin_adhoc = mkDefault false;
bosh = mkDefault true;
ping = mkDefault true;
roster = mkDefault true;
saslauth = mkDefault true;
smacks = mkDefault true;
tls = mkDefault true;
websocket = mkDefault true;
proxy65 = mkIf cfg.prosody.lockdown (mkDefault false);
};
httpInterfaces = mkIf cfg.prosody.lockdown (mkDefault [ "127.0.0.1" ]);
httpsPorts = mkIf cfg.prosody.lockdown (mkDefault [ ]);
muc = [
{
domain = "conference.${cfg.hostName}";
name = "Jitsi Meet MUC";
allowners_muc = cfg.prosody.allowners_muc;
roomLocking = false;
roomDefaultPublicJids = true;
extraConfig = ''
restrict_room_creation = true
storage = "memory"
admins = { "focus@auth.${cfg.hostName}" }
'';
}
{
domain = "breakout.${cfg.hostName}";
name = "Jitsi Meet Breakout MUC";
roomLocking = false;
roomDefaultPublicJids = true;
extraConfig = ''
restrict_room_creation = true
storage = "memory"
admins = { "focus@auth.${cfg.hostName}", "jvb@auth.${cfg.hostName}" }
'';
}
{
domain = "internal.auth.${cfg.hostName}";
name = "Jitsi Meet Videobridge MUC";
roomLocking = false;
roomDefaultPublicJids = true;
extraConfig = ''
storage = "memory"
admins = { "focus@auth.${cfg.hostName}", "jvb@auth.${cfg.hostName}", "jigasi@auth.${cfg.hostName}" }
'';
#-- muc_room_cache_size = 1000
}
{
domain = "lobby.${cfg.hostName}";
name = "Jitsi Meet Lobby MUC";
roomLocking = false;
roomDefaultPublicJids = true;
extraConfig = ''
restrict_room_creation = true
storage = "memory"
'';
}
];
extraModules = [
"pubsub"
"smacks"
"speakerstats"
"external_services"
"conference_duration"
"muc_lobby_rooms"
"muc_breakout_rooms"
"av_moderation"
"muc_hide_all"
"muc_meeting_id"
"muc_domain_mapper"
"muc_rate_limit"
"limits_exception"
"persistent_lobby"
"room_metadata"
];
extraPluginPaths = [ "${pkgs.jitsi-meet-prosody}/share/prosody-plugins" ];
extraConfig = lib.mkMerge [
(mkAfter ''
Component "focus.${cfg.hostName}" "client_proxy"
target_address = "focus@auth.${cfg.hostName}"
Component "jigasi.${cfg.hostName}" "client_proxy"
target_address = "jigasi@auth.${cfg.hostName}"
Component "speakerstats.${cfg.hostName}" "speakerstats_component"
muc_component = "conference.${cfg.hostName}"
Component "conferenceduration.${cfg.hostName}" "conference_duration_component"
muc_component = "conference.${cfg.hostName}"
Component "endconference.${cfg.hostName}" "end_conference"
muc_component = "conference.${cfg.hostName}"
Component "avmoderation.${cfg.hostName}" "av_moderation_component"
muc_component = "conference.${cfg.hostName}"
Component "metadata.${cfg.hostName}" "room_metadata_component"
muc_component = "conference.${cfg.hostName}"
breakout_rooms_component = "breakout.${cfg.hostName}"
'')
(mkBefore (
''
muc_mapper_domain_base = "${cfg.hostName}"
http_cors_override = {
websocket = { enabled = true }
}
consider_websocket_secure = true;
unlimited_jids = {
"focus@auth.${cfg.hostName}",
"jvb@auth.${cfg.hostName}"
}
''
+ optionalString cfg.prosody.lockdown ''
c2s_interfaces = { "127.0.0.1" };
modules_disabled = { "s2s" };
''
))
];
virtualHosts.${cfg.hostName} = {
enabled = true;
domain = cfg.hostName;
extraConfig = ''
authentication = ${
if cfg.secureDomain.enable then "\"${cfg.secureDomain.authentication}\"" else "\"jitsi-anonymous\""
}
c2s_require_encryption = false
admins = { "focus@auth.${cfg.hostName}" }
smacks_max_unacked_stanzas = 5
smacks_hibernation_time = 60
smacks_max_hibernated_sessions = 1
smacks_max_old_sessions = 1
av_moderation_component = "avmoderation.${cfg.hostName}"
speakerstats_component = "speakerstats.${cfg.hostName}"
conference_duration_component = "conferenceduration.${cfg.hostName}"
end_conference_component = "endconference.${cfg.hostName}"
lobby_muc = "lobby.${cfg.hostName}"
breakout_rooms_muc = "breakout.${cfg.hostName}"
room_metadata_component = "metadata.${cfg.hostName}"
main_muc = "conference.${cfg.hostName}"
'';
ssl = {
cert = "/var/lib/jitsi-meet/jitsi-meet.crt";
key = "/var/lib/jitsi-meet/jitsi-meet.key";
};
};
virtualHosts."auth.${cfg.hostName}" = {
enabled = true;
domain = "auth.${cfg.hostName}";
extraConfig = ''
authentication = "internal_hashed"
'';
ssl = {
cert = "/var/lib/jitsi-meet/jitsi-meet.crt";
key = "/var/lib/jitsi-meet/jitsi-meet.key";
};
};
virtualHosts."recorder.${cfg.hostName}" = {
enabled = true;
domain = "recorder.${cfg.hostName}";
extraConfig = ''
authentication = "internal_plain"
c2s_require_encryption = false
'';
};
virtualHosts."guest.${cfg.hostName}" = {
enabled = true;
domain = "guest.${cfg.hostName}";
extraConfig = ''
authentication = "anonymous"
c2s_require_encryption = false
'';
};
};
systemd.services.prosody = mkIf cfg.prosody.enable {
preStart =
let
videobridgeSecret =
if cfg.videobridge.passwordFile != null then
cfg.videobridge.passwordFile
else
"/var/lib/jitsi-meet/videobridge-secret";
in
''
${config.services.prosody.package}/bin/prosodyctl register focus auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jicofo-user-secret)"
${config.services.prosody.package}/bin/prosodyctl register jvb auth.${cfg.hostName} "$(cat ${videobridgeSecret})"
${config.services.prosody.package}/bin/prosodyctl mod_roster_command subscribe focus.${cfg.hostName} focus@auth.${cfg.hostName}
${config.services.prosody.package}/bin/prosodyctl register jibri auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jibri-auth-secret)"
${config.services.prosody.package}/bin/prosodyctl register recorder recorder.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jibri-recorder-secret)"
''
+ optionalString cfg.jigasi.enable ''
${config.services.prosody.package}/bin/prosodyctl register jigasi auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jigasi-user-secret)"
'';
serviceConfig = {
EnvironmentFile = [ "/var/lib/jitsi-meet/secrets-env" ];
SupplementaryGroups = [ "jitsi-meet" ];
};
reloadIfChanged = true;
};
users.groups.jitsi-meet = { };
systemd.tmpfiles.rules = [
"d '/var/lib/jitsi-meet' 0750 root jitsi-meet - -"
];
systemd.services.jitsi-meet-init-secrets = {
wantedBy = [ "multi-user.target" ];
before = [
"jicofo.service"
"jitsi-videobridge2.service"
]
++ (optional cfg.prosody.enable "prosody.service")
++ (optional cfg.jigasi.enable "jigasi.service");
serviceConfig = {
Type = "oneshot";
UMask = "027";
User = "root";
Group = "jitsi-meet";
WorkingDirectory = "/var/lib/jitsi-meet";
};
script =
let
secrets = [
"jicofo-component-secret"
"jicofo-user-secret"
"jibri-auth-secret"
"jibri-recorder-secret"
]
++ (optionals cfg.jigasi.enable [
"jigasi-user-secret"
"jigasi-component-secret"
])
++ (optional (cfg.videobridge.passwordFile == null) "videobridge-secret");
in
''
${concatMapStringsSep "\n" (s: ''
if [ ! -f ${s} ]; then
tr -dc a-zA-Z0-9 </dev/urandom | head -c 64 > ${s}
fi
'') secrets}
# for easy access in prosody
echo "JICOFO_COMPONENT_SECRET=$(cat jicofo-component-secret)" > secrets-env
echo "JIGASI_COMPONENT_SECRET=$(cat jigasi-component-secret)" >> secrets-env
''
+ optionalString cfg.prosody.enable ''
# generate self-signed certificates
if [ ! -f /var/lib/jitsi-meet/jitsi-meet.crt ]; then
${getBin pkgs.openssl}/bin/openssl req \
-x509 \
-newkey rsa:4096 \
-keyout /var/lib/jitsi-meet/jitsi-meet.key \
-out /var/lib/jitsi-meet/jitsi-meet.crt \
-days 36500 \
-nodes \
-subj '/CN=${cfg.hostName}/CN=auth.${cfg.hostName}'
chmod 640 /var/lib/jitsi-meet/jitsi-meet.key
fi
'';
};
systemd.services.jitsi-excalidraw = mkIf cfg.excalidraw.enable {
description = "Excalidraw collaboration backend for Jitsi";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment.PORT = toString cfg.excalidraw.port;
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.jitsi-excalidraw}/bin/jitsi-excalidraw-backend";
Restart = "on-failure";
DynamicUser = true;
Group = "jitsi-meet";
CapabilityBoundingSet = "";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectClock = true;
ProtectHome = true;
ProtectProc = "noaccess";
ProtectKernelLogs = true;
PrivateTmp = true;
PrivateDevices = true;
PrivateUsers = true;
ProtectHostname = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
LockPersonality = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallFilter = [
"@system-service @pkey"
"~@privileged"
];
};
};
services.nginx = mkIf cfg.nginx.enable {
enable = mkDefault true;
virtualHosts.${cfg.hostName} = {
enableACME = mkDefault true;
forceSSL = mkDefault true;
root = pkgs.jitsi-meet;
extraConfig = ''
ssi on;
'';
locations."@root_path".extraConfig = ''
rewrite ^/(.*)$ / break;
'';
locations."~ ^/([^/\\?&:'\"]+)$".tryFiles = "$uri @root_path";
locations."^~ /xmpp-websocket" = {
priority = 100;
proxyPass = "http://localhost:5280/xmpp-websocket";
proxyWebsockets = true;
};
locations."=/http-bind" = {
proxyPass = "http://localhost:5280/http-bind";
extraConfig = ''
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
'';
};
locations."=/external_api.js" = mkDefault {
alias = "${pkgs.jitsi-meet}/libs/external_api.min.js";
};
locations."=/_api/room-info" = {
proxyPass = "http://localhost:5280/room-info";
extraConfig = ''
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
'';
};
locations."=/config.js" = mkDefault {
alias =
overrideJs "${pkgs.jitsi-meet}/config.js" "config" (recursiveUpdate defaultCfg cfg.config)
cfg.extraConfig;
};
locations."=/interface_config.js" = mkDefault {
alias =
overrideJs "${pkgs.jitsi-meet}/interface_config.js" "interfaceConfig" cfg.interfaceConfig
"";
};
locations."/socket.io/" = mkIf cfg.excalidraw.enable {
proxyPass = "http://127.0.0.1:${toString cfg.excalidraw.port}";
proxyWebsockets = true;
};
};
};
services.caddy = mkIf cfg.caddy.enable {
enable = mkDefault true;
virtualHosts.${cfg.hostName} = {
extraConfig =
let
templatedJitsiMeet = pkgs.runCommand "templated-jitsi-meet" { } ''
cp -R --no-preserve=all ${pkgs.jitsi-meet}/* .
for file in *.html **/*.html ; do
${pkgs.sd}/bin/sd '<!--#include virtual="(.*)" -->' '{{ include "$1" }}' $file
done
rm config.js
rm interface_config.js
cp -R . $out
cp ${
overrideJs "${pkgs.jitsi-meet}/config.js" "config" (recursiveUpdate defaultCfg cfg.config)
cfg.extraConfig
} $out/config.js
cp ${
overrideJs "${pkgs.jitsi-meet}/interface_config.js" "interfaceConfig" cfg.interfaceConfig ""
} $out/interface_config.js
cp ./libs/external_api.min.js $out/external_api.js
'';
in
(optionalString cfg.excalidraw.enable ''
handle /socket.io/ {
reverse_proxy 127.0.0.1:${toString cfg.excalidraw.port}
}
'')
+ ''
handle /http-bind {
header Host ${cfg.hostName}
reverse_proxy 127.0.0.1:5280
}
handle /xmpp-websocket {
reverse_proxy 127.0.0.1:5280
}
handle {
templates
root * ${templatedJitsiMeet}
try_files {path} {path}
try_files {path} /index.html
file_server
}
'';
};
};
services.jitsi-meet.config =
recursiveUpdate
(mkIf cfg.excalidraw.enable {
whiteboard = {
enabled = true;
collabServerBaseUrl = "https://${cfg.hostName}";
};
})
(
mkIf cfg.secureDomain.enable {
hosts.anonymousdomain = "guest.${cfg.hostName}";
}
);
services.jitsi-videobridge = mkIf cfg.videobridge.enable {
enable = true;
xmppConfigs."localhost" = {
userName = "jvb";
domain = "auth.${cfg.hostName}";
passwordFile = "/var/lib/jitsi-meet/videobridge-secret";
mucJids = "jvbbrewery@internal.auth.${cfg.hostName}";
disableCertificateVerification = true;
};
};
services.jicofo = mkIf cfg.jicofo.enable {
enable = true;
xmppHost = "localhost";
xmppDomain = cfg.hostName;
userDomain = "auth.${cfg.hostName}";
userName = "focus";
userPasswordFile = "/var/lib/jitsi-meet/jicofo-user-secret";
componentPasswordFile = "/var/lib/jitsi-meet/jicofo-component-secret";
bridgeMuc = "jvbbrewery@internal.auth.${cfg.hostName}";
config = mkMerge [
{
jicofo.xmpp.service.disable-certificate-verification = true;
jicofo.xmpp.client.disable-certificate-verification = true;
}
(lib.mkIf (config.services.jibri.enable || cfg.jibri.enable) {
jicofo.jibri = {
brewery-jid = "JibriBrewery@internal.auth.${cfg.hostName}";
pending-timeout = "90";
};
})
(lib.mkIf cfg.secureDomain.enable {
jicofo = {
authentication = {
enabled = "true";
type = "XMPP";
login-url = cfg.hostName;
};
xmpp.client.client-proxy = "focus.${cfg.hostName}";
};
})
];
};
services.jibri = mkIf cfg.jibri.enable {
enable = true;
xmppEnvironments."jitsi-meet" = {
xmppServerHosts = [ "localhost" ];
xmppDomain = cfg.hostName;
control.muc = {
domain = "internal.auth.${cfg.hostName}";
roomName = "JibriBrewery";
nickname = "jibri";
};
control.login = {
domain = "auth.${cfg.hostName}";
username = "jibri";
passwordFile = "/var/lib/jitsi-meet/jibri-auth-secret";
};
call.login = {
domain = "recorder.${cfg.hostName}";
username = "recorder";
passwordFile = "/var/lib/jitsi-meet/jibri-recorder-secret";
};
usageTimeout = "0";
disableCertificateVerification = true;
stripFromRoomDomain = "conference.";
};
};
services.jigasi = mkIf cfg.jigasi.enable {
enable = true;
xmppHost = "localhost";
xmppDomain = cfg.hostName;
userDomain = "auth.${cfg.hostName}";
userName = "jigasi";
userPasswordFile = "/var/lib/jitsi-meet/jigasi-user-secret";
componentPasswordFile = "/var/lib/jitsi-meet/jigasi-component-secret";
bridgeMuc = "jigasibrewery@internal.${cfg.hostName}";
config = {
"org.jitsi.jigasi.ALWAYS_TRUST_MODE_ENABLED" = "true";
};
};
};
meta.doc = ./jitsi-meet.md;
meta.maintainers = lib.teams.jitsi.members;
}

View File

@@ -0,0 +1,174 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.kanboard;
toStringAttrs = lib.mapAttrs (lib.const toString);
in
{
meta.maintainers = with lib.maintainers; [ yzx9 ];
options.services.kanboard = {
enable = lib.mkEnableOption "Kanboard";
package = lib.mkPackageOption pkgs "kanboard" { };
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/kanboard";
description = "Default data folder for Kanboard.";
example = "/mnt/kanboard";
};
user = lib.mkOption {
type = lib.types.str;
default = "kanboard";
description = "User under which Kanboard runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "kanboard";
description = "Group under which Kanboard runs.";
};
settings = lib.mkOption {
type =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
default = { };
description = ''
Customize the default settings, refer to <https://github.com/kanboard/kanboard/blob/main/config.default.php>
for details on supported values.
'';
};
# Nginx
domain = lib.mkOption {
type = lib.types.str;
default = "kanboard";
description = "FQDN for the Kanboard instance.";
example = "kanboard.example.org";
};
nginx = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })
);
default = { };
description = ''
With this option, you can customize an NGINX virtual host which already
has sensible defaults for Kanboard. Set to `{ }` if you do not need any
customization for the virtual host. If enabled, then by default, the
{option}`serverName` is `''${domain}`. If this is set to null (the
default), no NGINX virtual host will be configured.
'';
example = lib.literalExpression ''
{
enableACME = true;
forceHttps = true;
}
'';
};
phpfpm.settings = lib.mkOption {
type =
with lib.types;
attrsOf (oneOf [
int
str
bool
]);
default = { };
description = ''
Options for kanboard's PHPFPM pool.
'';
};
};
config = lib.mkIf cfg.enable {
users = {
users = lib.mkIf (cfg.user == "kanboard") {
kanboard = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
};
};
groups = lib.mkIf (cfg.group == "kanboard") {
kanboard = { };
};
};
services.phpfpm.pools.kanboard = {
user = cfg.user;
group = cfg.group;
settings = lib.mkMerge [
{
"pm" = "dynamic";
"php_admin_value[error_log]" = "stderr";
"php_admin_flag[log_errors]" = true;
"listen.owner" = "nginx";
"catch_workers_output" = true;
"pm.max_children" = "32";
"pm.start_servers" = "2";
"pm.min_spare_servers" = "2";
"pm.max_spare_servers" = "4";
"pm.max_requests" = "500";
}
cfg.phpfpm.settings
];
phpEnv = lib.mkMerge [
{ DATA_DIR = cfg.dataDir; }
(toStringAttrs cfg.settings)
];
};
services.nginx = lib.mkIf (cfg.nginx != null) {
enable = lib.mkDefault true;
virtualHosts."${cfg.domain}" = lib.mkMerge [
{
root = lib.mkForce "${cfg.package}/share/kanboard";
locations."/".extraConfig = ''
rewrite ^ /index.php;
'';
locations."~ \\.php$".extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.kanboard.socket};
include ${config.services.nginx.package}/conf/fastcgi.conf;
include ${config.services.nginx.package}/conf/fastcgi_params;
'';
locations."~ \\.(js|css|ttf|woff2?|png|jpe?g|svg)$".extraConfig = ''
add_header Cache-Control "public, max-age=15778463";
add_header X-Content-Type-Options nosniff;
add_header X-Robots-Tag none;
add_header X-Download-Options noopen;
add_header X-Permitted-Cross-Domain-Policies none;
add_header Referrer-Policy no-referrer;
access_log off;
'';
extraConfig = ''
try_files $uri /index.php;
'';
}
cfg.nginx
];
};
};
}

View File

@@ -0,0 +1,226 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.karakeep;
karakeepEnv = lib.mkMerge [
{ DATA_DIR = "/var/lib/karakeep"; }
(lib.mkIf cfg.meilisearch.enable {
MEILI_ADDR = "http://127.0.0.1:${toString config.services.meilisearch.listenPort}";
})
(lib.mkIf cfg.browser.enable {
BROWSER_WEB_URL = "http://127.0.0.1:${toString cfg.browser.port}";
})
cfg.extraEnvironment
];
environmentFiles = [
"/var/lib/karakeep/settings.env"
]
++ (lib.optional (cfg.environmentFile != null) cfg.environmentFile);
in
{
options = {
services.karakeep = {
enable = lib.mkEnableOption "Enable the Karakeep service";
package = lib.mkPackageOption pkgs "karakeep" { };
extraEnvironment = lib.mkOption {
description = ''
Environment variables to pass to Karakaeep. This is how most settings
can be configured. Changing DATA_DIR is possible but not supported.
See <https://docs.karakeep.app/configuration/>
'';
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
PORT = "1234";
DISABLE_SIGNUPS = "true";
DISABLE_NEW_RELEASE_CHECK = "true";
}
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
An optional path to an environment file that will be used in the web and workers
services. This is useful for loading private keys.
'';
example = "/var/lib/karakeep/secrets.env";
};
browser = {
enable = lib.mkOption {
description = ''
Enable the karakeep-browser service that runs a chromium instance in
the background with debugging ports exposed. This is necessary for
certain features like screenshots.
'';
type = lib.types.bool;
default = true;
};
port = lib.mkOption {
description = "The port the browser should run on.";
type = lib.types.port;
default = 9222;
};
exe = lib.mkOption {
description = "The browser executable (must be Chrome-like).";
type = lib.types.str;
default = "${pkgs.chromium}/bin/chromium";
defaultText = lib.literalExpression "\${pkgs.chromium}/bin/chromium";
example = lib.literalExpression "\${pkgs.google-chrome}/bin/google-chrome-stable";
};
};
meilisearch = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable Meilisearch and configure Karakeep to use it. Meilisearch is
required for text search.
'';
};
};
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
users.groups.karakeep = { };
users.users.karakeep = {
isSystemUser = true;
group = "karakeep";
};
services.meilisearch = lib.mkIf cfg.meilisearch.enable {
enable = true;
};
systemd.services.karakeep-init = {
description = "Initialize Karakeep Data";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
partOf = [ "karakeep.service" ];
path = [ pkgs.openssl ];
script = ''
umask 0077
if [ ! -f "$STATE_DIRECTORY/settings.env" ]; then
cat <<EOF >"$STATE_DIRECTORY/settings.env"
# Generated by NixOS Karakeep module
MEILI_MASTER_KEY=$(openssl rand -base64 36)
NEXTAUTH_SECRET=$(openssl rand -base64 36)
EOF
fi
export DATA_DIR="$STATE_DIRECTORY"
exec "${cfg.package}/lib/karakeep/migrate"
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "karakeep";
Group = "karakeep";
StateDirectory = "karakeep";
PrivateTmp = "yes";
};
};
systemd.services.karakeep-workers = {
description = "Karakeep Workers";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"karakeep-init.service"
];
partOf = [ "karakeep.service" ];
path = [
pkgs.monolith
pkgs.yt-dlp
];
environment = karakeepEnv;
serviceConfig = {
User = "karakeep";
Group = "karakeep";
ExecStart = "${cfg.package}/lib/karakeep/start-workers";
StateDirectory = "karakeep";
EnvironmentFile = environmentFiles;
PrivateTmp = "yes";
};
};
systemd.services.karakeep-web = {
description = "Karakeep Web";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"karakeep-init.service"
"karakeep-workers.service"
];
partOf = [ "karakeep.service" ];
environment = karakeepEnv;
serviceConfig = {
ExecStart = "${cfg.package}/lib/karakeep/start-web";
User = "karakeep";
Group = "karakeep";
StateDirectory = "karakeep";
EnvironmentFile = environmentFiles;
PrivateTmp = "yes";
};
};
systemd.services.karakeep-browser = lib.mkIf cfg.browser.enable {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
partOf = [ "karakeep.service" ];
script = ''
export HOME="$CACHE_DIRECTORY"
exec ${cfg.browser.exe} \
--headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port=${toString cfg.browser.port} \
--hide-scrollbars \
--user-data-dir="$STATE_DIRECTORY"
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
CacheDirectory = "karakeep-browser";
StateDirectory = "karakeep-browser";
DevicePolicy = "closed";
DynamicUser = true;
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RestrictNamespaces = true;
RestrictRealtime = true;
};
};
};
meta = {
maintainers = [ lib.maintainers.three ];
};
}

View File

@@ -0,0 +1,335 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.kasmweb;
in
{
options.services.kasmweb = {
enable = lib.mkEnableOption "kasmweb";
networkSubnet = lib.mkOption {
default = "172.20.0.0/16";
type = lib.types.str;
description = ''
The network subnet to use for the containers.
'';
};
postgres = {
user = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
Username to use for the postgres database.
'';
};
password = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
password to use for the postgres database.
'';
};
};
redisPassword = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
password to use for the redis cache.
'';
};
defaultAdminPassword = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
default admin password to use.
'';
};
defaultUserPassword = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
default user password to use.
'';
};
defaultManagerToken = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
default manager token to use.
'';
};
defaultGuacToken = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
default guac token to use.
'';
};
defaultRegistrationToken = lib.mkOption {
default = "kasmweb";
type = lib.types.str;
description = ''
default registration token to use.
'';
};
datastorePath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/kasmweb";
description = ''
The directory used to store all data for kasmweb.
'';
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = ''
The address on which kasmweb should listen.
'';
};
listenPort = lib.mkOption {
type = lib.types.port;
default = 443;
description = ''
The port on which kasmweb should listen.
'';
};
sslCertificate = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The SSL certificate to be used for kasmweb.
'';
};
sslCertificateKey = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The SSL certificate's key to be used for kasmweb. Make sure to specify
this as a string and not a literal path, so that it is not accidentally
included in your nixstore.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services = {
"init-kasmweb" = {
wantedBy = [
"docker-kasm_db.service"
"podman-kasm_db.service"
];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
TimeoutStartSec = 300;
ExecStart = pkgs.replaceVarsWith {
src = ./initialize_kasmweb.sh;
isExecutable = true;
replacements = {
binPath = lib.makeBinPath [
pkgs.docker
pkgs.openssl
pkgs.gnused
pkgs.yq-go
];
runtimeShell = pkgs.runtimeShell;
kasmweb = pkgs.kasmweb;
postgresUser = "postgres";
postgresPassword = "postgres";
inherit (cfg)
datastorePath
sslCertificate
sslCertificateKey
redisPassword
networkSubnet
defaultUserPassword
defaultAdminPassword
defaultManagerToken
defaultRegistrationToken
defaultGuacToken
;
};
};
};
};
};
virtualisation = {
oci-containers.backend = "docker";
oci-containers.containers = {
kasm_db = {
image = "postgres:16-alpine";
autoStart = true;
environment = {
POSTGRES_PASSWORD = "postgres";
POSTGRES_USER = "postgres";
POSTGRES_DB = "kasm";
};
volumes = [
"${cfg.datastorePath}/conf/database/data.sql:/docker-entrypoint-initdb.d/data.sql"
"${cfg.datastorePath}/conf/database/:/tmp/"
"kasmweb_db:/var/lib/postgresql/data"
];
extraOptions = [ "--network=kasm_default_network" ];
};
kasm_db_init = {
image = "kasmweb/api:${pkgs.kasmweb.version}";
user = "root:root";
autoStart = true;
volumes = [
"${cfg.datastorePath}/:/opt/kasm/current/"
"kasmweb_api_data:/tmp"
];
dependsOn = [ "kasm_db" ];
entrypoint = "/bin/bash";
cmd = [ "/opt/kasm/current/init_seeds.sh" ];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
];
};
kasm_redis = {
image = "redis:5-alpine";
entrypoint = "/bin/sh";
autoStart = true;
cmd = [
"-c"
"redis-server --requirepass ${cfg.redisPassword}"
];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
];
};
kasm_api = {
image = "kasmweb/api:${pkgs.kasmweb.version}";
autoStart = false;
user = "root:root";
volumes = [
"${cfg.datastorePath}/:/opt/kasm/current/"
"kasmweb_api_data:/tmp"
];
dependsOn = [ "kasm_db_init" ];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
];
};
kasm_manager = {
image = "kasmweb/manager:${pkgs.kasmweb.version}";
autoStart = false;
user = "root:root";
volumes = [
"${cfg.datastorePath}/:/opt/kasm/current/"
];
dependsOn = [
"kasm_db_init"
"kasm_db"
"kasm_api"
];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
"--read-only"
];
};
kasm_agent = {
image = "kasmweb/agent:${pkgs.kasmweb.version}";
autoStart = false;
user = "root:root";
volumes = [
"${cfg.datastorePath}/:/opt/kasm/current/"
"/var/run/docker.sock:/var/run/docker.sock"
"${pkgs.docker}/bin/docker:/usr/bin/docker"
"${cfg.datastorePath}/conf/nginx:/etc/nginx/conf.d"
];
dependsOn = [ "kasm_manager" ];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
"--read-only"
];
};
kasm_share = {
image = "kasmweb/share:${pkgs.kasmweb.version}";
autoStart = false;
user = "root:root";
volumes = [
"${cfg.datastorePath}/:/opt/kasm/current/"
];
dependsOn = [
"kasm_db_init"
"kasm_db"
"kasm_redis"
];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
"--read-only"
];
};
kasm_guac = {
image = "kasmweb/kasm-guac:${pkgs.kasmweb.version}";
autoStart = false;
user = "root:root";
volumes = [
"${cfg.datastorePath}/:/opt/kasm/current/"
];
dependsOn = [
"kasm_db"
"kasm_redis"
];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
"--read-only"
];
};
kasm_proxy = {
image = "kasmweb/nginx:latest";
autoStart = false;
ports = [ "${cfg.listenAddress}:${toString cfg.listenPort}:443" ];
user = "root:root";
volumes = [
"${cfg.datastorePath}/conf/nginx:/etc/nginx/conf.d:ro"
"${cfg.datastorePath}/certs/kasm_nginx.key:/etc/ssl/private/kasm_nginx.key"
"${cfg.datastorePath}/certs/kasm_nginx.crt:/etc/ssl/certs/kasm_nginx.crt"
"${cfg.datastorePath}/www:/srv/www:ro"
"${cfg.datastorePath}/log/nginx:/var/log/external/nginx"
"${cfg.datastorePath}/log/logrotate:/var/log/external/logrotate"
];
dependsOn = [
"kasm_manager"
"kasm_api"
"kasm_agent"
"kasm_share"
"kasm_guac"
];
extraOptions = [
"--network=kasm_default_network"
"--userns=host"
"--network-alias=proxy"
];
};
};
};
};
}

View File

@@ -0,0 +1,125 @@
#! @runtimeShell@
export PATH=@binPath@:$PATH
mkdir -p @datastorePath@/log
chmod -R a+rw @datastorePath@
ln -sf @kasmweb@/bin @datastorePath@
rm -r @datastorePath@/conf
cp -r @kasmweb@/conf @datastorePath@
mkdir -p @datastorePath@/conf/nginx/containers.d
chmod -R a+rw @datastorePath@/conf
ln -sf @kasmweb@/www @datastorePath@
cat >@datastorePath@/init_seeds.sh <<EOF
#!/bin/bash
if [ ! -e /opt/kasm/current/.done_initing_data ]; then
while true; do
sleep 15;
/usr/bin/kasm_server.so --initialize-database --cfg \
/opt/kasm/current/conf/app/api.app.config.yaml \
--seed-file \
/opt/kasm/current/conf/database/seed_data/default_properties.yaml \
--populate-production \
&& break
done && /usr/bin/kasm_server.so --cfg \
/opt/kasm/current/conf/app/api.app.config.yaml \
--populate-production \
--seed-file \
/opt/kasm/current/conf/database/seed_data/default_agents.yaml \
&& /usr/bin/kasm_server.so --cfg \
/opt/kasm/current/conf/app/api.app.config.yaml \
--populate-production \
--seed-file \
/opt/kasm/current/conf/database/seed_data/default_images_amd64.yaml \
&& touch /opt/kasm/current/.done_initing_data
while true; do sleep 10 ; done
else
echo "skipping database init"
while true; do sleep 10 ; done
fi
EOF
docker network inspect kasm_default_network >/dev/null || docker network create kasm_default_network --subnet @networkSubnet@
if [ -e @datastorePath@/ids.env ]; then
source @datastorePath@/ids.env
else
API_SERVER_ID=$(cat /proc/sys/kernel/random/uuid)
MANAGER_ID=$(cat /proc/sys/kernel/random/uuid)
SHARE_ID=$(cat /proc/sys/kernel/random/uuid)
SERVER_ID=$(cat /proc/sys/kernel/random/uuid)
echo "export API_SERVER_ID=$API_SERVER_ID" > @datastorePath@/ids.env
echo "export MANAGER_ID=$MANAGER_ID" >> @datastorePath@/ids.env
echo "export SHARE_ID=$SHARE_ID" >> @datastorePath@/ids.env
echo "export SERVER_ID=$SERVER_ID" >> @datastorePath@/ids.env
mkdir -p @datastorePath@/certs
openssl req -x509 -nodes -days 1825 -newkey rsa:2048 -keyout @datastorePath@/certs/kasm_nginx.key -out @datastorePath@/certs/kasm_nginx.crt -subj "/C=US/ST=VA/L=None/O=None/OU=DoFu/CN=$(hostname)/emailAddress=none@none.none" 2> /dev/null
mkdir -p @datastorePath@/file_mappings
docker volume create kasmweb_db || true
rm @datastorePath@/.done_initing_data
fi
chmod +x @datastorePath@/init_seeds.sh
chmod a+w @datastorePath@/init_seeds.sh
if [ -e @sslCertificate@ ]; then
cp @sslCertificate@ @datastorePath@/certs/kasm_nginx.crt
cp @sslCertificateKey@ @datastorePath@/certs/kasm_nginx.key
fi
yq -i '.server.zone_name = "'default'"' @datastorePath@/conf/app/api.app.config.yaml
yq -i '(.zones.[0]) .zone_name = "'default'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
sed -i -e "s/username.*/username: @postgresUser@/g" \
-e "s/password.*/password: @postgresPassword@/g" \
-e "s/host.*db/host: kasm_db/g" \
-e "s/ssl: true/ssl: false/g" \
-e "s/redis_password.*/redis_password: @redisPassword@/g" \
-e "s/server_hostname.*/server_hostname: kasm_api/g" \
-e "s/server_id.*/server_id: $API_SERVER_ID/g" \
-e "s/manager_id.*/manager_id: $MANAGER_ID/g" \
-e "s/share_id.*/share_id: $SHARE_ID/g" \
@datastorePath@/conf/app/api.app.config.yaml
sed -i -e "s/ token:.*/ token: \"@defaultManagerToken@\"/g" \
-e "s/hostnames: \['proxy.*/hostnames: \['kasm_proxy'\]/g" \
-e "s/server_id.*/server_id: $SERVER_ID/g" \
@datastorePath@/conf/app/agent.app.config.yaml
# Generate a salt and hash for the desired passwords. Update the yaml
ADMIN_SALT=$(cat /proc/sys/kernel/random/uuid)
ADMIN_HASH=$(printf @defaultAdminPassword@${ADMIN_SALT} | sha256sum | cut -c-64)
USER_SALT=$(cat /proc/sys/kernel/random/uuid)
USER_HASH=$(printf @defaultUserPassword@${USER_SALT} | sha256sum | cut -c-64)
yq -i '(.users.[] | select(.username=="admin@kasm.local") | .salt) = "'${ADMIN_SALT}'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
yq -i '(.users.[] | select(.username=="admin@kasm.local") | .pw_hash) = "'${ADMIN_HASH}'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
yq -i '(.users.[] | select(.username=="user@kasm.local") | .salt) = "'${USER_SALT}'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
yq -i '(.users.[] | select(.username=="user@kasm.local") | .pw_hash) = "'${USER_HASH}'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
yq -i '(.settings.[] | select(.name=="token") | select(.category == "manager")) .value = "'@defaultManagerToken@'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
yq -i '(.settings.[] | select(.name=="registration_token") | select(.category == "auth")) .value = "'@defaultRegistrationToken@'"' @datastorePath@/conf/database/seed_data/default_properties.yaml
sed -i -e "s/upstream_auth_address:.*/upstream_auth_address: 'proxy'/g" \
@datastorePath@/conf/database/seed_data/default_properties.yaml
sed -i -e "s/GUACTOKEN/@defaultGuacToken@/g" \
-e "s/APIHOSTNAME/proxy/g" \
@datastorePath@/conf/app/kasmguac.app.config.yaml
sed -i "s/00000000-0000-0000-0000-000000000000/$SERVER_ID/g" \
@datastorePath@/conf/database/seed_data/default_agents.yaml
while [ ! -e @datastorePath@/.done_initing_data ]; do
sleep 10;
done
systemctl restart docker-kasm_proxy.service

View File

@@ -0,0 +1,120 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.kavita;
settingsFormat = pkgs.formats.json { };
appsettings = settingsFormat.generate "appsettings.json" (
{ TokenKey = "@TOKEN@"; } // cfg.settings
);
in
{
imports = [
(lib.mkChangedOptionModule
[ "services" "kavita" "ipAdresses" ]
[ "services" "kavita" "settings" "IpAddresses" ]
(
config:
let
value = lib.getAttrFromPath [ "services" "kavita" "ipAdresses" ] config;
in
lib.concatStringsSep "," value
)
)
(lib.mkRenamedOptionModule [ "services" "kavita" "port" ] [ "services" "kavita" "settings" "Port" ])
];
options.services.kavita = {
enable = lib.mkEnableOption "Kavita reading server";
user = lib.mkOption {
type = lib.types.str;
default = "kavita";
description = "User account under which Kavita runs.";
};
package = lib.mkPackageOption pkgs "kavita" { };
dataDir = lib.mkOption {
default = "/var/lib/kavita";
type = lib.types.str;
description = "The directory where Kavita stores its state.";
};
tokenKeyFile = lib.mkOption {
type = lib.types.path;
description = ''
A file containing the TokenKey, a secret with at 512+ bits.
It can be generated with `head -c 64 /dev/urandom | base64 --wrap=0`.
'';
};
settings = lib.mkOption {
default = { };
description = ''
Kavita configuration options, as configured in {file}`appsettings.json`.
'';
type = lib.types.submodule {
freeformType = settingsFormat.type;
options = {
Port = lib.mkOption {
default = 5000;
type = lib.types.port;
description = "Port to bind to.";
};
IpAddresses = lib.mkOption {
default = "0.0.0.0,::";
type = lib.types.commas;
description = ''
IP Addresses to bind to. The default is to bind to all IPv4 and IPv6 addresses.
'';
};
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.kavita = {
description = "Kavita";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
preStart = ''
install -m600 ${appsettings} ${lib.escapeShellArg cfg.dataDir}/config/appsettings.json
${pkgs.replace-secret}/bin/replace-secret '@TOKEN@' \
''${CREDENTIALS_DIRECTORY}/token \
'${cfg.dataDir}/config/appsettings.json'
'';
serviceConfig = {
WorkingDirectory = cfg.dataDir;
LoadCredential = [ "token:${cfg.tokenKeyFile}" ];
ExecStart = lib.getExe cfg.package;
Restart = "always";
User = cfg.user;
};
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0750 ${cfg.user} ${cfg.user} - -"
"d '${cfg.dataDir}/config' 0750 ${cfg.user} ${cfg.user} - -"
];
users = {
users.${cfg.user} = {
description = "kavita service user";
isSystemUser = true;
group = cfg.user;
home = cfg.dataDir;
};
groups.${cfg.user} = { };
};
};
meta.maintainers = with lib.maintainers; [ misterio77 ];
}

View File

@@ -0,0 +1,141 @@
# Keycloak {#module-services-keycloak}
[Keycloak](https://www.keycloak.org/) is an
open source identity and access management server with support for
[OpenID Connect](https://openid.net/connect/),
[OAUTH 2.0](https://oauth.net/2/) and
[SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0).
## Administration {#module-services-keycloak-admin}
An administrative user with the username
`admin` is automatically created in the
`master` realm. Its initial password can be
configured by setting [](#opt-services.keycloak.initialAdminPassword)
and defaults to `changeme`. The password is
not stored safely and should be changed immediately in the
admin panel.
Refer to the [Keycloak Server Administration Guide](
https://www.keycloak.org/docs/latest/server_admin/index.html
) for information on
how to administer your Keycloak
instance.
## Database access {#module-services-keycloak-database}
Keycloak can be used with either PostgreSQL, MariaDB or
MySQL. Which one is used can be
configured in [](#opt-services.keycloak.database.type). The selected
database will automatically be enabled and a database and role
created unless [](#opt-services.keycloak.database.host) is changed
from its default of `localhost` or
[](#opt-services.keycloak.database.createLocally) is set to `false`.
External database access can also be configured by setting
[](#opt-services.keycloak.database.host),
[](#opt-services.keycloak.database.name),
[](#opt-services.keycloak.database.username),
[](#opt-services.keycloak.database.useSSL) and
[](#opt-services.keycloak.database.caCert) as
appropriate. Note that you need to manually create the database
and allow the configured database user full access to it.
[](#opt-services.keycloak.database.passwordFile)
must be set to the path to a file containing the password used
to log in to the database. If [](#opt-services.keycloak.database.host)
and [](#opt-services.keycloak.database.createLocally)
are kept at their defaults, the database role
`keycloak` with that password is provisioned
on the local database instance.
::: {.warning}
The path should be provided as a string, not a Nix path, since Nix
paths are copied into the world readable Nix store.
:::
## Hostname {#module-services-keycloak-hostname}
The hostname is used to build the public URL used as base for
all frontend requests and must be configured through
[](#opt-services.keycloak.settings.hostname).
::: {.note}
If you're migrating an old Wildfly based Keycloak instance
and want to keep compatibility with your current clients,
you'll likely want to set [](#opt-services.keycloak.settings.http-relative-path)
to `/auth`. See the option description
for more details.
:::
[](#opt-services.keycloak.settings.hostname-backchannel-dynamic)
Keycloak has the capability to offer a separate URL for backchannel requests,
enabling internal communication while maintaining the use of a public URL
for frontchannel requests. Moreover, the backchannel is dynamically
resolved based on incoming headers endpoint.
For more information on hostname configuration, see the [Hostname
section of the Keycloak Server Installation and Configuration
Guide](https://www.keycloak.org/server/hostname).
## Setting up TLS/SSL {#module-services-keycloak-tls}
By default, Keycloak won't accept
unsecured HTTP connections originating from outside its local
network.
HTTPS support requires a TLS/SSL certificate and a private key,
both [PEM formatted](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail).
Their paths should be set through
[](#opt-services.keycloak.sslCertificate) and
[](#opt-services.keycloak.sslCertificateKey).
::: {.warning}
The paths should be provided as a strings, not a Nix paths,
since Nix paths are copied into the world readable Nix store.
:::
## Themes {#module-services-keycloak-themes}
You can package custom themes and make them visible to
Keycloak through [](#opt-services.keycloak.themes). See the
[Themes section of the Keycloak Server Development Guide](
https://www.keycloak.org/docs/latest/server_development/#_themes
) and the description of the aforementioned NixOS option for
more information.
## Configuration file settings {#module-services-keycloak-settings}
Keycloak server configuration parameters can be set in
[](#opt-services.keycloak.settings). These correspond
directly to options in
{file}`conf/keycloak.conf`. Some of the most
important parameters are documented as suboptions, the rest can
be found in the [All
configuration section of the Keycloak Server Installation and
Configuration Guide](https://www.keycloak.org/server/all-config).
Options containing secret data should be set to an attribute
set containing the attribute `_secret` - a
string pointing to a file containing the value the option
should be set to. See the description of
[](#opt-services.keycloak.settings) for an example.
## Example configuration {#module-services-keycloak-example-config}
A basic configuration with some custom settings could look like this:
```nix
{
services.keycloak = {
enable = true;
settings = {
hostname = "keycloak.example.com";
hostname-strict-backchannel = true;
};
initialAdminPassword = "e6Wcm0RrtegMEHl"; # change on first login
sslCertificate = "/run/keys/ssl_cert";
sslCertificateKey = "/run/keys/ssl_key";
database.passwordFile = "/run/keys/db_password";
};
}
```

View File

@@ -0,0 +1,782 @@
{
config,
options,
pkgs,
lib,
...
}:
let
cfg = config.services.keycloak;
opt = options.services.keycloak;
inherit (lib)
types
mkMerge
mkOption
mkChangedOptionModule
mkRenamedOptionModule
mkRemovedOptionModule
mkPackageOption
concatStringsSep
mapAttrsToList
escapeShellArg
mkIf
optionalString
optionals
mkDefault
literalExpression
isAttrs
literalMD
maintainers
catAttrs
collect
hasPrefix
;
inherit (builtins)
elem
typeOf
isInt
isString
hashString
isPath
;
prefixUnlessEmpty = prefix: string: optionalString (string != "") "${prefix}${string}";
in
{
imports = [
(mkRenamedOptionModule
[ "services" "keycloak" "bindAddress" ]
[ "services" "keycloak" "settings" "http-host" ]
)
(mkRenamedOptionModule
[ "services" "keycloak" "forceBackendUrlToFrontendUrl" ]
[ "services" "keycloak" "settings" "hostname-strict-backchannel" ]
)
(mkChangedOptionModule
[ "services" "keycloak" "httpPort" ]
[ "services" "keycloak" "settings" "http-port" ]
(config: builtins.fromJSON config.services.keycloak.httpPort)
)
(mkChangedOptionModule
[ "services" "keycloak" "httpsPort" ]
[ "services" "keycloak" "settings" "https-port" ]
(config: builtins.fromJSON config.services.keycloak.httpsPort)
)
(mkRemovedOptionModule [ "services" "keycloak" "frontendUrl" ] ''
Set `services.keycloak.settings.hostname' and `services.keycloak.settings.http-relative-path' instead.
NOTE: You likely want to set 'http-relative-path' to '/auth' to keep compatibility with your clients.
See its description for more information.
'')
(mkRemovedOptionModule [
"services"
"keycloak"
"extraConfig"
] "Use `services.keycloak.settings' instead.")
];
options.services.keycloak =
let
inherit (types)
bool
str
int
nullOr
attrsOf
oneOf
path
enum
package
port
listOf
;
assertStringPath =
optionName: value:
if isPath value then
throw ''
services.keycloak.${optionName}:
${toString value}
is a Nix path, but should be a string, since Nix
paths are copied into the world-readable Nix store.
''
else
value;
in
{
enable = mkOption {
type = bool;
default = false;
example = true;
description = ''
Whether to enable the Keycloak identity and access management
server.
'';
};
sslCertificate = mkOption {
type = nullOr path;
default = null;
example = "/run/keys/ssl_cert";
apply = assertStringPath "sslCertificate";
description = ''
The path to a PEM formatted certificate to use for TLS/SSL
connections.
'';
};
sslCertificateKey = mkOption {
type = nullOr path;
default = null;
example = "/run/keys/ssl_key";
apply = assertStringPath "sslCertificateKey";
description = ''
The path to a PEM formatted private key to use for TLS/SSL
connections.
'';
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
description = ''
Keycloak plugin jar, ear files or derivations containing
them. Packaged plugins are available through
`pkgs.keycloak.plugins`.
'';
};
database = {
type = mkOption {
type = enum [
"mysql"
"mariadb"
"postgresql"
];
default = "postgresql";
example = "mariadb";
description = ''
The type of database Keycloak should connect to.
'';
};
host = mkOption {
type = str;
default = "localhost";
description = ''
Hostname of the database to connect to.
'';
};
port =
let
dbPorts = {
postgresql = 5432;
mariadb = 3306;
mysql = 3306;
};
in
mkOption {
type = port;
default = dbPorts.${cfg.database.type};
defaultText = literalMD "default port of selected database";
description = ''
Port of the database to connect to.
'';
};
useSSL = mkOption {
type = bool;
default = cfg.database.host != "localhost";
defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
description = ''
Whether the database connection should be secured by SSL /
TLS.
'';
};
caCert = mkOption {
type = nullOr path;
default = null;
description = ''
The SSL / TLS CA certificate that verifies the identity of the
database server.
Required when PostgreSQL is used and SSL is turned on.
For MySQL, if left at `null`, the default
Java keystore is used, which should suffice if the server
certificate is issued by an official CA.
'';
};
createLocally = mkOption {
type = bool;
default = true;
description = ''
Whether a database should be automatically created on the
local host. Set this to false if you plan on provisioning a
local database yourself. This has no effect if
services.keycloak.database.host is customized.
'';
};
name = mkOption {
type = str;
default = "keycloak";
description = ''
Database name to use when connecting to an external or
manually provisioned database; has no effect when a local
database is automatically provisioned.
To use this with a local database, set [](#opt-services.keycloak.database.createLocally) to
`false` and create the database and user
manually.
'';
};
username = mkOption {
type = str;
default = "keycloak";
description = ''
Username to use when connecting to an external or manually
provisioned database; has no effect when a local database is
automatically provisioned.
To use this with a local database, set [](#opt-services.keycloak.database.createLocally) to
`false` and create the database and user
manually.
'';
};
passwordFile = mkOption {
type = path;
example = "/run/keys/db_password";
apply = assertStringPath "passwordFile";
description = ''
The path to a file containing the database password.
'';
};
};
package = mkPackageOption pkgs "keycloak" { };
initialAdminPassword = mkOption {
type = nullOr str;
default = null;
description = ''
Initial password set for the temporary `admin` user.
The password is not stored safely and should be changed
immediately in the admin panel.
See [Admin bootstrap and recovery](https://www.keycloak.org/server/bootstrap-admin-recovery) for details.
'';
};
themes = mkOption {
type = attrsOf package;
default = { };
description = ''
Additional theme packages for Keycloak. Each theme is linked into
subdirectory with a corresponding attribute name.
Theme packages consist of several subdirectories which provide
different theme types: for example, `account`,
`login` etc. After adding a theme to this option you
can select it by its name in Keycloak administration console.
'';
};
realmFiles = mkOption {
type = listOf path;
example = lib.literalExpression ''
[
./some/realm.json
./another/realm.json
]
'';
default = [ ];
description = ''
Realm files that the server is going to import during startup.
If a realm already exists in the server, the import operation is
skipped. Importing the master realm is not supported. All files are
expected to be in `json` format. See the
[documentation](https://www.keycloak.org/server/importExport) for
further information.
'';
};
settings = mkOption {
type = lib.types.submodule {
freeformType = attrsOf (
nullOr (oneOf [
str
int
bool
(attrsOf path)
])
);
options = {
http-host = mkOption {
type = str;
default = "::";
example = "::1";
description = ''
On which address Keycloak should accept new connections.
'';
};
http-port = mkOption {
type = port;
default = 80;
example = 8080;
description = ''
On which port Keycloak should listen for new HTTP connections.
'';
};
https-port = mkOption {
type = port;
default = 443;
example = 8443;
description = ''
On which port Keycloak should listen for new HTTPS connections.
'';
};
http-relative-path = mkOption {
type = str;
default = "/";
example = "/auth";
apply = x: if !(hasPrefix "/") x then "/" + x else x;
description = ''
The path relative to `/` for serving
resources.
::: {.note}
In versions of Keycloak using Wildfly (&lt;17),
this defaulted to `/auth`. If
upgrading from the Wildfly version of Keycloak,
i.e. a NixOS version before 22.05, you'll likely
want to set this to `/auth` to
keep compatibility with your clients.
See <https://www.keycloak.org/migration/migrating-to-quarkus>
for more information on migrating from Wildfly to Quarkus.
:::
'';
};
hostname = mkOption {
type = nullOr str;
example = "keycloak.example.com";
description = ''
The hostname part of the public URL used as base for
all frontend requests.
See <https://www.keycloak.org/server/hostname>
for more information about hostname configuration.
'';
};
hostname-backchannel-dynamic = mkOption {
type = bool;
default = false;
example = true;
description = ''
Enables dynamic resolving of backchannel URLs,
including hostname, scheme, port and context path.
See <https://www.keycloak.org/server/hostname>
for more information about hostname configuration.
'';
};
};
};
example = literalExpression ''
{
hostname = "keycloak.example.com";
https-key-store-file = "/path/to/file";
https-key-store-password = { _secret = "/run/keys/store_password"; };
}
'';
description = ''
Configuration options corresponding to parameters set in
{file}`conf/keycloak.conf`.
Most available options are documented at <https://www.keycloak.org/server/all-config>.
Options containing secret data should be set to an attribute
set containing the attribute `_secret` - a
string pointing to a file containing the value the option
should be set to. See the example to get a better picture of
this: in the resulting
{file}`conf/keycloak.conf` file, the
`https-key-store-password` key will be set
to the contents of the
{file}`/run/keys/store_password` file.
'';
};
};
config =
let
# We only want to create a database if we're actually going to
# connect to it.
databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
createLocalMySQL =
databaseActuallyCreateLocally
&& elem cfg.database.type [
"mysql"
"mariadb"
];
mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
'';
# Both theme and theme type directories need to be actual
# directories in one hierarchy to pass Keycloak checks.
themesBundle = pkgs.runCommand "keycloak-themes" { } ''
linkTheme() {
theme="$1"
name="$2"
mkdir "$out/$name"
for typeDir in "$theme"/*; do
if [ -d "$typeDir" ]; then
type="$(basename "$typeDir")"
mkdir "$out/$name/$type"
for file in "$typeDir"/*; do
ln -sn "$file" "$out/$name/$type/$(basename "$file")"
done
fi
done
}
mkdir -p "$out"
for theme in ${keycloakBuild}/themes/*; do
if [ -d "$theme" ]; then
linkTheme "$theme" "$(basename "$theme")"
fi
done
${concatStringsSep "\n" (
mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes
)}
'';
keycloakConfig = lib.generators.toKeyValue {
mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
mkValueString =
v:
if isInt v then
toString v
else if isString v then
v
else if true == v then
"true"
else if false == v then
"false"
else if isSecret v then
hashString "sha256" v._secret
else
throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}";
};
};
isSecret = v: isAttrs v && v ? _secret && isString v._secret;
filteredConfig = lib.converge (lib.filterAttrsRecursive (
_: v:
!elem v [
{ }
null
]
)) cfg.settings;
confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
keycloakBuild = cfg.package.override {
inherit confFile;
plugins =
cfg.package.enabledPlugins
++ cfg.plugins
++ (with cfg.package.plugins; [
quarkus-systemd-notify
quarkus-systemd-notify-deployment
]);
};
in
mkIf cfg.enable {
assertions = [
{
assertion =
(cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
}
{
assertion =
createLocalPostgreSQL -> config.services.postgresql.settings.standard_conforming_strings or true;
message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
}
{
assertion = cfg.settings.hostname != null || !cfg.settings.hostname-strict or true;
message = "Setting the Keycloak hostname is required, see `services.keycloak.settings.hostname`";
}
{
assertion = cfg.settings.hostname-url or null == null;
message = ''
The option `services.keycloak.settings.hostname-url' has been removed.
Set `services.keycloak.settings.hostname' instead.
See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
'';
}
{
assertion = cfg.settings.hostname-strict-backchannel or null == null;
message = ''
The option `services.keycloak.settings.hostname-strict-backchannel' has been removed.
Set `services.keycloak.settings.hostname-backchannel-dynamic' instead.
See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
'';
}
{
assertion = cfg.settings.proxy or null == null;
message = ''
The option `services.keycloak.settings.proxy' has been removed.
Set `services.keycloak.settings.proxy-headers` in combination
with other hostname options as needed instead.
See [Proxy option removed](https://www.keycloak.org/docs/latest/upgrading/index.html#proxy-option-removed)
for more information.
'';
}
];
environment.systemPackages = [ keycloakBuild ];
services.keycloak.settings =
let
postgresParams = concatStringsSep "&" (
optionals cfg.database.useSSL [
"ssl=true"
]
++ optionals (cfg.database.caCert != null) [
"sslrootcert=${cfg.database.caCert}"
"sslmode=verify-ca"
]
);
mariadbParams = concatStringsSep "&" (
[
"characterEncoding=UTF-8"
]
++ optionals cfg.database.useSSL [
"useSSL=true"
"requireSSL=true"
"verifyServerCertificate=true"
]
++ optionals (cfg.database.caCert != null) [
"trustCertificateKeyStoreUrl=file:${mySqlCaKeystore}"
"trustCertificateKeyStorePassword=notsosecretpassword"
]
);
dbProps = if cfg.database.type == "postgresql" then postgresParams else mariadbParams;
in
mkMerge [
{
db = if cfg.database.type == "postgresql" then "postgres" else cfg.database.type;
db-username = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
db-password._secret = cfg.database.passwordFile;
db-url-host = cfg.database.host;
db-url-port = toString cfg.database.port;
db-url-database = if databaseActuallyCreateLocally then "keycloak" else cfg.database.name;
db-url-properties = prefixUnlessEmpty "?" dbProps;
db-url = null;
}
(mkIf (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
https-certificate-file = "/run/keycloak/ssl/ssl_cert";
https-certificate-key-file = "/run/keycloak/ssl/ssl_key";
})
];
systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
after = [ "postgresql.target" ];
before = [ "keycloak.service" ];
bindsTo = [ "postgresql.target" ];
path = [ config.services.postgresql.package ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
Group = "postgres";
LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
};
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
create_role="$(mktemp)"
trap 'rm -f "$create_role"' EXIT
# Read the password from the credentials directory and
# escape any single quotes by adding additional single
# quotes after them, following the rules laid out here:
# https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
db_password="''${db_password//\'/\'\'}"
echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role"
psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
'';
enableStrictShellChecks = true;
};
systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
after = [ "mysql.service" ];
before = [ "keycloak.service" ];
bindsTo = [ "mysql.service" ];
path = [ config.services.mysql.package ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.mysql.user;
Group = config.services.mysql.group;
LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
};
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
# Read the password from the credentials directory and
# escape any single quotes by adding additional single
# quotes after them, following the rules laid out here:
# https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
db_password="''${db_password//\'/\'\'}"
( echo "SET sql_mode = 'NO_BACKSLASH_ESCAPES';"
echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
) | mysql -N
'';
enableStrictShellChecks = true;
};
systemd.tmpfiles.settings."10-keycloak" =
let
mkTarget =
file:
let
baseName = builtins.baseNameOf file;
name = if lib.hasSuffix ".json" baseName then baseName else "${baseName}.json";
in
"/run/keycloak/data/import/${name}";
settingsList = map (f: {
name = mkTarget f;
value = {
"L+".argument = "${f}";
};
}) cfg.realmFiles;
in
builtins.listToAttrs settingsList;
systemd.services.keycloak =
let
databaseServices =
if createLocalPostgreSQL then
[
"keycloakPostgreSQLInit.service"
"postgresql.target"
]
else if createLocalMySQL then
[
"keycloakMySQLInit.service"
"mysql.service"
]
else
[ ];
secretPaths = catAttrs "_secret" (collect isSecret cfg.settings);
mkSecretReplacement = file: ''
replace-secret ${hashString "sha256" file} "$CREDENTIALS_DIRECTORY/${baseNameOf file}" /run/keycloak/conf/keycloak.conf
'';
secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
in
{
after = databaseServices;
bindsTo = databaseServices;
wantedBy = [ "multi-user.target" ];
path = with pkgs; [
keycloakBuild
openssl
replace-secret
];
environment = {
KC_HOME_DIR = "/run/keycloak";
KC_CONF_DIR = "/run/keycloak/conf";
}
// lib.optionalAttrs (cfg.initialAdminPassword != null) {
KC_BOOTSTRAP_ADMIN_USERNAME = "admin";
KC_BOOTSTRAP_ADMIN_PASSWORD = cfg.initialAdminPassword;
};
serviceConfig = {
LoadCredential =
map (p: "${baseNameOf p}:${p}") secretPaths
++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
"ssl_cert:${cfg.sslCertificate}"
"ssl_key:${cfg.sslCertificateKey}"
];
User = "keycloak";
Group = "keycloak";
DynamicUser = true;
RuntimeDirectory = "keycloak";
RuntimeDirectoryMode = "0700";
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
Type = "notify"; # Requires quarkus-systemd-notify plugin
NotifyAccess = "all";
};
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
umask u=rwx,g=,o=
ln -s ${themesBundle} /run/keycloak/themes
ln -s ${keycloakBuild}/providers /run/keycloak/
ln -s ${keycloakBuild}/lib /run/keycloak/
install -D -m 0600 ${confFile} /run/keycloak/conf/keycloak.conf
${secretReplacements}
# Escape any backslashes in the db parameters, since
# they're otherwise unexpectedly read as escape
# sequences.
sed -i '/db-/ s|\\|\\\\|g' /run/keycloak/conf/keycloak.conf
''
+ optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
mkdir -p /run/keycloak/ssl
cp "$CREDENTIALS_DIRECTORY"/ssl_{cert,key} /run/keycloak/ssl/
''
+ ''
kc.sh --verbose start --optimized ${lib.optionalString (cfg.realmFiles != [ ]) "--import-realm"}
'';
enableStrictShellChecks = true;
};
services.postgresql.enable = mkDefault createLocalPostgreSQL;
services.mysql.enable = mkDefault createLocalMySQL;
services.mysql.package =
let
dbPkg = if cfg.database.type == "mariadb" then pkgs.mariadb else pkgs.mysql80;
in
mkIf createLocalMySQL (mkDefault dbPkg);
};
meta.doc = ./keycloak.md;
meta.maintainers = [ maintainers.talyz ];
}

Some files were not shown because too many files have changed in this diff Show More