diff options
Diffstat (limited to 'nixpkgs/nixos/modules/services/web-apps')
98 files changed, 27641 insertions, 0 deletions
diff --git a/nixpkgs/nixos/modules/services/web-apps/akkoma.md b/nixpkgs/nixos/modules/services/web-apps/akkoma.md new file mode 100644 index 000000000000..83dd1a8b35f2 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/akkoma.md @@ -0,0 +1,332 @@ +# 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 frontend’s 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 copy‐edited 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 re‐using 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 +``` + +### Re‐using data {#modules-services-akkoma-migration-pleroma-reuse} + +To re‐use 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). diff --git a/nixpkgs/nixos/modules/services/web-apps/akkoma.nix b/nixpkgs/nixos/modules/services/web-apps/akkoma.nix new file mode 100644 index 000000000000..8d1775258612 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/akkoma.nix @@ -0,0 +1,1086 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.akkoma; + ex = cfg.config; + db = ex.":pleroma"."Pleroma.Repo"; + web = ex.":pleroma"."Pleroma.Web.Endpoint"; + + isConfined = config.systemd.services.akkoma.confinement.enable; + hasSmtp = (attrByPath [ ":pleroma" "Pleroma.Emails.Mailer" "adapter" "value" ] null ex) == "Swoosh.Adapters.SMTP"; + + isAbsolutePath = v: isString v && substring 0 1 v == "/"; + isSecret = v: isAttrs v && v ? _secret && isAbsolutePath v._secret; + + absolutePath = with types; mkOptionType { + name = "absolutePath"; + description = "absolute path"; + descriptionClass = "noun"; + check = isAbsolutePath; + inherit (str) merge; + }; + + secret = mkOptionType { + name = "secret"; + description = "secret value"; + descriptionClass = "noun"; + check = isSecret; + nestedTypes = { + _secret = absolutePath; + }; + }; + + ipAddress = with types; mkOptionType { + name = "ipAddress"; + description = "IPv4 or IPv6 address"; + descriptionClass = "conjunction"; + check = x: str.check x && builtins.match "[.0-9:A-Fa-f]+" x != null; + inherit (str) merge; + }; + + elixirValue = let + elixirValue' = with types; + nullOr (oneOf [ bool int float str (attrsOf elixirValue') (listOf elixirValue') ]) // { + description = "Elixir value"; + }; + in elixirValue'; + + frontend = { + options = { + package = mkOption { + type = types.package; + description = mdDoc "Akkoma frontend package."; + example = literalExpression "pkgs.akkoma-frontends.akkoma-fe"; + }; + + name = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Akkoma frontend name."; + example = "akkoma-fe"; + }; + + ref = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Akkoma frontend reference."; + example = "stable"; + }; + }; + }; + + sha256 = builtins.hashString "sha256"; + + replaceSec = let + replaceSec' = { }@args: v: + if isAttrs v + then if v ? _secret + then if isAbsolutePath v._secret + then sha256 v._secret + else abort "Invalid secret path (_secret = ${v._secret})" + else mapAttrs (_: val: replaceSec' args val) v + else if isList v + then map (replaceSec' args) v + else v; + in replaceSec' { }; + + # Erlang/Elixir uses a somewhat special format for IP addresses + erlAddr = addr: fileContents + (pkgs.runCommand addr { + nativeBuildInputs = with pkgs; [ elixir ]; + code = '' + case :inet.parse_address('${addr}') do + {:ok, addr} -> IO.inspect addr + {:error, _} -> System.halt(65) + end + ''; + passAsFile = [ "code" ]; + } ''elixir "$codePath" >"$out"''); + + format = pkgs.formats.elixirConf { }; + configFile = format.generate "config.exs" + (replaceSec + (attrsets.updateManyAttrsByPath [{ + path = [ ":pleroma" "Pleroma.Web.Endpoint" "http" "ip" ]; + update = addr: + if isAbsolutePath addr + then format.lib.mkTuple + [ (format.lib.mkAtom ":local") addr ] + else format.lib.mkRaw (erlAddr addr); + }] cfg.config)); + + writeShell = { name, text, runtimeInputs ? [ ] }: + pkgs.writeShellApplication { inherit name text runtimeInputs; } + "/bin/${name}"; + + genScript = writeShell { + name = "akkoma-gen-cookie"; + runtimeInputs = with pkgs; [ coreutils util-linux ]; + text = '' + install -m 0400 \ + -o ${escapeShellArg cfg.user } \ + -g ${escapeShellArg cfg.group} \ + <(hexdump -n 16 -e '"%02x"' /dev/urandom) \ + "$RUNTIME_DIRECTORY/cookie" + ''; + }; + + copyScript = writeShell { + name = "akkoma-copy-cookie"; + runtimeInputs = with pkgs; [ coreutils ]; + text = '' + install -m 0400 \ + -o ${escapeShellArg cfg.user} \ + -g ${escapeShellArg cfg.group} \ + ${escapeShellArg cfg.dist.cookie._secret} \ + "$RUNTIME_DIRECTORY/cookie" + ''; + }; + + secretPaths = catAttrs "_secret" (collect isSecret cfg.config); + + vapidKeygen = pkgs.writeText "vapidKeygen.exs" '' + [public_path, private_path] = System.argv() + {public_key, private_key} = :crypto.generate_key :ecdh, :prime256v1 + File.write! public_path, Base.url_encode64(public_key, padding: false) + File.write! private_path, Base.url_encode64(private_key, padding: false) + ''; + + initSecretsScript = writeShell { + name = "akkoma-init-secrets"; + runtimeInputs = with pkgs; [ coreutils elixir ]; + text = let + key-base = web.secret_key_base; + jwt-signer = ex.":joken".":default_signer"; + signing-salt = web.signing_salt; + liveview-salt = web.live_view.signing_salt; + vapid-private = ex.":web_push_encryption".":vapid_details".private_key; + vapid-public = ex.":web_push_encryption".":vapid_details".public_key; + in '' + secret() { + # Generate default secret if non‐existent + test -e "$2" || install -D -m 0600 <(tr -dc 'A-Za-z-._~' </dev/urandom | head -c "$1") "$2" + if [ "$(stat --dereference --format='%s' "$2")" -lt "$1" ]; then + echo "Secret '$2' is smaller than minimum size of $1 bytes." >&2 + exit 65 + fi + } + + secret 64 ${escapeShellArg key-base._secret} + secret 64 ${escapeShellArg jwt-signer._secret} + secret 8 ${escapeShellArg signing-salt._secret} + secret 8 ${escapeShellArg liveview-salt._secret} + + ${optionalString (isSecret vapid-public) '' + { test -e ${escapeShellArg vapid-private._secret} && \ + test -e ${escapeShellArg vapid-public._secret}; } || \ + elixir ${escapeShellArgs [ vapidKeygen vapid-public._secret vapid-private._secret ]} + ''} + ''; + }; + + configScript = writeShell { + name = "akkoma-config"; + runtimeInputs = with pkgs; [ coreutils replace-secret ]; + text = '' + cd "$RUNTIME_DIRECTORY" + tmp="$(mktemp config.exs.XXXXXXXXXX)" + trap 'rm -f "$tmp"' EXIT TERM + + cat ${escapeShellArg configFile} >"$tmp" + ${concatMapStrings (file: '' + replace-secret ${escapeShellArgs [ (sha256 file) file ]} "$tmp" + '') secretPaths} + + chown ${escapeShellArg cfg.user}:${escapeShellArg cfg.group} "$tmp" + chmod 0400 "$tmp" + mv -f "$tmp" config.exs + ''; + }; + + pgpass = let + esc = escape [ ":" ''\'' ]; + in if (cfg.initDb.password != null) + then pkgs.writeText "pgpass.conf" '' + *:*:*${esc cfg.initDb.username}:${esc (sha256 cfg.initDb.password._secret)} + '' + else null; + + escapeSqlId = x: ''"${replaceStrings [ ''"'' ] [ ''""'' ] x}"''; + escapeSqlStr = x: "'${replaceStrings [ "'" ] [ "''" ] x}'"; + + setupSql = pkgs.writeText "setup.psql" '' + \set ON_ERROR_STOP on + + ALTER ROLE ${escapeSqlId db.username} + LOGIN PASSWORD ${if db ? password + then "${escapeSqlStr (sha256 db.password._secret)}" + else "NULL"}; + + ALTER DATABASE ${escapeSqlId db.database} + OWNER TO ${escapeSqlId db.username}; + + \connect ${escapeSqlId db.database} + CREATE EXTENSION IF NOT EXISTS citext; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + ''; + + dbHost = if db ? socket_dir then db.socket_dir + else if db ? socket then db.socket + else if db ? hostname then db.hostname + else null; + + initDbScript = writeShell { + name = "akkoma-initdb"; + runtimeInputs = with pkgs; [ coreutils replace-secret config.services.postgresql.package ]; + text = '' + pgpass="$(mktemp -t pgpass-XXXXXXXXXX.conf)" + setupSql="$(mktemp -t setup-XXXXXXXXXX.psql)" + trap 'rm -f "$pgpass $setupSql"' EXIT TERM + + ${optionalString (dbHost != null) '' + export PGHOST=${escapeShellArg dbHost} + ''} + export PGUSER=${escapeShellArg cfg.initDb.username} + ${optionalString (pgpass != null) '' + cat ${escapeShellArg pgpass} >"$pgpass" + replace-secret ${escapeShellArgs [ + (sha256 cfg.initDb.password._secret) cfg.initDb.password._secret ]} "$pgpass" + export PGPASSFILE="$pgpass" + ''} + + cat ${escapeShellArg setupSql} >"$setupSql" + ${optionalString (db ? password) '' + replace-secret ${escapeShellArgs [ + (sha256 db.password._secret) db.password._secret ]} "$setupSql" + ''} + + # Create role if non‐existent + psql -tAc "SELECT 1 FROM pg_roles + WHERE rolname = "${escapeShellArg (escapeSqlStr db.username)} | grep -F -q 1 || \ + psql -tAc "CREATE ROLE "${escapeShellArg (escapeSqlId db.username)} + + # Create database if non‐existent + psql -tAc "SELECT 1 FROM pg_database + WHERE datname = "${escapeShellArg (escapeSqlStr db.database)} | grep -F -q 1 || \ + psql -tAc "CREATE DATABASE "${escapeShellArg (escapeSqlId db.database)}" + OWNER "${escapeShellArg (escapeSqlId db.username)}" + TEMPLATE template0 + ENCODING 'utf8' + LOCALE 'C'" + + psql -f "$setupSql" + ''; + }; + + envWrapper = let + script = writeShell { + name = "akkoma-env"; + text = '' + cd "${cfg.package}" + + RUNTIME_DIRECTORY="''${RUNTIME_DIRECTORY:-/run/akkoma}" + AKKOMA_CONFIG_PATH="$RUNTIME_DIRECTORY/config.exs" \ + ERL_EPMD_ADDRESS="${cfg.dist.address}" \ + ERL_EPMD_PORT="${toString cfg.dist.epmdPort}" \ + ERL_FLAGS="${concatStringsSep " " [ + "-kernel inet_dist_use_interface '${erlAddr cfg.dist.address}'" + "-kernel inet_dist_listen_min ${toString cfg.dist.portMin}" + "-kernel inet_dist_listen_max ${toString cfg.dist.portMax}" + ]}" \ + RELEASE_COOKIE="$(<"$RUNTIME_DIRECTORY/cookie")" \ + RELEASE_NAME="akkoma" \ + exec "${cfg.package}/bin/$(basename "$0")" "$@" + ''; + }; + in pkgs.runCommandLocal "akkoma-env" { } '' + mkdir -p "$out/bin" + + ln -r -s ${escapeShellArg script} "$out/bin/pleroma" + ln -r -s ${escapeShellArg script} "$out/bin/pleroma_ctl" + ''; + + userWrapper = pkgs.writeShellApplication { + name = "pleroma_ctl"; + text = '' + if [ "''${1-}" == "update" ]; then + echo "OTP releases are not supported on NixOS." >&2 + exit 64 + fi + + exec sudo -u ${escapeShellArg cfg.user} \ + "${envWrapper}/bin/pleroma_ctl" "$@" + ''; + }; + + socketScript = if isAbsolutePath web.http.ip + then writeShell { + name = "akkoma-socket"; + runtimeInputs = with pkgs; [ coreutils inotify-tools ]; + text = '' + coproc { + inotifywait -q -m -e create ${escapeShellArg (dirOf web.http.ip)} + } + + trap 'kill "$COPROC_PID"' EXIT TERM + + until test -S ${escapeShellArg web.http.ip} + do read -r -u "''${COPROC[0]}" + done + + chmod 0666 ${escapeShellArg web.http.ip} + ''; + } + else null; + + staticDir = ex.":pleroma".":instance".static_dir; + uploadDir = ex.":pleroma".":instance".upload_dir; + + staticFiles = pkgs.runCommandLocal "akkoma-static" { } '' + ${concatStringsSep "\n" (mapAttrsToList (key: val: '' + mkdir -p $out/frontends/${escapeShellArg val.name}/ + ln -s ${escapeShellArg val.package} $out/frontends/${escapeShellArg val.name}/${escapeShellArg val.ref} + '') cfg.frontends)} + + ${optionalString (cfg.extraStatic != null) + (concatStringsSep "\n" (mapAttrsToList (key: val: '' + mkdir -p "$out/$(dirname ${escapeShellArg key})" + ln -s ${escapeShellArg val} $out/${escapeShellArg key} + '') cfg.extraStatic))} + ''; +in { + options = { + services.akkoma = { + enable = mkEnableOption (mdDoc "Akkoma"); + + package = mkOption { + type = types.package; + default = pkgs.akkoma; + defaultText = literalExpression "pkgs.akkoma"; + description = mdDoc "Akkoma package to use."; + }; + + user = mkOption { + type = types.nonEmptyStr; + default = "akkoma"; + description = mdDoc "User account under which Akkoma runs."; + }; + + group = mkOption { + type = types.nonEmptyStr; + default = "akkoma"; + description = mdDoc "Group account under which Akkoma runs."; + }; + + initDb = { + enable = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Whether to automatically initialise the database on startup. This will create a + database role and database if they do not already exist, and (re)set the role password + and the ownership of the database. + + This setting can be used safely even if the database already exists and contains data. + + The database settings are configured through + [{option}`config.services.akkoma.config.":pleroma"."Pleroma.Repo"`](#opt-services.akkoma.config.__pleroma_._Pleroma.Repo_). + + If disabled, the database has to be set up manually: + + ```SQL + CREATE ROLE akkoma LOGIN; + + CREATE DATABASE akkoma + OWNER akkoma + TEMPLATE template0 + ENCODING 'utf8' + LOCALE 'C'; + + \connect akkoma + CREATE EXTENSION IF NOT EXISTS citext; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + ``` + ''; + }; + + username = mkOption { + type = types.nonEmptyStr; + default = config.services.postgresql.superUser; + defaultText = literalExpression "config.services.postgresql.superUser"; + description = mdDoc '' + Name of the database user to initialise the database with. + + This user is required to have the `CREATEROLE` and `CREATEDB` capabilities. + ''; + }; + + password = mkOption { + type = types.nullOr secret; + default = null; + description = mdDoc '' + Password of the database user to initialise the database with. + + If set to `null`, no password will be used. + + The attribute `_secret` should point to a file containing the secret. + ''; + }; + }; + + initSecrets = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Whether to initialise non‐existent secrets with random values. + + If enabled, appropriate secrets for the following options will be created automatically + if the files referenced in the `_secrets` attribute do not exist during startup. + + - {option}`config.":pleroma"."Pleroma.Web.Endpoint".secret_key_base` + - {option}`config.":pleroma"."Pleroma.Web.Endpoint".signing_salt` + - {option}`config.":pleroma"."Pleroma.Web.Endpoint".live_view.signing_salt` + - {option}`config.":web_push_encryption".":vapid_details".private_key` + - {option}`config.":web_push_encryption".":vapid_details".public_key` + - {option}`config.":joken".":default_signer"` + ''; + }; + + installWrapper = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Whether to install a wrapper around `pleroma_ctl` to simplify administration of the + Akkoma instance. + ''; + }; + + extraPackages = mkOption { + type = with types; listOf package; + default = with pkgs; [ exiftool ffmpeg_5-headless graphicsmagick-imagemagick-compat ]; + defaultText = literalExpression "with pkgs; [ exiftool graphicsmagick-imagemagick-compat ffmpeg_5-headless ]"; + example = literalExpression "with pkgs; [ exiftool imagemagick ffmpeg_5-full ]"; + description = mdDoc '' + List of extra packages to include in the executable search path of the service unit. + These are needed by various configurable components such as: + + - ExifTool for the `Pleroma.Upload.Filter.Exiftool` upload filter, + - ImageMagick for still image previews in the media proxy as well as for the + `Pleroma.Upload.Filters.Mogrify` upload filter, and + - ffmpeg for video previews in the media proxy. + ''; + }; + + frontends = mkOption { + description = mdDoc "Akkoma frontends."; + type = with types; attrsOf (submodule frontend); + default = { + primary = { + package = pkgs.akkoma-frontends.akkoma-fe; + name = "akkoma-fe"; + ref = "stable"; + }; + admin = { + package = pkgs.akkoma-frontends.admin-fe; + name = "admin-fe"; + ref = "stable"; + }; + }; + defaultText = literalExpression '' + { + primary = { + package = pkgs.akkoma-frontends.akkoma-fe; + name = "akkoma-fe"; + ref = "stable"; + }; + admin = { + package = pkgs.akkoma-frontends.admin-fe; + name = "admin-fe"; + ref = "stable"; + }; + } + ''; + }; + + extraStatic = mkOption { + type = with types; nullOr (attrsOf package); + description = mdDoc '' + Attribute set of extra packages to add to the static files directory. + + Do not add frontends here. These should be configured through + [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends). + ''; + default = null; + example = literalExpression '' + { + "emoji/blobs.gg" = pkgs.akkoma-emoji.blobs_gg; + "static/terms-of-service.html" = pkgs.writeText "terms-of-service.html" ''' + … + '''; + "favicon.png" = let + rev = "697a8211b0f427a921e7935a35d14bb3e32d0a2c"; + in pkgs.stdenvNoCC.mkDerivation { + name = "favicon.png"; + + src = pkgs.fetchurl { + url = "https://raw.githubusercontent.com/TilCreator/NixOwO/''${rev}/NixOwO_plain.svg"; + hash = "sha256-tWhHMfJ3Od58N9H5yOKPMfM56hYWSOnr/TGCBi8bo9E="; + }; + + nativeBuildInputs = with pkgs; [ librsvg ]; + + dontUnpack = true; + installPhase = ''' + rsvg-convert -o $out -w 96 -h 96 $src + '''; + }; + } + ''; + }; + + dist = { + address = mkOption { + type = ipAddress; + default = "127.0.0.1"; + description = mdDoc '' + Listen address for Erlang distribution protocol and Port Mapper Daemon (epmd). + ''; + }; + + epmdPort = mkOption { + type = types.port; + default = 4369; + description = mdDoc "TCP port to bind Erlang Port Mapper Daemon to."; + }; + + portMin = mkOption { + type = types.port; + default = 49152; + description = mdDoc "Lower bound for Erlang distribution protocol TCP port."; + }; + + portMax = mkOption { + type = types.port; + default = 65535; + description = mdDoc "Upper bound for Erlang distribution protocol TCP port."; + }; + + cookie = mkOption { + type = types.nullOr secret; + default = null; + example = { _secret = "/var/lib/secrets/akkoma/releaseCookie"; }; + description = mdDoc '' + Erlang release cookie. + + If set to `null`, a temporary random cookie will be generated. + ''; + }; + }; + + config = mkOption { + description = mdDoc '' + Configuration for Akkoma. The attributes are serialised to Elixir DSL. + + Refer to <https://docs.akkoma.dev/stable/configuration/cheatsheet/> for + configuration options. + + 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. + ''; + type = types.submodule { + freeformType = format.type; + options = { + ":pleroma" = { + ":instance" = { + name = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Instance name."; + }; + + email = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Instance administrator email."; + }; + + description = mkOption { + type = types.nonEmptyStr; + description = mdDoc "Instance description."; + }; + + static_dir = mkOption { + type = types.path; + default = toString staticFiles; + defaultText = literalMD '' + Derivation gathering the following paths into a directory: + + - [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends) + - [{option}`services.akkoma.extraStatic`](#opt-services.akkoma.extraStatic) + ''; + description = mdDoc '' + Directory of static files. + + This directory can be built using a derivation, or it can be managed as mutable + state by setting the option to an absolute path. + ''; + }; + + upload_dir = mkOption { + type = absolutePath; + default = "/var/lib/akkoma/uploads"; + description = mdDoc '' + Directory where Akkoma will put uploaded files. + ''; + }; + }; + + "Pleroma.Repo" = mkOption { + type = elixirValue; + default = { + adapter = format.lib.mkRaw "Ecto.Adapters.Postgres"; + socket_dir = "/run/postgresql"; + username = cfg.user; + database = "akkoma"; + }; + defaultText = literalExpression '' + { + adapter = (pkgs.formats.elixirConf { }).lib.mkRaw "Ecto.Adapters.Postgres"; + socket_dir = "/run/postgresql"; + username = config.services.akkoma.user; + database = "akkoma"; + } + ''; + description = mdDoc '' + Database configuration. + + Refer to + <https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options> + for options. + ''; + }; + + "Pleroma.Web.Endpoint" = { + url = { + host = mkOption { + type = types.nonEmptyStr; + default = config.networking.fqdn; + defaultText = literalExpression "config.networking.fqdn"; + description = mdDoc "Domain name of the instance."; + }; + + scheme = mkOption { + type = types.nonEmptyStr; + default = "https"; + description = mdDoc "URL scheme."; + }; + + port = mkOption { + type = types.port; + default = 443; + description = mdDoc "External port number."; + }; + }; + + http = { + ip = mkOption { + type = types.either absolutePath ipAddress; + default = "/run/akkoma/socket"; + example = "::1"; + description = mdDoc '' + Listener IP address or Unix socket path. + + The value is automatically converted to Elixir’s internal address + representation during serialisation. + ''; + }; + + port = mkOption { + type = types.port; + default = if isAbsolutePath web.http.ip then 0 else 4000; + defaultText = literalExpression '' + if isAbsolutePath config.services.akkoma.config.:pleroma"."Pleroma.Web.Endpoint".http.ip + then 0 + else 4000; + ''; + description = mdDoc '' + Listener port number. + + Must be 0 if using a Unix socket. + ''; + }; + }; + + secret_key_base = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/key-base"; }; + description = mdDoc '' + Secret key used as a base to generate further secrets for encrypting and + signing data. + + The attribute `_secret` should point to a file containing the secret. + + This key can generated can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z-._~' </dev/urandom | head -c 64 + ``` + ''; + }; + + live_view = { + signing_salt = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/liveview-salt"; }; + description = mdDoc '' + LiveView signing salt. + + The attribute `_secret` should point to a file containing the secret. + + This salt can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8 + ``` + ''; + }; + }; + + signing_salt = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/signing-salt"; }; + description = mdDoc '' + Signing salt. + + The attribute `_secret` should point to a file containing the secret. + + This salt can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8 + ``` + ''; + }; + }; + + ":frontends" = mkOption { + type = elixirValue; + default = mapAttrs + (key: val: format.lib.mkMap { name = val.name; ref = val.ref; }) + cfg.frontends; + defaultText = literalExpression '' + lib.mapAttrs (key: val: + (pkgs.formats.elixirConf { }).lib.mkMap { name = val.name; ref = val.ref; }) + config.services.akkoma.frontends; + ''; + description = mdDoc '' + Frontend configuration. + + Users should rely on the default value and prefer to configure frontends through + [{option}`config.services.akkoma.frontends`](#opt-services.akkoma.frontends). + ''; + }; + }; + + ":web_push_encryption" = mkOption { + default = { }; + description = mdDoc '' + Web Push Notifications configuration. + + The necessary key pair can be generated as follows: + + ```ShellSession + $ nix-shell -p nodejs --run 'npx web-push generate-vapid-keys' + ``` + ''; + type = types.submodule { + freeformType = elixirValue; + options = { + ":vapid_details" = { + subject = mkOption { + type = types.nonEmptyStr; + default = "mailto:${ex.":pleroma".":instance".email}"; + defaultText = literalExpression '' + "mailto:''${config.services.akkoma.config.":pleroma".":instance".email}" + ''; + description = mdDoc "mailto URI for administrative contact."; + }; + + public_key = mkOption { + type = with types; either nonEmptyStr secret; + default = { _secret = "/var/lib/secrets/akkoma/vapid-public"; }; + description = mdDoc "base64-encoded public ECDH key."; + }; + + private_key = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/vapid-private"; }; + description = mdDoc '' + base64-encoded private ECDH key. + + The attribute `_secret` should point to a file containing the secret. + ''; + }; + }; + }; + }; + }; + + ":joken" = { + ":default_signer" = mkOption { + type = secret; + default = { _secret = "/var/lib/secrets/akkoma/jwt-signer"; }; + description = mdDoc '' + JWT signing secret. + + The attribute `_secret` should point to a file containing the secret. + + This secret can be generated as follows: + + ```ShellSession + $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 64 + ``` + ''; + }; + }; + + ":logger" = { + ":backends" = mkOption { + type = types.listOf elixirValue; + visible = false; + default = with format.lib; [ + (mkTuple [ (mkRaw "ExSyslogger") (mkAtom ":ex_syslogger") ]) + ]; + }; + + ":ex_syslogger" = { + ident = mkOption { + type = types.str; + visible = false; + default = "akkoma"; + }; + + level = mkOption { + type = types.nonEmptyStr; + apply = format.lib.mkAtom; + default = ":info"; + example = ":warning"; + description = mdDoc '' + Log level. + + Refer to + <https://hexdocs.pm/logger/Logger.html#module-levels> + for options. + ''; + }; + }; + }; + + ":tzdata" = { + ":data_dir" = mkOption { + type = elixirValue; + internal = true; + default = format.lib.mkRaw '' + Path.join(System.fetch_env!("CACHE_DIRECTORY"), "tzdata") + ''; + }; + }; + }; + }; + }; + + nginx = mkOption { + type = with types; nullOr (submodule + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })); + default = null; + description = mdDoc '' + Extra configuration for the nginx virtual host of Akkoma. + + If set to `null`, no virtual host will be added to the nginx configuration. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + warnings = optionals (!config.security.sudo.enable) ['' + The pleroma_ctl wrapper enabled by the installWrapper option relies on + sudo, which appears to have been disabled through security.sudo.enable. + '']; + + users = { + users."${cfg.user}" = { + description = "Akkoma user"; + group = cfg.group; + isSystemUser = true; + }; + groups."${cfg.group}" = { }; + }; + + # Confinement of the main service unit requires separation of the + # configuration generation into a separate unit to permit access to secrets + # residing outside of the chroot. + systemd.services.akkoma-config = { + description = "Akkoma social network configuration"; + reloadTriggers = [ configFile ] ++ secretPaths; + + unitConfig.PropagatesReloadTo = [ "akkoma.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + UMask = "0077"; + + RuntimeDirectory = "akkoma"; + + ExecStart = mkMerge [ + (mkIf (cfg.dist.cookie == null) [ genScript ]) + (mkIf (cfg.dist.cookie != null) [ copyScript ]) + (mkIf cfg.initSecrets [ initSecretsScript ]) + [ configScript ] + ]; + + ExecReload = mkMerge [ + (mkIf cfg.initSecrets [ initSecretsScript ]) + [ configScript ] + ]; + }; + }; + + systemd.services.akkoma-initdb = mkIf cfg.initDb.enable { + description = "Akkoma social network database setup"; + requires = [ "akkoma-config.service" ]; + requiredBy = [ "akkoma.service" ]; + after = [ "akkoma-config.service" "postgresql.service" ]; + before = [ "akkoma.service" ]; + + serviceConfig = { + Type = "oneshot"; + User = mkIf (db ? socket_dir || db ? socket) + cfg.initDb.username; + RemainAfterExit = true; + UMask = "0077"; + ExecStart = initDbScript; + PrivateTmp = true; + }; + }; + + systemd.services.akkoma = let + runtimeInputs = with pkgs; [ coreutils gawk gnused ] ++ cfg.extraPackages; + in { + description = "Akkoma social network"; + documentation = [ "https://docs.akkoma.dev/stable/" ]; + + # This service depends on network-online.target and is sequenced after + # it because it requires access to the Internet to function properly. + bindsTo = [ "akkoma-config.service" ]; + wants = [ "network-online.service" ]; + wantedBy = [ "multi-user.target" ]; + after = [ + "akkoma-config.target" + "network.target" + "network-online.target" + "postgresql.service" + ]; + + confinement.packages = mkIf isConfined runtimeInputs; + path = runtimeInputs; + + serviceConfig = { + Type = "exec"; + User = cfg.user; + Group = cfg.group; + UMask = "0077"; + + # The run‐time directory is preserved as it is managed by the akkoma-config.service unit. + RuntimeDirectory = "akkoma"; + RuntimeDirectoryPreserve = true; + + CacheDirectory = "akkoma"; + + BindPaths = [ "${uploadDir}:${uploadDir}:norbind" ]; + BindReadOnlyPaths = mkMerge [ + (mkIf (!isStorePath staticDir) [ "${staticDir}:${staticDir}:norbind" ]) + (mkIf isConfined (mkMerge [ + [ "/etc/hosts" "/etc/resolv.conf" ] + (mkIf (isStorePath staticDir) (map (dir: "${dir}:${dir}:norbind") + (splitString "\n" (readFile ((pkgs.closureInfo { rootPaths = staticDir; }) + "/store-paths"))))) + (mkIf (db ? socket_dir) [ "${db.socket_dir}:${db.socket_dir}:norbind" ]) + (mkIf (db ? socket) [ "${db.socket}:${db.socket}:norbind" ]) + ])) + ]; + + ExecStartPre = "${envWrapper}/bin/pleroma_ctl migrate"; + ExecStart = "${envWrapper}/bin/pleroma start"; + ExecStartPost = socketScript; + ExecStop = "${envWrapper}/bin/pleroma stop"; + ExecStopPost = mkIf (isAbsolutePath web.http.ip) + "${pkgs.coreutils}/bin/rm -f '${web.http.ip}'"; + + ProtectProc = "noaccess"; + ProcSubset = "pid"; + ProtectSystem = mkIf (!isConfined) "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateIPC = true; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + + CapabilityBoundingSet = mkIf + (any (port: port > 0 && port < 1024) + [ web.http.port cfg.dist.epmdPort cfg.dist.portMin ]) + [ "CAP_NET_BIND_SERVICE" ]; + + NoNewPrivileges = true; + SystemCallFilter = [ "@system-service" "~@privileged" "@chown" ]; + SystemCallArchitectures = "native"; + + DeviceAllow = null; + DevicePolicy = "closed"; + + # SMTP adapter uses dynamic port 0 binding, which is incompatible with bind address filtering + SocketBindAllow = mkIf (!hasSmtp) (mkMerge [ + [ "tcp:${toString cfg.dist.epmdPort}" "tcp:${toString cfg.dist.portMin}-${toString cfg.dist.portMax}" ] + (mkIf (web.http.port != 0) [ "tcp:${toString web.http.port}" ]) + ]); + SocketBindDeny = mkIf (!hasSmtp) "any"; + }; + }; + + systemd.tmpfiles.rules = [ + "d ${uploadDir} 0700 ${cfg.user} ${cfg.group} - -" + "Z ${uploadDir} ~0700 ${cfg.user} ${cfg.group} - -" + ]; + + environment.systemPackages = mkIf (cfg.installWrapper) [ userWrapper ]; + + services.nginx.virtualHosts = mkIf (cfg.nginx != null) { + ${web.url.host} = mkMerge [ cfg.nginx { + locations."/" = { + proxyPass = + if isAbsolutePath web.http.ip + then "http://unix:${web.http.ip}" + else if hasInfix ":" web.http.ip + then "http://[${web.http.ip}]:${toString web.http.port}" + else "http://${web.http.ip}:${toString web.http.port}"; + + proxyWebsockets = true; + recommendedProxySettings = true; + }; + }]; + }; + }; + + meta.maintainers = with maintainers; [ mvs ]; + meta.doc = ./akkoma.md; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/alps.nix b/nixpkgs/nixos/modules/services/web-apps/alps.nix new file mode 100644 index 000000000000..05fb676102df --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/alps.nix @@ -0,0 +1,132 @@ +{ lib, pkgs, config, ... }: + +with lib; + +let + cfg = config.services.alps; +in { + options.services.alps = { + enable = mkEnableOption (lib.mdDoc "alps"); + + port = mkOption { + type = types.port; + default = 1323; + description = lib.mdDoc '' + TCP port the service should listen on. + ''; + }; + + bindIP = mkOption { + default = "[::]"; + type = types.str; + description = lib.mdDoc '' + The IP the service should listen on. + ''; + }; + + theme = mkOption { + type = types.enum [ "alps" "sourcehut" ]; + default = "sourcehut"; + description = lib.mdDoc '' + The frontend's theme to use. + ''; + }; + + imaps = { + port = mkOption { + type = types.port; + default = 993; + description = lib.mdDoc '' + The IMAPS server port. + ''; + }; + + host = mkOption { + type = types.str; + default = "[::1]"; + example = "mail.example.org"; + description = lib.mdDoc '' + The IMAPS server address. + ''; + }; + }; + + smtps = { + port = mkOption { + type = types.port; + default = 465; + description = lib.mdDoc '' + The SMTPS server port. + ''; + }; + + host = mkOption { + type = types.str; + default = cfg.imaps.host; + defaultText = "services.alps.imaps.host"; + example = "mail.example.org"; + description = lib.mdDoc '' + 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" ]; + 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" ]; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/atlassian/confluence.nix b/nixpkgs/nixos/modules/services/web-apps/atlassian/confluence.nix new file mode 100644 index 000000000000..fe98c1777ea0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/atlassian/confluence.nix @@ -0,0 +1,228 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.confluence; + + pkg = cfg.package.override (optionalAttrs cfg.sso.enable { + enableSSO = cfg.sso.enable; + }); + + crowdProperties = pkgs.writeText "crowd.properties" '' + application.name ${cfg.sso.applicationName} + application.password ${if cfg.sso.applicationPassword != null then cfg.sso.applicationPassword else "@NIXOS_CONFLUENCE_CROWD_SSO_PWD@"} + application.login.url ${cfg.sso.crowd}/console/ + + crowd.server.url ${cfg.sso.crowd}/services/ + crowd.base.url ${cfg.sso.crowd}/ + + session.isauthenticated session.isauthenticated + session.tokenkey session.tokenkey + session.validationinterval ${toString cfg.sso.validationInterval} + session.lastvalidation session.lastvalidation + ''; + +in + +{ + options = { + services.confluence = { + enable = mkEnableOption (lib.mdDoc "Atlassian Confluence service"); + + user = mkOption { + type = types.str; + default = "confluence"; + description = lib.mdDoc "User which runs confluence."; + }; + + group = mkOption { + type = types.str; + default = "confluence"; + description = lib.mdDoc "Group which runs confluence."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/confluence"; + description = lib.mdDoc "Home directory of the confluence instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc "Address to listen on."; + }; + + listenPort = mkOption { + type = types.port; + default = 8090; + description = lib.mdDoc "Port to listen on."; + }; + + catalinaOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-Xms1024m" "-Xmx2048m" "-Dconfluence.disable.peopledirectory.all=true" ]; + description = lib.mdDoc "Java options to pass to catalina/tomcat."; + }; + + proxy = { + enable = mkEnableOption (lib.mdDoc "proxy support"); + + name = mkOption { + type = types.str; + example = "confluence.example.com"; + description = lib.mdDoc "Virtual hostname at the proxy"; + }; + + port = mkOption { + type = types.port; + default = 443; + example = 80; + description = lib.mdDoc "Port used at the proxy"; + }; + + scheme = mkOption { + type = types.str; + default = "https"; + example = "http"; + description = lib.mdDoc "Protocol used at the proxy."; + }; + }; + + sso = { + enable = mkEnableOption (lib.mdDoc "SSO with Atlassian Crowd"); + + crowd = mkOption { + type = types.str; + example = "http://localhost:8095/crowd"; + description = lib.mdDoc "Crowd Base URL without trailing slash"; + }; + + applicationName = mkOption { + type = types.str; + example = "jira"; + description = lib.mdDoc "Exact name of this Confluence instance in Crowd"; + }; + + applicationPassword = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "Application password of this Confluence instance in Crowd"; + }; + + applicationPasswordFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "Path to the application password for Crowd of Confluence."; + }; + + validationInterval = mkOption { + type = types.int; + default = 2; + example = 0; + description = lib.mdDoc '' + Set to 0, if you want authentication checks to occur on each + request. Otherwise set to the number of minutes between request + to validate if the user is logged in or out of the Crowd SSO + server. Setting this value to 1 or higher will increase the + performance of Crowd's integration. + ''; + }; + }; + + package = mkOption { + type = types.package; + default = pkgs.atlassian-confluence; + defaultText = literalExpression "pkgs.atlassian-confluence"; + description = lib.mdDoc "Atlassian Confluence package to use."; + }; + + jrePackage = mkOption { + type = types.package; + default = pkgs.oraclejre8; + defaultText = literalExpression "pkgs.oraclejre8"; + description = lib.mdDoc "Note that Atlassian only support the Oracle JRE (JRASERVER-46152)."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; + + assertions = [ + { assertion = cfg.sso.enable -> ((cfg.sso.applicationPassword == null) != (cfg.sso.applicationPasswordFile)); + message = "Please set either applicationPassword or applicationPasswordFile"; + } + ]; + + warnings = mkIf (cfg.sso.enable && cfg.sso.applicationPassword != null) [ + "Using `services.confluence.sso.applicationPassword` is deprecated! Use `applicationPasswordFile` instead!" + ]; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d '${cfg.home}' - ${cfg.user} - - -" + "d /run/confluence - - - - -" + + "L+ /run/confluence/home - - - - ${cfg.home}" + "L+ /run/confluence/logs - - - - ${cfg.home}/logs" + "L+ /run/confluence/temp - - - - ${cfg.home}/temp" + "L+ /run/confluence/work - - - - ${cfg.home}/work" + "L+ /run/confluence/server.xml - - - - ${cfg.home}/server.xml" + ]; + + systemd.services.confluence = { + description = "Atlassian Confluence"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + path = [ cfg.jrePackage pkgs.bash ]; + + environment = { + CONF_USER = cfg.user; + JAVA_HOME = "${cfg.jrePackage}"; + CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions; + JAVA_OPTS = mkIf cfg.sso.enable "-Dcrowd.properties=${cfg.home}/crowd.properties"; + }; + + preStart = '' + mkdir -p ${cfg.home}/{logs,work,temp,deploy} + + sed -e 's,port="8090",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \ + '' + (lib.optionalString cfg.proxy.enable '' + -e 's,protocol="org.apache.coyote.http11.Http11NioProtocol",protocol="org.apache.coyote.http11.Http11NioProtocol" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}",' \ + '') + '' + ${pkg}/conf/server.xml.dist > ${cfg.home}/server.xml + + ${optionalString cfg.sso.enable '' + install -m660 ${crowdProperties} ${cfg.home}/crowd.properties + ${optionalString (cfg.sso.applicationPasswordFile != null) '' + ${pkgs.replace-secret}/bin/replace-secret \ + '@NIXOS_CONFLUENCE_CROWD_SSO_PWD@' \ + ${cfg.sso.applicationPasswordFile} \ + ${cfg.home}/crowd.properties + ''} + ''} + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + Restart = "on-failure"; + RestartSec = "10"; + ExecStart = "${pkg}/bin/start-confluence.sh -fg"; + ExecStop = "${pkg}/bin/stop-confluence.sh"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/atlassian/crowd.nix b/nixpkgs/nixos/modules/services/web-apps/atlassian/crowd.nix new file mode 100644 index 000000000000..c8d1eaef31d8 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/atlassian/crowd.nix @@ -0,0 +1,197 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.crowd; + + pkg = cfg.package.override { + home = cfg.home; + port = cfg.listenPort; + openidPassword = cfg.openidPassword; + } // (optionalAttrs cfg.proxy.enable { + proxyUrl = "${cfg.proxy.scheme}://${cfg.proxy.name}:${toString cfg.proxy.port}"; + }); + + crowdPropertiesFile = pkgs.writeText "crowd.properties" '' + application.name crowd-openid-server + application.password @NIXOS_CROWD_OPENID_PW@ + application.base.url http://localhost:${toString cfg.listenPort}/openidserver + application.login.url http://localhost:${toString cfg.listenPort}/openidserver + application.login.url.template http://localhost:${toString cfg.listenPort}/openidserver?returnToUrl=''${RETURN_TO_URL} + + crowd.server.url http://localhost:${toString cfg.listenPort}/crowd/services/ + + session.isauthenticated session.isauthenticated + session.tokenkey session.tokenkey + session.validationinterval 0 + session.lastvalidation session.lastvalidation + ''; + +in + +{ + options = { + services.crowd = { + enable = mkEnableOption (lib.mdDoc "Atlassian Crowd service"); + + user = mkOption { + type = types.str; + default = "crowd"; + description = lib.mdDoc "User which runs Crowd."; + }; + + group = mkOption { + type = types.str; + default = "crowd"; + description = lib.mdDoc "Group which runs Crowd."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/crowd"; + description = lib.mdDoc "Home directory of the Crowd instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc "Address to listen on."; + }; + + listenPort = mkOption { + type = types.port; + default = 8092; + description = lib.mdDoc "Port to listen on."; + }; + + openidPassword = mkOption { + type = types.str; + default = "WILL_NEVER_BE_SET"; + description = lib.mdDoc "Application password for OpenID server."; + }; + + openidPasswordFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "Path to the file containing the application password for OpenID server."; + }; + + catalinaOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-Xms1024m" "-Xmx2048m" ]; + description = lib.mdDoc "Java options to pass to catalina/tomcat."; + }; + + proxy = { + enable = mkEnableOption (lib.mdDoc "reverse proxy support"); + + name = mkOption { + type = types.str; + example = "crowd.example.com"; + description = lib.mdDoc "Virtual hostname at the proxy"; + }; + + port = mkOption { + type = types.port; + default = 443; + example = 80; + description = lib.mdDoc "Port used at the proxy"; + }; + + scheme = mkOption { + type = types.str; + default = "https"; + example = "http"; + description = lib.mdDoc "Protocol used at the proxy."; + }; + + secure = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Whether the connections to the proxy should be considered secure."; + }; + }; + + package = mkOption { + type = types.package; + default = pkgs.atlassian-crowd; + defaultText = literalExpression "pkgs.atlassian-crowd"; + description = lib.mdDoc "Atlassian Crowd package to use."; + }; + + jrePackage = mkOption { + type = types.package; + default = pkgs.oraclejre8; + defaultText = literalExpression "pkgs.oraclejre8"; + description = lib.mdDoc "Note that Atlassian only support the Oracle JRE (JRASERVER-46152)."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d '${cfg.home}' - ${cfg.user} ${cfg.group} - -" + "d /run/atlassian-crowd - - - - -" + + "L+ /run/atlassian-crowd/database - - - - ${cfg.home}/database" + "L+ /run/atlassian-crowd/logs - - - - ${cfg.home}/logs" + "L+ /run/atlassian-crowd/work - - - - ${cfg.home}/work" + "L+ /run/atlassian-crowd/server.xml - - - - ${cfg.home}/server.xml" + ]; + + systemd.services.atlassian-crowd = { + description = "Atlassian Crowd"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + path = [ cfg.jrePackage ]; + + environment = { + JAVA_HOME = "${cfg.jrePackage}"; + CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions; + CATALINA_TMPDIR = "/tmp"; + JAVA_OPTS = mkIf (cfg.openidPasswordFile != null) "-Dcrowd.properties=${cfg.home}/crowd.properties"; + }; + + preStart = '' + rm -rf ${cfg.home}/work + mkdir -p ${cfg.home}/{logs,database,work} + + sed -e 's,port="8095",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \ + '' + (lib.optionalString cfg.proxy.enable '' + -e 's,compression="on",compression="off" protocol="HTTP/1.1" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}" secure="${boolToString cfg.proxy.secure}",' \ + '') + '' + ${pkg}/apache-tomcat/conf/server.xml.dist > ${cfg.home}/server.xml + + ${optionalString (cfg.openidPasswordFile != null) '' + install -m660 ${crowdPropertiesFile} ${cfg.home}/crowd.properties + ${pkgs.replace-secret}/bin/replace-secret \ + '@NIXOS_CROWD_OPENID_PW@' \ + ${cfg.openidPasswordFile} \ + ${cfg.home}/crowd.properties + ''} + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + Restart = "on-failure"; + RestartSec = "10"; + ExecStart = "${pkg}/start_crowd.sh -fg"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/atlassian/jira.nix b/nixpkgs/nixos/modules/services/web-apps/atlassian/jira.nix new file mode 100644 index 000000000000..4cc858216944 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/atlassian/jira.nix @@ -0,0 +1,223 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.jira; + + pkg = cfg.package.override (optionalAttrs cfg.sso.enable { + enableSSO = cfg.sso.enable; + }); + + crowdProperties = pkgs.writeText "crowd.properties" '' + application.name ${cfg.sso.applicationName} + application.password @NIXOS_JIRA_CROWD_SSO_PWD@ + application.login.url ${cfg.sso.crowd}/console/ + + crowd.server.url ${cfg.sso.crowd}/services/ + crowd.base.url ${cfg.sso.crowd}/ + + session.isauthenticated session.isauthenticated + session.tokenkey session.tokenkey + session.validationinterval ${toString cfg.sso.validationInterval} + session.lastvalidation session.lastvalidation + ''; + +in + +{ + options = { + services.jira = { + enable = mkEnableOption (lib.mdDoc "Atlassian JIRA service"); + + user = mkOption { + type = types.str; + default = "jira"; + description = lib.mdDoc "User which runs JIRA."; + }; + + group = mkOption { + type = types.str; + default = "jira"; + description = lib.mdDoc "Group which runs JIRA."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/jira"; + description = lib.mdDoc "Home directory of the JIRA instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc "Address to listen on."; + }; + + listenPort = mkOption { + type = types.port; + default = 8091; + description = lib.mdDoc "Port to listen on."; + }; + + catalinaOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-Xms1024m" "-Xmx2048m" ]; + description = lib.mdDoc "Java options to pass to catalina/tomcat."; + }; + + proxy = { + enable = mkEnableOption (lib.mdDoc "reverse proxy support"); + + name = mkOption { + type = types.str; + example = "jira.example.com"; + description = lib.mdDoc "Virtual hostname at the proxy"; + }; + + port = mkOption { + type = types.port; + default = 443; + example = 80; + description = lib.mdDoc "Port used at the proxy"; + }; + + scheme = mkOption { + type = types.str; + default = "https"; + example = "http"; + description = lib.mdDoc "Protocol used at the proxy."; + }; + + secure = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Whether the connections to the proxy should be considered secure."; + }; + }; + + sso = { + enable = mkEnableOption (lib.mdDoc "SSO with Atlassian Crowd"); + + crowd = mkOption { + type = types.str; + example = "http://localhost:8095/crowd"; + description = lib.mdDoc "Crowd Base URL without trailing slash"; + }; + + applicationName = mkOption { + type = types.str; + example = "jira"; + description = lib.mdDoc "Exact name of this JIRA instance in Crowd"; + }; + + applicationPasswordFile = mkOption { + type = types.str; + description = lib.mdDoc "Path to the file containing the application password of this JIRA instance in Crowd"; + }; + + validationInterval = mkOption { + type = types.int; + default = 2; + example = 0; + description = lib.mdDoc '' + Set to 0, if you want authentication checks to occur on each + request. Otherwise set to the number of minutes between request + to validate if the user is logged in or out of the Crowd SSO + server. Setting this value to 1 or higher will increase the + performance of Crowd's integration. + ''; + }; + }; + + package = mkOption { + type = types.package; + default = pkgs.atlassian-jira; + defaultText = literalExpression "pkgs.atlassian-jira"; + description = lib.mdDoc "Atlassian JIRA package to use."; + }; + + jrePackage = mkOption { + type = types.package; + default = pkgs.oraclejre8; + defaultText = literalExpression "pkgs.oraclejre8"; + description = lib.mdDoc "Note that Atlassian only support the Oracle JRE (JRASERVER-46152)."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.home; + }; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d '${cfg.home}' - ${cfg.user} - - -" + "d /run/atlassian-jira - - - - -" + + "L+ /run/atlassian-jira/home - - - - ${cfg.home}" + "L+ /run/atlassian-jira/logs - - - - ${cfg.home}/logs" + "L+ /run/atlassian-jira/work - - - - ${cfg.home}/work" + "L+ /run/atlassian-jira/temp - - - - ${cfg.home}/temp" + "L+ /run/atlassian-jira/server.xml - - - - ${cfg.home}/server.xml" + ]; + + systemd.services.atlassian-jira = { + description = "Atlassian JIRA"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + path = [ cfg.jrePackage pkgs.bash ]; + + environment = { + JIRA_USER = cfg.user; + JIRA_HOME = cfg.home; + JAVA_HOME = "${cfg.jrePackage}"; + CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions; + JAVA_OPTS = mkIf cfg.sso.enable "-Dcrowd.properties=${cfg.home}/crowd.properties"; + }; + + preStart = '' + mkdir -p ${cfg.home}/{logs,work,temp,deploy} + + sed -e 's,port="8080",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \ + '' + (lib.optionalString cfg.proxy.enable '' + -e 's,protocol="HTTP/1.1",protocol="HTTP/1.1" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}" secure="${toString cfg.proxy.secure}",' \ + '') + '' + ${pkg}/conf/server.xml.dist > ${cfg.home}/server.xml + + ${optionalString cfg.sso.enable '' + install -m660 ${crowdProperties} ${cfg.home}/crowd.properties + ${pkgs.replace-secret}/bin/replace-secret \ + '@NIXOS_JIRA_CROWD_SSO_PWD@' \ + ${cfg.sso.applicationPasswordFile} \ + ${cfg.home}/crowd.properties + ''} + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + Restart = "on-failure"; + RestartSec = "10"; + ExecStart = "${pkg}/bin/start-jira.sh -fg"; + ExecStop = "${pkg}/bin/stop-jira.sh"; + }; + }; + }; + + imports = [ + (mkRemovedOptionModule [ "services" "jira" "sso" "applicationPassword" ] '' + Use `applicationPasswordFile` instead! + '') + ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/bookstack.nix b/nixpkgs/nixos/modules/services/web-apps/bookstack.nix new file mode 100644 index 000000000000..d846c98577c8 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/bookstack.nix @@ -0,0 +1,446 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.bookstack; + bookstack = pkgs.bookstack.override { + dataDir = cfg.dataDir; + }; + db = cfg.database; + mail = cfg.mail; + + user = cfg.user; + group = cfg.group; + + # shell script for local administration + artisan = pkgs.writeScriptBin "bookstack" '' + #! ${pkgs.runtimeShell} + cd ${bookstack} + sudo=exec + if [[ "$USER" != ${user} ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${user}' + fi + $sudo ${pkgs.php}/bin/php artisan $* + ''; + + tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME; + +in { + imports = [ + (mkRemovedOptionModule [ "services" "bookstack" "extraConfig" ] "Use services.bookstack.config instead.") + (mkRemovedOptionModule [ "services" "bookstack" "cacheDir" ] "The cache directory is now handled automatically.") + ]; + + options.services.bookstack = { + + enable = mkEnableOption (lib.mdDoc "BookStack"); + + user = mkOption { + default = "bookstack"; + description = lib.mdDoc "User bookstack runs as."; + type = types.str; + }; + + group = mkOption { + default = "bookstack"; + description = lib.mdDoc "Group bookstack runs as."; + type = types.str; + }; + + appKeyFile = mkOption { + description = lib.mdDoc '' + A file containing the Laravel APP_KEY - a 32 character long, + base64 encoded key used for encryption where needed. Can be + generated with `head -c 32 /dev/urandom | base64`. + ''; + example = "/run/keys/bookstack-appkey"; + type = types.path; + }; + + hostname = lib.mkOption { + type = lib.types.str; + default = config.networking.fqdnOrHostName; + defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; + example = "bookstack.example.com"; + description = lib.mdDoc '' + The hostname to serve BookStack on. + ''; + }; + + appURL = mkOption { + description = lib.mdDoc '' + The root URL that you want to host BookStack on. All URLs in BookStack 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: `php artisan bookstack:update-url https://old.example.com https://new.example.com` + ''; + 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 = lib.mdDoc "BookStack data directory"; + default = "/var/lib/bookstack"; + type = types.path; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + name = mkOption { + type = types.str; + default = "bookstack"; + description = lib.mdDoc "Database name."; + }; + user = mkOption { + type = types.str; + default = user; + defaultText = literalExpression "user"; + description = lib.mdDoc "Database username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/bookstack-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + mail = { + driver = mkOption { + type = types.enum [ "smtp" "sendmail" ]; + default = "smtp"; + description = lib.mdDoc "Mail driver to use."; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Mail host address."; + }; + port = mkOption { + type = types.port; + default = 1025; + description = lib.mdDoc "Mail host port."; + }; + fromName = mkOption { + type = types.str; + default = "BookStack"; + description = lib.mdDoc "Mail \"from\" name."; + }; + from = mkOption { + type = types.str; + default = "mail@bookstackapp.com"; + description = lib.mdDoc "Mail \"from\" email."; + }; + user = mkOption { + type = with types; nullOr str; + default = null; + example = "bookstack"; + description = lib.mdDoc "Mail username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/bookstack-mailpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`mail.user`. + ''; + }; + encryption = mkOption { + type = with types; nullOr (enum [ "tls" ]); + default = null; + description = lib.mdDoc "SMTP encryption mechanism to use."; + }; + }; + + maxUploadSize = mkOption { + type = types.str; + default = "18M"; + example = "1G"; + description = lib.mdDoc "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 = lib.mdDoc '' + Options for the bookstack PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + nginx = mkOption { + type = types.submodule ( + recursiveUpdate + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {} + ); + default = {}; + example = literalExpression '' + { + serverAliases = [ + "bookstack.''${config.networking.domain}" + ]; + # To enable encryption and let let's encrypt take care of certificate + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + The path to a file containing the value the + option should be set to in the final + configuration file. + ''; + }; + }; + }))); + default = {}; + example = literalExpression '' + { + ALLOWED_IFRAME_HOSTS = "https://example.com"; + WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf"; + AUTH_METHOD = "oidc"; + OIDC_NAME = "MyLogin"; + OIDC_DISPLAY_NAME_CLAIMS = "name"; + OIDC_CLIENT_ID = "bookstack"; + OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"}; + OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm"; + OIDC_ISSUER_DISCOVER = true; + } + ''; + description = lib.mdDoc '' + BookStack configuration options to set in the + {file}`.env` file. + + Refer to <https://www.bookstackapp.com/docs/> + for details on supported values. + + 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 to get a better picture of + this: in the resulting {file}`.env` file, the + `OIDC_CLIENT_SECRET` key will be set to the + contents of the {file}`/run/keys/oidc_secret` + file. + ''; + }; + + }; + + config = mkIf cfg.enable { + + assertions = [ + { assertion = db.createLocally -> db.user == user; + message = "services.bookstack.database.user must be set to ${user} if services.bookstack.database.createLocally is set true."; + } + { assertion = db.createLocally -> db.passwordFile == null; + message = "services.bookstack.database.passwordFile cannot be specified if services.bookstack.database.createLocally is set to true."; + } + ]; + + services.bookstack.config = { + 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/bookstack/cache/services.php"; + APP_PACKAGES_CACHE = "/run/bookstack/cache/packages.php"; + APP_CONFIG_CACHE = "/run/bookstack/cache/config.php"; + APP_ROUTES_CACHE = "/run/bookstack/cache/routes-v7.php"; + APP_EVENTS_CACHE = "/run/bookstack/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.bookstack = { + inherit user; + inherit group; + 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; + virtualHosts.${cfg.hostname} = mkMerge [ cfg.nginx { + root = mkForce "${bookstack}/public"; + locations = { + "/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php?$query_string"; + }; + "~ \.php$".extraConfig = '' + fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket}; + ''; + "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = { + extraConfig = "expires 365d;"; + }; + }; + }]; + }; + + systemd.services.bookstack-setup = { + description = "Preparation tasks for BookStack"; + before = [ "phpfpm-bookstack.service" ]; + after = optional db.createLocally "mysql.service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = user; + WorkingDirectory = "${bookstack}"; + RuntimeDirectory = "bookstack/cache"; + RuntimeDirectoryMode = "0700"; + }; + path = [ pkgs.replace-secret ]; + script = + let + isSecret = v: isAttrs v && v ? _secret && isString v._secret; + bookstackEnvVars = 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; + bookstackEnv = pkgs.writeText "bookstack.env" (bookstackEnvVars filteredConfig); + in '' + # error handling + set -euo pipefail + + # set permissions + umask 077 + + # create .env file + install -T -m 0600 -o ${user} ${bookstackEnv} "${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 db + ${pkgs.php}/bin/php artisan migrate --force + ''; + }; + + 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 == "bookstack") { + bookstack = { + inherit group; + isSystemUser = true; + }; + "${config.services.nginx.user}".extraGroups = [ group ]; + }; + groups = mkIf (group == "bookstack") { + bookstack = {}; + }; + }; + + }; + + meta.maintainers = with maintainers; [ ymarkus ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/calibre-web.nix b/nixpkgs/nixos/modules/services/web-apps/calibre-web.nix new file mode 100644 index 000000000000..143decfc0917 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/calibre-web.nix @@ -0,0 +1,165 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.calibre-web; + + inherit (lib) concatStringsSep mkEnableOption mkIf mkOption optional optionalString types; +in +{ + options = { + services.calibre-web = { + enable = mkEnableOption (lib.mdDoc "Calibre-Web"); + + listen = { + ip = mkOption { + type = types.str; + default = "::1"; + description = lib.mdDoc '' + IP address that Calibre-Web should listen on. + ''; + }; + + port = mkOption { + type = types.port; + default = 8083; + description = lib.mdDoc '' + Listen port for Calibre-Web. + ''; + }; + }; + + dataDir = mkOption { + type = types.str; + default = "calibre-web"; + description = lib.mdDoc '' + The directory below {file}`/var/lib` where Calibre-Web stores its data. + ''; + }; + + user = mkOption { + type = types.str; + default = "calibre-web"; + description = lib.mdDoc "User account under which Calibre-Web runs."; + }; + + group = mkOption { + type = types.str; + default = "calibre-web"; + description = lib.mdDoc "Group account under which Calibre-Web runs."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Open ports in the firewall for the server. + ''; + }; + + options = { + calibreLibrary = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc '' + Path to Calibre library. + ''; + }; + + enableBookConversion = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Configure path to the Calibre's ebook-convert in the DB. + ''; + }; + + enableBookUploading = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Allow books to be uploaded via Calibre-Web UI. + ''; + }; + + reverseProxyAuth = { + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable authorization using auth proxy. + ''; + }; + + header = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Auth proxy header name. + ''; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.calibre-web = let + appDb = "/var/lib/${cfg.dataDir}/app.db"; + gdriveDb = "/var/lib/${cfg.dataDir}/gdrive.db"; + calibreWebCmd = "${pkgs.calibre-web}/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}'" + ++ optional cfg.options.enableBookConversion "config_converterpath = '${pkgs.calibre}/bin/ebook-convert'" + ); + in + { + description = "Web app for browsing, reading and downloading eBooks stored in a Calibre database"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + StateDirectory = cfg.dataDir; + 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"; + }; + }; + + 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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/changedetection-io.nix b/nixpkgs/nixos/modules/services/web-apps/changedetection-io.nix new file mode 100644 index 000000000000..bbf4c2aed186 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/changedetection-io.nix @@ -0,0 +1,220 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.changedetection-io; +in +{ + options.services.changedetection-io = { + enable = mkEnableOption (lib.mdDoc "changedetection-io"); + + user = mkOption { + default = "changedetection-io"; + type = types.str; + description = lib.mdDoc '' + User account under which changedetection-io runs. + ''; + }; + + group = mkOption { + default = "changedetection-io"; + type = types.str; + description = lib.mdDoc '' + Group account under which changedetection-io runs. + ''; + }; + + listenAddress = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Address the server will listen on."; + }; + + port = mkOption { + type = types.port; + default = 5000; + description = lib.mdDoc "Port the server will listen on."; + }; + + datastorePath = mkOption { + type = types.str; + default = "/var/lib/changedetection-io"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + The base url used in notifications and `{base_url}` token. + ''; + }; + + behindProxy = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Securely pass environment variabels 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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" ]; + preStart = '' + mkdir -p ${cfg.datastorePath} + ''; + 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; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/chatgpt-retrieval-plugin.nix b/nixpkgs/nixos/modules/services/web-apps/chatgpt-retrieval-plugin.nix new file mode 100644 index 000000000000..f29d095bc10b --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/chatgpt-retrieval-plugin.nix @@ -0,0 +1,106 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.chatgpt-retrieval-plugin; +in +{ + options.services.chatgpt-retrieval-plugin = { + enable = mkEnableOption (lib.mdDoc "chatgpt-retrieval-plugin service"); + + port = mkOption { + type = types.port; + default = 8080; + description = lib.mdDoc "Port the chatgpt-retrieval-plugin service listens on."; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + example = "0.0.0.0"; + description = lib.mdDoc "The hostname or IP address for chatgpt-retrieval-plugin to bind to."; + }; + + bearerTokenPath = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to the secret bearer token used for the http api authentication. + ''; + default = ""; + example = "config.age.secrets.CHATGPT_RETRIEVAL_PLUGIN_BEARER_TOKEN.path"; + }; + + openaiApiKeyPath = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to the secret openai api key used for embeddings. + ''; + default = ""; + example = "config.age.secrets.CHATGPT_RETRIEVAL_PLUGIN_OPENAI_API_KEY.path"; + }; + + datastore = mkOption { + type = types.enum [ "pinecone" "weaviate" "zilliz" "milvus" "qdrant" "redis" ]; + default = "qdrant"; + description = lib.mdDoc "This specifies the vector database provider you want to use to store and query embeddings."; + }; + + qdrantCollection = mkOption { + type = types.str; + description = lib.mdDoc '' + name of the qdrant collection used to store documents. + ''; + default = "document_chunks"; + }; + }; + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = cfg.bearerTokenPath != ""; + message = "services.chatgpt-retrieval-plugin.bearerTokenPath should not be an empty string."; + } + { + assertion = cfg.openaiApiKeyPath != ""; + message = "services.chatgpt-retrieval-plugin.openaiApiKeyPath should not be an empty string."; + } + ]; + + systemd.services.chatgpt-retrieval-plugin = { + description = "ChatGPT Retrieval Plugin"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + DynamicUser = true; + Restart = "always"; + LoadCredential = [ + "BEARER_TOKEN:${cfg.bearerTokenPath}" + "OPENAI_API_KEY:${cfg.openaiApiKeyPath}" + ]; + StateDirectory = "chatgpt-retrieval-plugin"; + StateDirectoryMode = "0755"; + }; + + # it doesn't make sense to pass secrets as env vars, this is a hack until + # upstream has proper secret management. + script = '' + export BEARER_TOKEN=$(${pkgs.systemd}/bin/systemd-creds cat BEARER_TOKEN) + export OPENAI_API_KEY=$(${pkgs.systemd}/bin/systemd-creds cat OPENAI_API_KEY) + exec ${pkgs.chatgpt-retrieval-plugin}/bin/start --host ${cfg.host} --port ${toString cfg.port} + ''; + + environment = { + DATASTORE = cfg.datastore; + QDRANT_COLLECTION = mkIf (cfg.datastore == "qdrant") cfg.qdrantCollection; + }; + }; + + systemd.tmpfiles.rules = [ + # create the directory for static files for fastapi + "C /var/lib/chatgpt-retrieval-plugin/.well-known - - - - ${pkgs.chatgpt-retrieval-plugin}/${pkgs.python3Packages.python.sitePackages}/.well-known" + ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/cloudlog.nix b/nixpkgs/nixos/modules/services/web-apps/cloudlog.nix new file mode 100644 index 000000000000..da2cf93d7f1c --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/cloudlog.nix @@ -0,0 +1,503 @@ +{ 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 (mdDoc "Whether to enable Cloudlog"); + dataDir = mkOption { + type = str; + default = "/var/lib/cloudlog"; + description = mdDoc "Cloudlog data directory."; + }; + baseUrl = mkOption { + type = str; + default = "http://localhost"; + description = mdDoc "Cloudlog base URL"; + }; + user = mkOption { + type = str; + default = "cloudlog"; + description = mdDoc "User account under which Cloudlog runs."; + }; + database = { + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + host = mkOption { + type = str; + description = mdDoc "MySQL database host"; + default = "localhost"; + }; + name = mkOption { + type = str; + description = mdDoc "MySQL database name."; + default = "cloudlog"; + }; + user = mkOption { + type = str; + description = mdDoc "MySQL user name."; + default = "cloudlog"; + }; + passwordFile = mkOption { + type = nullOr str; + description = mdDoc "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 = mdDoc '' + Options for Cloudlog's PHP-FPM pool. + ''; + }; + virtualHost = mkOption { + type = nullOr str; + default = "localhost"; + description = mdDoc '' + Name of the nginx virtualhost to use and setup. If null, do not setup + any virtualhost. + ''; + }; + extraConfig = mkOption { + description = mdDoc '' + 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 = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the + time at which the LoTW upload will occur. + ''; + }; + }; + upload-clublog = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the time + at which the Clublog upload will occur. + ''; + }; + }; + update-lotw-users = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the + time at which the LoTW user update will occur. + ''; + }; + }; + update-dok = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the + time at which the DOK update will occur. + ''; + }; + }; + update-clublog-scp = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the time + at which the Clublog SCP update will occur. + ''; + }; + }; + update-wwff = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the time + at which the WWFF update will occur. + ''; + }; + }; + upload-qrz = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by systemd.time(7)) of the + time at which the QRZ upload will occur. + ''; + }; + }; + update-sota = { + enable = mkOption { + type = bool; + default = true; + description = mdDoc '' + 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 = mdDoc '' + Specification (in the format described by 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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/code-server.nix b/nixpkgs/nixos/modules/services/web-apps/code-server.nix new file mode 100644 index 000000000000..fa7d4a348c23 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/code-server.nix @@ -0,0 +1,249 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.code-server; + defaultUser = "code-server"; + defaultGroup = defaultUser; +in { + options = { + services.code-server = { + enable = lib.mkEnableOption (lib.mdDoc "code-server"); + + package = lib.mkPackageOptionMD pkgs "code-server" { }; + + extraPackages = lib.mkOption { + default = [ ]; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Additional environment variables to pass to code-server. + ''; + default = { }; + example = { PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig"; }; + }; + + extraArguments = lib.mkOption { + default = [ ]; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + The host name or IP address the server should listen to. + ''; + type = lib.types.str; + }; + + port = lib.mkOption { + default = 4444; + description = lib.mdDoc '' + The port the server should listen to. + ''; + type = lib.types.port; + }; + + auth = lib.mkOption { + default = "password"; + description = lib.mdDoc '' + The type of authentication to use. + ''; + type = lib.types.enum [ "none" "password" ]; + }; + + hashedPassword = lib.mkOption { + default = ""; + description = lib.mdDoc '' + Create the password with: `echo -n 'thisismypassword' | npx argon2-cli -e`. + ''; + type = lib.types.str; + }; + + user = lib.mkOption { + default = defaultUser; + example = "yourUser"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Path to a socket (bind-addr will be ignored). + ''; + type = lib.types.nullOr lib.types.str; + }; + + socketMode = lib.mkOption { + default = null; + description = lib.mdDoc '' + File mode of the socket. + ''; + type = lib.types.nullOr lib.types.str; + }; + + userDataDir = lib.mkOption { + default = null; + description = lib.mdDoc '' + Path to the user data directory. + ''; + type = lib.types.nullOr lib.types.str; + }; + + extensionsDir = lib.mkOption { + default = null; + description = lib.mdDoc '' + Path to the extensions directory. + ''; + type = lib.types.nullOr lib.types.str; + }; + + proxyDomain = lib.mkOption { + default = null; + example = "code-server.lan"; + description = lib.mdDoc '' + Domain used for proxying ports. + ''; + type = lib.types.nullOr lib.types.str; + }; + + disableTelemetry = lib.mkOption { + default = false; + example = true; + description = lib.mdDoc '' + Disable telemetry. + ''; + type = lib.types.bool; + }; + + disableUpdateCheck = lib.mkOption { + default = false; + example = true; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Disable file downloads from Code. + ''; + type = lib.types.bool; + }; + + disableWorkspaceTrust = lib.mkOption { + default = false; + example = true; + description = lib.mdDoc '' + Disable Workspace Trust feature. + ''; + type = lib.types.bool; + }; + + disableGettingStartedOverride = lib.mkOption { + default = false; + example = true; + description = lib.mdDoc '' + 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" ]; + 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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/coder.nix b/nixpkgs/nixos/modules/services/web-apps/coder.nix new file mode 100644 index 000000000000..469a29bc3aa8 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/coder.nix @@ -0,0 +1,217 @@ +{ config, lib, options, pkgs, ... }: + +with lib; + +let + cfg = config.services.coder; + name = "coder"; +in { + options = { + services.coder = { + enable = mkEnableOption (lib.mdDoc "Coder service"); + + user = mkOption { + type = types.str; + default = "coder"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = mkOption { + type = types.package; + default = pkgs.coder; + description = lib.mdDoc '' + Package to use for the service. + ''; + defaultText = literalExpression "pkgs.coder"; + }; + + homeDir = mkOption { + type = types.str; + description = lib.mdDoc '' + Home directory for coder user. + ''; + default = "/var/lib/coder"; + }; + + listenAddress = mkOption { + type = types.str; + description = lib.mdDoc '' + Listen address. + ''; + default = "127.0.0.1:3000"; + }; + + accessUrl = mkOption { + type = types.nullOr types.str; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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"; + }; + + database = { + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Create the database and database user locally. + ''; + }; + + host = mkOption { + type = types.str; + default = "/run/postgresql"; + description = lib.mdDoc '' + Hostname hosting the database. + ''; + }; + + database = mkOption { + type = types.str; + default = "coder"; + description = lib.mdDoc '' + Name of database. + ''; + }; + + username = mkOption { + type = types.str; + default = "coder"; + description = lib.mdDoc '' + Username for accessing the database. + ''; + }; + + password = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Password for accessing the database. + ''; + }; + + sslmode = mkOption { + type = types.nullOr types.str; + default = "disable"; + description = lib.mdDoc '' + Password for accessing the database. + ''; + }; + }; + + tlsCert = mkOption { + type = types.nullOr types.path; + description = lib.mdDoc '' + The path to the TLS certificate. + ''; + default = null; + }; + + tlsKey = mkOption { + type = types.nullOr types.path; + description = lib.mdDoc '' + The path to the TLS key. + ''; + default = null; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.username == name; + message = "services.coder.database.username must be set to ${user} 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 = { + 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; + }; + }; + + services.postgresql = lib.mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = [ + cfg.database.database + ]; + ensureUsers = [{ + name = cfg.database.username; + ensurePermissions = { + "DATABASE \"${cfg.database.database}\"" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + 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; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/convos.nix b/nixpkgs/nixos/modules/services/web-apps/convos.nix new file mode 100644 index 000000000000..cd9f9d885d69 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/convos.nix @@ -0,0 +1,72 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.convos; +in +{ + options.services.convos = { + enable = mkEnableOption (lib.mdDoc "Convos"); + listenPort = mkOption { + type = types.port; + default = 3000; + example = 8080; + description = lib.mdDoc "Port the web interface should listen on"; + }; + listenAddress = mkOption { + type = types.str; + default = "*"; + example = "127.0.0.1"; + description = lib.mdDoc "Address or host the web interface should listen on"; + }; + reverseProxy = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + 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 = ""; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/dex.nix b/nixpkgs/nixos/modules/services/web-apps/dex.nix new file mode 100644 index 000000000000..f69f1749aeb8 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/dex.nix @@ -0,0 +1,135 @@ +{ 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: if c ? secretFile then [ c.secretFile ] else []) (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)); +in +{ + options.services.dex = { + enable = mkEnableOption (lib.mdDoc "the OpenID Connect and OAuth2 identity provider"); + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc '' + Environment file (see `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 = lib.mdDoc '' + The available options can be found in + [the example configuration](https://github.com/dexidp/dex/blob/v${pkgs.dex-oidc.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.service"); + path = with pkgs; [ replace-secret ]; + serviceConfig = { + ExecStart = "${pkgs.dex-oidc}/bin/dex serve /run/dex/config.yaml"; + ExecStartPre = [ + "${pkgs.coreutils}/bin/install -m 600 ${configFile} /run/dex/config.yaml" + "+${startPreScript}" + ]; + + RuntimeDirectory = "dex"; + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + BindReadOnlyPaths = [ + "/nix/store" + "-/etc/dex" + "-/etc/hosts" + "-/etc/localtime" + "-/etc/nsswitch.conf" + "-/etc/resolv.conf" + "-/etc/ssl/certs/ca-certificates.crt" + ]; + BindPaths = optional (cfg.settings.storage.type == "postgres") "/var/run/postgresql"; + CapabilityBoundingSet = "CAP_NET_BIND_SERVICE"; + # 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; + # Would re-mount paths ignored by temporary root + #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" ]; + TemporaryFileSystem = "/:ro"; + # Does not work well with the temporary root + #UMask = "0066"; + } // optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + }; + + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/discourse.md b/nixpkgs/nixos/modules/services/web-apps/discourse.md new file mode 100644 index 000000000000..35180bea87d9 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/discourse.md @@ -0,0 +1,286 @@ +# 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: +``` +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: + +``` +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: + +``` +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: +``` +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: + +``` +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"; +}; +``` diff --git a/nixpkgs/nixos/modules/services/web-apps/discourse.nix b/nixpkgs/nixos/modules/services/web-apps/discourse.nix new file mode 100644 index 000000000000..f80eb6b4c7f0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/discourse.nix @@ -0,0 +1,1093 @@ +{ config, options, lib, pkgs, utils, ... }: + +let + json = pkgs.formats.json {}; + + cfg = config.services.discourse; + opt = options.services.discourse; + + # Keep in sync with https://github.com/discourse/discourse_docker/blob/main/image/base/slim.Dockerfile#L5 + upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13; + + postgresqlPackage = if config.services.postgresql.enable then + config.services.postgresql.package + else + pkgs.postgresql; + + postgresqlVersion = lib.getVersion postgresqlPackage; + + # We only want to create a database if we're actually going to connect to it. + databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null; + + tlsEnabled = cfg.enableACME + || cfg.sslCertificate != null + || cfg.sslCertificateKey != null; +in +{ + options = { + services.discourse = { + enable = lib.mkEnableOption (lib.mdDoc "Discourse, an open source discussion platform"); + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.discourse; + apply = p: p.override { + plugins = lib.unique (p.enabledPlugins ++ cfg.plugins); + }; + defaultText = lib.literalExpression "pkgs.discourse"; + description = lib.mdDoc '' + The discourse package to use. + ''; + }; + + hostname = lib.mkOption { + type = lib.types.str; + default = config.networking.fqdnOrHostName; + defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; + example = "discourse.example.com"; + description = lib.mdDoc '' + The hostname to serve Discourse on. + ''; + }; + + secretKeyBaseFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/secret_key_base"; + description = lib.mdDoc '' + The path to a file containing the + `secret_key_base` secret. + + Discourse uses `secret_key_base` to encrypt + the cookie store, which contains session data, and to digest + user auth tokens. + + Needs to be a 64 byte long string of hexadecimal + characters. You can generate one by running + + ``` + openssl rand -hex 64 >/path/to/secret_key_base_file + ``` + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + + sslCertificate = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/ssl.cert"; + description = lib.mdDoc '' + The path to the server SSL certificate. Set this to enable + SSL. + ''; + }; + + sslCertificateKey = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/ssl.key"; + description = lib.mdDoc '' + The path to the server SSL certificate key. Set this to + enable SSL. + ''; + }; + + enableACME = lib.mkOption { + type = lib.types.bool; + default = cfg.sslCertificate == null && cfg.sslCertificateKey == null; + defaultText = lib.literalMD '' + `true`, unless {option}`services.discourse.sslCertificate` + and {option}`services.discourse.sslCertificateKey` are set. + ''; + description = lib.mdDoc '' + Whether an ACME certificate should be used to secure + connections to the server. + ''; + }; + + backendSettings = lib.mkOption { + type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ])); + default = {}; + example = lib.literalExpression '' + { + 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"; + }; + ''; + description = lib.mdDoc '' + Additional settings to put in the + {file}`discourse.conf` file. + + Look in the + [discourse_defaults.conf](https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf) + file in the upstream distribution to find available options. + + Setting an option to `null` means + “define variable, but leave right-hand side empty”. + ''; + }; + + siteSettings = lib.mkOption { + type = json.type; + default = {}; + example = lib.literalExpression '' + { + 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; + }; + }; + ''; + description = lib.mdDoc '' + Discourse site settings. These are the settings that can be + changed from the UI. This only defines their default values: + they can still be overridden from the UI. + + Available settings can be found by looking in the + [site_settings.yml](https://github.com/discourse/discourse/blob/master/config/site_settings.yml) + file of the upstream distribution. To find a setting's path, + you only need to care about the first two levels; i.e. its + category and name. See the example. + + 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 to get a better picture of this: in the resulting + {file}`config/nixos_site_settings.json` file, + the `login.github_client_secret` key will + be set to the contents of the + {file}`/run/keys/discourse_github_client_secret` + file. + ''; + }; + + admin = { + skipCreate = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Do not create the admin account, instead rely on other + existing admin accounts. + ''; + }; + + email = lib.mkOption { + type = lib.types.str; + example = "admin@example.com"; + description = lib.mdDoc '' + The admin user email address. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + example = "admin"; + description = lib.mdDoc '' + The admin user username. + ''; + }; + + fullName = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc '' + The admin user's full name. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.path; + description = lib.mdDoc '' + A path to a file containing the admin user's password. + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + }; + + nginx.enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc '' + Whether an `nginx` virtual host should be + set up to serve Discourse. Only disable if you're planning + to use a different web server, which is not recommended. + ''; + }; + + database = { + pool = lib.mkOption { + type = lib.types.int; + default = 8; + description = lib.mdDoc '' + Database connection pool size. + ''; + }; + + host = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = lib.mdDoc '' + Discourse database hostname. `null` means + “prefer local unix socket connection”. + ''; + }; + + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = lib.mdDoc '' + File containing the Discourse database user password. + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc '' + 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 {option}`services.discourse.database.host` is customized. + ''; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "discourse"; + description = lib.mdDoc '' + Discourse database name. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + default = "discourse"; + description = lib.mdDoc '' + Discourse database user. + ''; + }; + + ignorePostgresqlVersion = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Whether to allow other versions of PostgreSQL than the + recommended one. Only effective when + {option}`services.discourse.database.createLocally` + is enabled. + ''; + }; + }; + + redis = { + host = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = lib.mdDoc '' + Redis server hostname. + ''; + }; + + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = lib.mdDoc '' + File containing the Redis password. + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + + dbNumber = lib.mkOption { + type = lib.types.int; + default = 0; + description = lib.mdDoc '' + Redis database number. + ''; + }; + + useSSL = lib.mkOption { + type = lib.types.bool; + default = cfg.redis.host != "localhost"; + defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"''; + description = lib.mdDoc '' + Connect to Redis with SSL. + ''; + }; + }; + + mail = { + notificationEmailAddress = lib.mkOption { + type = lib.types.str; + default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}"; + defaultText = lib.literalExpression '' + "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}" + ''; + description = lib.mdDoc '' + The `from:` email address used when + sending all essential system emails. The domain specified + here must have SPF, DKIM and reverse PTR records set + correctly for email to arrive. + ''; + }; + + contactEmailAddress = lib.mkOption { + type = lib.types.str; + default = ""; + description = lib.mdDoc '' + Email address of key contact responsible for this + site. Used for critical notifications, as well as on the + `/about` contact form for urgent matters. + ''; + }; + + outgoing = { + serverAddress = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = lib.mdDoc '' + The address of the SMTP server Discourse should use to + send email. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 25; + description = lib.mdDoc '' + The port of the SMTP server Discourse should use to + send email. + ''; + }; + + username = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = lib.mdDoc '' + The username of the SMTP server. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = lib.mdDoc '' + A file containing the password of the SMTP server account. + + This should be a string, not a nix path, since nix paths + are copied into the world-readable nix store. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + default = cfg.hostname; + defaultText = lib.literalExpression "config.${opt.hostname}"; + description = lib.mdDoc '' + HELO domain to use for outgoing mail. + ''; + }; + + authentication = lib.mkOption { + type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]); + default = null; + description = lib.mdDoc '' + Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html + ''; + }; + + enableStartTLSAuto = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc '' + Whether to try to use StartTLS. + ''; + }; + + opensslVerifyMode = lib.mkOption { + type = lib.types.str; + default = "peer"; + description = lib.mdDoc '' + How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html + ''; + }; + + forceTLS = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Force implicit TLS as per RFC 8314 3.3. + ''; + }; + }; + + incoming = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Whether to set up Postfix to receive incoming mail. + ''; + }; + + replyEmailAddress = lib.mkOption { + type = lib.types.str; + default = "%{reply_key}@${cfg.hostname}"; + defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"''; + description = lib.mdDoc '' + Template for reply by email incoming email address, for + example: %{reply_key}@reply.example.com or + replies+%{reply_key}@example.com + ''; + }; + + mailReceiverPackage = lib.mkOption { + type = lib.types.package; + default = pkgs.discourse-mail-receiver; + defaultText = lib.literalExpression "pkgs.discourse-mail-receiver"; + description = lib.mdDoc '' + The discourse-mail-receiver package to use. + ''; + }; + + apiKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = lib.mdDoc '' + A file containing the Discourse API key used to add + posts and messages from mail. If left at its default + value `null`, one will be automatically + generated. + + This should be a string, not a nix path, since nix paths + are copied into the world-readable nix store. + ''; + }; + }; + }; + + plugins = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = []; + example = lib.literalExpression '' + with config.services.discourse.package.plugins; [ + discourse-canned-replies + discourse-github + ]; + ''; + description = lib.mdDoc '' + Plugins to install as part of Discourse, expressed as a list of derivations. + ''; + }; + + sidekiqProcesses = lib.mkOption { + type = lib.types.int; + default = 1; + description = lib.mdDoc '' + How many Sidekiq processes should be spawned. + ''; + }; + + unicornTimeout = lib.mkOption { + type = lib.types.int; + default = 30; + description = lib.mdDoc '' + Time in seconds before a request to Unicorn times out. + + This can be raised if the system Discourse is running on is + too slow to handle many requests within 30 seconds. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null); + message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!"; + } + { + assertion = cfg.hostname != ""; + message = "Could not automatically determine hostname, set service.discourse.hostname manually."; + } + { + assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion); + message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. " + + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. " + + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL."; + } + ]; + + + # Default config values are from `config/discourse_defaults.conf` + # upstream. + services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) { + db_pool = cfg.database.pool; + db_timeout = 5000; + db_connect_timeout = 5; + db_socket = null; + db_host = cfg.database.host; + db_backup_host = null; + db_port = null; + db_backup_port = 5432; + db_name = cfg.database.name; + db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username; + db_password = cfg.database.passwordFile; + db_prepared_statements = false; + db_replica_host = null; + db_replica_port = null; + db_advisory_locks = true; + + inherit (cfg) hostname; + backup_hostname = null; + + smtp_address = cfg.mail.outgoing.serverAddress; + smtp_port = cfg.mail.outgoing.port; + smtp_domain = cfg.mail.outgoing.domain; + smtp_user_name = cfg.mail.outgoing.username; + smtp_password = cfg.mail.outgoing.passwordFile; + smtp_authentication = cfg.mail.outgoing.authentication; + smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto; + smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode; + smtp_force_tls = cfg.mail.outgoing.forceTLS; + + load_mini_profiler = true; + mini_profiler_snapshots_period = 0; + mini_profiler_snapshots_transport_url = null; + mini_profiler_snapshots_transport_auth_key = null; + + cdn_url = null; + cdn_origin_hostname = null; + developer_emails = null; + + redis_host = cfg.redis.host; + redis_port = 6379; + redis_replica_host = null; + redis_replica_port = 6379; + redis_db = cfg.redis.dbNumber; + redis_password = cfg.redis.passwordFile; + redis_skip_client_commands = false; + redis_use_ssl = cfg.redis.useSSL; + + message_bus_redis_enabled = false; + message_bus_redis_host = "localhost"; + message_bus_redis_port = 6379; + message_bus_redis_replica_host = null; + message_bus_redis_replica_port = 6379; + message_bus_redis_db = 0; + message_bus_redis_password = null; + message_bus_redis_skip_client_commands = false; + + enable_cors = false; + cors_origin = ""; + serve_static_assets = false; + sidekiq_workers = 5; + connection_reaper_age = 30; + connection_reaper_interval = 30; + relative_url_root = null; + message_bus_max_backlog_size = 100; + message_bus_clear_every = 50; + secret_key_base = cfg.secretKeyBaseFile; + fallback_assets_path = null; + + s3_bucket = null; + s3_region = null; + s3_access_key_id = null; + s3_secret_access_key = null; + s3_use_iam_profile = null; + s3_cdn_url = null; + s3_endpoint = null; + s3_http_continue_timeout = null; + s3_install_cors_rule = null; + s3_asset_cdn_url = null; + + max_user_api_reqs_per_minute = 20; + max_user_api_reqs_per_day = 2880; + max_admin_api_reqs_per_minute = 60; + max_reqs_per_ip_per_minute = 200; + max_reqs_per_ip_per_10_seconds = 50; + max_asset_reqs_per_ip_per_10_seconds = 200; + max_reqs_per_ip_mode = "block"; + max_reqs_rate_limit_on_private = false; + skip_per_ip_rate_limit_trust_level = 1; + force_anonymous_min_queue_seconds = 1; + force_anonymous_min_per_10_seconds = 3; + background_requests_max_queue_length = 0.5; + reject_message_bus_queue_seconds = 0.1; + disable_search_queue_threshold = 1; + max_old_rebakes_per_15_minutes = 300; + max_logster_logs = 1000; + refresh_maxmind_db_during_precompile_days = 2; + maxmind_backup_path = null; + maxmind_license_key = null; + enable_performance_http_headers = false; + enable_js_error_reporting = true; + mini_scheduler_workers = 5; + compress_anon_cache = false; + anon_cache_store_threshold = 2; + allowed_theme_repos = null; + enable_email_sync_demon = false; + max_digests_enqueued_per_30_mins_per_site = 10000; + cluster_name = null; + multisite_config_path = "config/multisite.yml"; + enable_long_polling = null; + long_polling_interval = null; + preload_link_header = false; + redirect_avatar_requests = false; + pg_force_readonly_mode = false; + dns_query_timeout_secs = null; + regex_timeout_seconds = 2; + allow_impersonation = true; + }; + + services.redis.servers.discourse = + lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) { + enable = true; + bind = cfg.redis.host; + port = cfg.backendSettings.redis_port; + }; + + services.postgresql = lib.mkIf databaseActuallyCreateLocally { + enable = true; + ensureUsers = [{ name = "discourse"; }]; + }; + + # The postgresql module doesn't currently support concepts like + # objects owners and extensions; for now we tack on what's needed + # here. + systemd.services.discourse-postgresql = + let + pgsql = config.services.postgresql; + in + lib.mkIf databaseActuallyCreateLocally { + after = [ "postgresql.service" ]; + bindsTo = [ "postgresql.service" ]; + wantedBy = [ "discourse.service" ]; + partOf = [ "discourse.service" ]; + path = [ + pgsql.package + ]; + script = '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"' + psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm" + psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore" + ''; + + serviceConfig = { + User = pgsql.superUser; + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + + systemd.services.discourse = { + wantedBy = [ "multi-user.target" ]; + after = [ + "redis-discourse.service" + "postgresql.service" + "discourse-postgresql.service" + ]; + bindsTo = [ + "redis-discourse.service" + ] ++ lib.optionals (cfg.database.host == null) [ + "postgresql.service" + "discourse-postgresql.service" + ]; + path = cfg.package.runtimeDeps ++ [ + postgresqlPackage + pkgs.replace-secret + cfg.package.rake + ]; + environment = cfg.package.runtimeEnv // { + UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout; + UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses; + MALLOC_ARENA_MAX = "2"; + }; + + preStart = + let + discourseKeyValue = 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 null == v then "" + else if isFloat v then lib.strings.floatToString v + else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; + }; + }; + + discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings); + + mkSecretReplacement = file: + lib.optionalString (file != null) '' + replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf + ''; + + mkAdmin = '' + export ADMIN_EMAIL="${cfg.admin.email}" + export ADMIN_NAME="${cfg.admin.fullName}" + export ADMIN_USERNAME="${cfg.admin.username}" + ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})" + export ADMIN_PASSWORD + discourse-rake admin:create_noninteractively + ''; + + in '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + umask u=rwx,g=rx,o= + + rm -rf /var/lib/discourse/tmp/* + + cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/ + cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/ + ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads + ln -sf /var/lib/discourse/backups /run/discourse/public/backups + + ( + umask u=rwx,g=,o= + + ${utils.genJqSecretsReplacementSnippet + cfg.siteSettings + "/run/discourse/config/nixos_site_settings.json" + } + install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf + ${mkSecretReplacement cfg.database.passwordFile} + ${mkSecretReplacement cfg.mail.outgoing.passwordFile} + ${mkSecretReplacement cfg.redis.passwordFile} + ${mkSecretReplacement cfg.secretKeyBaseFile} + chmod 0400 /run/discourse/config/discourse.conf + ) + + discourse-rake db:migrate >>/var/log/discourse/db_migration.log + chmod -R u+w /var/lib/discourse/tmp/ + + ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin} + + discourse-rake themes:update + discourse-rake uploads:regenerate_missing_optimized + ''; + + serviceConfig = { + Type = "simple"; + User = "discourse"; + Group = "discourse"; + RuntimeDirectory = map (p: "discourse/" + p) [ + "config" + "home" + "assets/javascripts/plugins" + "public" + "sockets" + ]; + RuntimeDirectoryMode = "0750"; + StateDirectory = map (p: "discourse/" + p) [ + "uploads" + "backups" + "tmp" + ]; + StateDirectoryMode = "0750"; + LogsDirectory = "discourse"; + TimeoutSec = "infinity"; + Restart = "on-failure"; + WorkingDirectory = "${cfg.package}/share/discourse"; + + RemoveIPC = true; + PrivateTmp = true; + NoNewPrivileges = true; + RestrictSUIDSGID = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + + ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb"; + }; + }; + + services.nginx = lib.mkIf cfg.nginx.enable { + enable = true; + + recommendedTlsSettings = true; + recommendedOptimisation = true; + recommendedBrotliSettings = true; + recommendedGzipSettings = true; + recommendedProxySettings = true; + + upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {}; + + appendHttpConfig = '' + # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week) + # levels means it is a 2 deep hierarchy cause we can have lots of files + # max_size limits the size of the cache + proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m; + + # see: https://meta.discourse.org/t/x/74060 + proxy_buffer_size 8k; + ''; + + virtualHosts.${cfg.hostname} = { + inherit (cfg) sslCertificate sslCertificateKey enableACME; + forceSSL = lib.mkDefault tlsEnabled; + + root = "${cfg.package}/share/discourse/public"; + + locations = + let + proxy = { extraConfig ? "" }: { + proxyPass = "http://discourse"; + extraConfig = extraConfig + '' + proxy_set_header X-Request-Start "t=''${msec}"; + ''; + }; + cache = time: '' + expires ${time}; + add_header Cache-Control public,immutable; + ''; + cache_1y = cache "1y"; + cache_1d = cache "1d"; + in + { + "/".tryFiles = "$uri @discourse"; + "@discourse" = proxy {}; + "^~ /backups/".extraConfig = '' + internal; + ''; + "/favicon.ico" = { + return = "204"; + extraConfig = '' + access_log off; + log_not_found off; + ''; + }; + "~ ^/uploads/short-url/" = proxy {}; + "~ ^/secure-media-uploads/" = proxy {}; + "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + '' + add_header Access-Control-Allow-Origin *; + ''; + "/srv/status" = proxy { + extraConfig = '' + access_log off; + log_not_found off; + ''; + }; + "~ ^/javascripts/".extraConfig = cache_1d; + "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + '' + # asset pipeline enables this + brotli_static on; + gzip_static on; + ''; + "~ ^/plugins/".extraConfig = cache_1y; + "~ /images/emoji/".extraConfig = cache_1y; + "~ ^/uploads/" = proxy { + extraConfig = cache_1y + '' + proxy_set_header X-Sendfile-Type X-Accel-Redirect; + proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; + + # custom CSS + location ~ /stylesheet-cache/ { + try_files $uri =404; + } + # this allows us to bypass rails + location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ { + try_files $uri =404; + } + # SVG needs an extra header attached + location ~* \.(svg)$ { + } + # thumbnails & optimized images + location ~ /_?optimized/ { + try_files $uri =404; + } + ''; + }; + "~ ^/admin/backups/" = proxy { + extraConfig = '' + proxy_set_header X-Sendfile-Type X-Accel-Redirect; + proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; + ''; + }; + "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy { + extraConfig = '' + # if Set-Cookie is in the response nothing gets cached + # this is double bad cause we are not passing last modified in + proxy_ignore_headers "Set-Cookie"; + proxy_hide_header "Set-Cookie"; + proxy_hide_header "X-Discourse-Username"; + proxy_hide_header "X-Runtime"; + + # note x-accel-redirect can not be used with proxy_cache + proxy_cache discourse; + proxy_cache_key "$scheme,$host,$request_uri"; + proxy_cache_valid 200 301 302 7d; + ''; + }; + "/message-bus/" = proxy { + extraConfig = '' + proxy_http_version 1.1; + proxy_buffering off; + ''; + }; + "/downloads/".extraConfig = '' + internal; + alias ${cfg.package}/share/discourse/public/; + ''; + }; + }; + }; + + systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable ( + let + mail-receiver-environment = { + MAIL_DOMAIN = cfg.hostname; + DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}"; + DISCOURSE_API_KEY = "@api-key@"; + DISCOURSE_API_USERNAME = "system"; + }; + mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment; + in + { + before = [ "postfix.service" ]; + after = [ "discourse.service" ]; + wantedBy = [ "discourse.service" ]; + partOf = [ "discourse.service" ]; + path = [ + cfg.package.rake + pkgs.jq + ]; + preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then + discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key + fi + ''; + script = + let + apiKeyPath = + if cfg.mail.incoming.apiKeyFile == null then + "/var/lib/discourse-mail-receiver/api_key" + else + cfg.mail.incoming.apiKeyFile; + in '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + api_key=$(<'${apiKeyPath}') + export api_key + + jq <${mail-receiver-json} \ + '.DISCOURSE_API_KEY = $ENV.api_key' \ + >'/run/discourse-mail-receiver/mail-receiver-environment.json' + ''; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RuntimeDirectory = "discourse-mail-receiver"; + RuntimeDirectoryMode = "0700"; + StateDirectory = "discourse-mail-receiver"; + User = "discourse"; + Group = "discourse"; + }; + }); + + services.discourse.siteSettings = { + required = { + notification_email = cfg.mail.notificationEmailAddress; + contact_email = cfg.mail.contactEmailAddress; + }; + security.force_https = tlsEnabled; + email = { + manual_polling_enabled = cfg.mail.incoming.enable; + reply_by_email_enabled = cfg.mail.incoming.enable; + reply_by_email_address = cfg.mail.incoming.replyEmailAddress; + }; + }; + + services.postfix = lib.mkIf cfg.mail.incoming.enable { + enable = true; + sslCert = lib.optionalString (cfg.sslCertificate != null) cfg.sslCertificate; + sslKey = lib.optionalString (cfg.sslCertificateKey != null) cfg.sslCertificateKey; + + origin = cfg.hostname; + relayDomains = [ cfg.hostname ]; + config = { + smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy"; + append_dot_mydomain = lib.mkDefault false; + compatibility_level = "2"; + smtputf8_enable = false; + smtpd_banner = lib.mkDefault "ESMTP server"; + myhostname = lib.mkDefault cfg.hostname; + mydestination = lib.mkDefault "localhost"; + }; + transport = '' + ${cfg.hostname} discourse-mail-receiver: + ''; + masterConfig = { + "discourse-mail-receiver" = { + type = "unix"; + privileged = true; + chroot = false; + command = "pipe"; + args = [ + "user=discourse" + "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail" + "\${recipient}" + ]; + }; + "discourse-policy" = { + type = "unix"; + privileged = true; + chroot = false; + command = "spawn"; + args = [ + "user=discourse" + "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection" + ]; + }; + }; + }; + + users.users = { + discourse = { + group = "discourse"; + isSystemUser = true; + }; + } // (lib.optionalAttrs cfg.nginx.enable { + ${config.services.nginx.user}.extraGroups = [ "discourse" ]; + }); + + users.groups = { + discourse = {}; + }; + + environment.systemPackages = [ + cfg.package.rake + ]; + }; + + meta.doc = ./discourse.md; + meta.maintainers = [ lib.maintainers.talyz ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/documize.nix b/nixpkgs/nixos/modules/services/web-apps/documize.nix new file mode 100644 index 000000000000..f70da0829f44 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/documize.nix @@ -0,0 +1,137 @@ +{ 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 (lib.mdDoc "Documize Wiki"); + + stateDirectoryName = mkOption { + type = types.str; + default = "documize"; + description = lib.mdDoc '' + The name of the directory below {file}`/var/lib/private` + where documize runs in and stores, for example, backups. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.documize-community; + defaultText = literalExpression "pkgs.documize-community"; + description = lib.mdDoc '' + Which package to use for documize. + ''; + }; + + salt = mkOption { + type = types.nullOr types.str; + default = null; + example = "3edIYV6c8B28b19fh"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + The {file}`cert.pem` file used for https. + ''; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The {file}`key.pem` file used for https. + ''; + }; + + port = mkOption { + type = types.port; + default = 5001; + description = lib.mdDoc '' + The http/https port number. + ''; + }; + + forcesslport = mkOption { + type = types.nullOr types.port; + default = null; + description = lib.mdDoc '' + Redirect given http port number to TLS. + ''; + }; + + offline = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Specify the database provider: `mysql`, `percona`, `mariadb`, `postgresql`, `sqlserver` + ''; + }; + + db = mkOption { + type = types.str; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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}"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/dokuwiki.nix b/nixpkgs/nixos/modules/services/web-apps/dokuwiki.nix new file mode 100644 index 000000000000..9e685c127da7 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/dokuwiki.nix @@ -0,0 +1,602 @@ +{ 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; + }; + + 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.php81}/bin/php --syntax-check $target"; + }; + + mkPhpValue = v: let + isHasAttr = s: isAttrs v && hasAttr s v; + in + if isString v then escapeShellArg v + # NOTE: If any value contains a , (comma) this will not get escaped + else if isList v && any lib.strings.isCoercibleToString v then escapeShellArg (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(${lib.escapeShellArg 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: "[${escapeShellArg 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 = lib.mdDoc "Page or namespace to restrict"; + example = "start"; + }; + + actor = mkOption { + type = types.str; + description = lib.mdDoc "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 = lib.mdDoc '' + Permission level to restrict the actor(s) to. + See <https://www.dokuwiki.org/acl#background_info> for explanation + ''; + example = "read"; + }; + }; + }; + + # The current implementations of `doRename`, `mkRenamedOptionModule` do not provide the full options path when used with submodules. + # They would only show `settings.useacl' instead of `services.dokuwiki.sites."site1.local".settings.useacl' + # The partial re-implementation of these functions is done to help users in debugging by showing the full path. + mkRenamed = from: to: { config, options, name, ... }: let + pathPrefix = [ "services" "dokuwiki" "sites" name ]; + fromPath = pathPrefix ++ from; + fromOpt = getAttrFromPath from options; + toOp = getAttrsFromPath to config; + toPath = pathPrefix ++ to; + in { + options = setAttrByPath from (mkOption { + visible = false; + description = lib.mdDoc "Alias of {option}${showOption toPath}"; + apply = x: builtins.trace "Obsolete option `${showOption fromPath}' is used. It was renamed to ${showOption toPath}" toOp; + }); + config = mkMerge [ + { + warnings = optional fromOpt.isDefined + "The option `${showOption fromPath}' defined in ${showFiles fromOpt.files} has been renamed to `${showOption toPath}'."; + } + (lib.modules.mkAliasAndWrapDefsWithPriority (setAttrByPath to) fromOpt) + ]; + }; + + siteOpts = { options, config, lib, name, ... }: + { + imports = [ + (mkRenamed [ "aclUse" ] [ "settings" "useacl" ]) + (mkRenamed [ "superUser" ] [ "settings" "superuser" ]) + (mkRenamed [ "disableActions" ] [ "settings" "disableactions" ]) + ({ config, options, ... }: let + showPath = suffix: lib.options.showOption ([ "services" "dokuwiki" "sites" name ] ++ suffix); + replaceExtraConfig = "Please use `${showPath ["settings"]}' to pass structured settings instead."; + ecOpt = options.extraConfig; + ecPath = showPath [ "extraConfig" ]; + in { + options.extraConfig = mkOption { + visible = false; + apply = x: throw "The option ${ecPath} can no longer be used since it's been removed.\n${replaceExtraConfig}"; + }; + config.assertions = [ + { + assertion = !ecOpt.isDefined; + message = "The option definition `${ecPath}' in ${showFiles ecOpt.files} no longer has any effect; please remove it.\n${replaceExtraConfig}"; + } + { + assertion = config.mergedConfig.useacl -> (config.acl != null || config.aclFile != null); + message = "Either ${showPath [ "acl" ]} or ${showPath [ "aclFile" ]} is mandatory if ${showPath [ "settings" "useacl" ]} is true"; + } + { + assertion = config.usersFile != null -> config.mergedConfig.useacl != false; + message = "${showPath [ "settings" "useacl" ]} is required when ${showPath [ "usersFile" ]} is set (Currently defined as `${config.usersFile}' in ${showFiles options.usersFile.files})."; + } + ]; + }) + ]; + + options = { + enable = mkEnableOption (lib.mdDoc "DokuWiki web application"); + + package = mkOption { + type = types.package; + default = pkgs.dokuwiki; + defaultText = literalExpression "pkgs.dokuwiki"; + description = lib.mdDoc "Which DokuWiki package to use."; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/dokuwiki/${name}/data"; + description = lib.mdDoc "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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Options for the DokuWiki PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + phpPackage = mkOption { + type = types.package; + relatedPackages = [ "php80" "php81" ]; + default = pkgs.php81; + defaultText = "pkgs.php81"; + description = lib.mdDoc '' + PHP package to use for this dokuwiki site. + ''; + }; + + phpOptions = mkOption { + type = types.attrsOf types.str; + default = {}; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Read only representation of the final configuration. + ''; + }; + + # Required for the mkRenamedOptionModule + # TODO: Remove me once https://github.com/NixOS/nixpkgs/issues/96006 is fixed + # or we don't have any more notes about the removal of extraConfig, ... + warnings = mkOption { + type = types.listOf types.unspecified; + default = [ ]; + visible = false; + internal = true; + }; + 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 = lib.mdDoc "Specification of one or more DokuWiki sites to serve"; + }; + + webserver = mkOption { + type = types.enum [ "nginx" "caddy" ]; + default = "nginx"; + description = lib.mdDoc '' + 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 [{ + + warnings = flatten (mapAttrsToList (_: cfg: cfg.warnings) eachSite); + + 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 "http://${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 + ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/dolibarr.nix b/nixpkgs/nixos/modules/services/web-apps/dolibarr.nix new file mode 100644 index 000000000000..453229c130c2 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/dolibarr.nix @@ -0,0 +1,323 @@ +{ config, pkgs, lib, ... }: +let + inherit (lib) any boolToString concatStringsSep isBool isString mapAttrsToList mkDefault mkEnableOption mkIf mkMerge mkOption optionalAttrs types; + + package = pkgs.dolibarr.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 (lib.mdDoc "dolibarr"); + + domain = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc '' + Domain name of your server. + ''; + }; + + user = mkOption { + type = types.str; + default = "dolibarr"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + State and configuration directory dolibarr will use. + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + name = mkOption { + type = types.str; + default = "dolibarr"; + description = lib.mdDoc "Database name."; + }; + user = mkOption { + type = types.str; + default = "dolibarr"; + description = lib.mdDoc "Database username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/dolibarr-dbpassword"; + description = lib.mdDoc "Database password file."; + }; + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + settings = mkOption { + type = with types; (attrsOf (oneOf [ bool int str ])); + default = { }; + description = lib.mdDoc "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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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.php.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 = '' + # 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 + ${pkgs.php}/bin/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 644 ${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 ]; + }) +]); +} diff --git a/nixpkgs/nixos/modules/services/web-apps/engelsystem.nix b/nixpkgs/nixos/modules/services/web-apps/engelsystem.nix new file mode 100644 index 000000000000..f1d71f174471 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/engelsystem.nix @@ -0,0 +1,186 @@ +{ config, lib, pkgs, utils, ... }: + +let + inherit (lib) mkDefault mkEnableOption mkIf mkOption types literalExpression; + cfg = config.services.engelsystem; +in { + options = { + services.engelsystem = { + enable = mkOption { + default = false; + example = true; + description = lib.mdDoc '' + Whether to enable engelsystem, an online tool for coordinating volunteers + and shifts on large events. + ''; + type = lib.types.bool; + }; + + domain = mkOption { + type = types.str; + example = "engelsystem.example.com"; + description = lib.mdDoc "Domain to serve on."; + }; + + package = mkOption { + type = types.package; + description = lib.mdDoc "Engelsystem package used for the service."; + default = pkgs.engelsystem; + defaultText = literalExpression "pkgs.engelsystem"; + }; + + createDatabase = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to create a local database automatically. + This will override every database setting in {option}`services.engelsystem.config`. + ''; + }; + }; + + services.engelsystem.config = 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 = lib.mdDoc '' + 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/engelsystem/public"; + extraConfig = '' + index index.php; + try_files $uri $uri/ /index.php?$args; + autoindex off; + ''; + }; + "~ \\.php$" = { + root = "${cfg.package}/share/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.config "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 = '' + ${cfg.package}/bin/migrate + ''; + 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 = { }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/ethercalc.nix b/nixpkgs/nixos/modules/services/web-apps/ethercalc.nix new file mode 100644 index 000000000000..a5be86a34aa6 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/ethercalc.nix @@ -0,0 +1,62 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.ethercalc; +in { + options = { + services.ethercalc = { + enable = mkOption { + default = false; + type = types.bool; + description = lib.mdDoc '' + 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 = mkOption { + default = pkgs.ethercalc; + defaultText = literalExpression "pkgs.ethercalc"; + type = types.package; + description = lib.mdDoc "Ethercalc package to use."; + }; + + host = mkOption { + type = types.str; + default = "0.0.0.0"; + description = lib.mdDoc "Address to listen on (use 0.0.0.0 to allow access from any address)."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = lib.mdDoc "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"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/fluidd.nix b/nixpkgs/nixos/modules/services/web-apps/fluidd.nix new file mode 100644 index 000000000000..d4b86b9dfb39 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/fluidd.nix @@ -0,0 +1,66 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.fluidd; + moonraker = config.services.moonraker; +in +{ + options.services.fluidd = { + enable = mkEnableOption (lib.mdDoc "Fluidd, a Klipper web interface for managing your 3d printer"); + + package = mkOption { + type = types.package; + description = lib.mdDoc "Fluidd package to be used in the module"; + default = pkgs.fluidd; + defaultText = literalExpression "pkgs.fluidd"; + }; + + hostName = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "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 = lib.mdDoc "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"; + }; + }; + } + ]; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/freshrss.nix b/nixpkgs/nixos/modules/services/web-apps/freshrss.nix new file mode 100644 index 000000000000..89e29f7ccb51 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/freshrss.nix @@ -0,0 +1,287 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.freshrss; + + poolName = "freshrss"; +in +{ + meta.maintainers = with maintainers; [ etu stunkymonkey ]; + + options.services.freshrss = { + enable = mkEnableOption (mdDoc "FreshRSS feed reader"); + + package = mkOption { + type = types.package; + default = pkgs.freshrss; + defaultText = lib.literalExpression "pkgs.freshrss"; + description = mdDoc "Which FreshRSS package to use."; + }; + + defaultUser = mkOption { + type = types.str; + default = "admin"; + description = mdDoc "Default username for FreshRSS."; + example = "eva"; + }; + + passwordFile = mkOption { + type = types.path; + description = mdDoc "Password for the defaultUser for FreshRSS."; + example = "/run/secrets/freshrss"; + }; + + baseUrl = mkOption { + type = types.str; + description = mdDoc "Default URL for FreshRSS."; + example = "https://freshrss.example.com"; + }; + + language = mkOption { + type = types.str; + default = "en"; + description = mdDoc "Default language for FreshRSS."; + example = "de"; + }; + + database = { + type = mkOption { + type = types.enum [ "sqlite" "pgsql" "mysql" ]; + default = "sqlite"; + description = mdDoc "Database type."; + example = "pgsql"; + }; + + host = mkOption { + type = types.nullOr types.str; + default = "localhost"; + description = mdDoc "Database host for FreshRSS."; + }; + + port = mkOption { + type = types.nullOr types.port; + default = null; + description = mdDoc "Database port for FreshRSS."; + example = 3306; + }; + + user = mkOption { + type = types.nullOr types.str; + default = "freshrss"; + description = mdDoc "Database user for FreshRSS."; + }; + + passFile = mkOption { + type = types.nullOr types.path; + default = null; + description = mdDoc "Database password file for FreshRSS."; + example = "/run/secrets/freshrss"; + }; + + name = mkOption { + type = types.nullOr types.str; + default = "freshrss"; + description = mdDoc "Database name for FreshRSS."; + }; + + tableprefix = mkOption { + type = types.nullOr types.str; + default = null; + description = mdDoc "Database table prefix for FreshRSS."; + example = "freshrss"; + }; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/freshrss"; + description = mdDoc "Default data folder for FreshRSS."; + example = "/mnt/freshrss"; + }; + + virtualHost = mkOption { + type = types.nullOr types.str; + default = "freshrss"; + description = mdDoc '' + Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost. + ''; + }; + + pool = mkOption { + type = types.str; + default = poolName; + description = mdDoc '' + Name of the phpfpm pool to use and setup. If not specified, a pool will be created + with default values. + ''; + }; + + user = mkOption { + type = types.str; + default = "freshrss"; + description = lib.mdDoc "User under which Freshrss runs."; + }; + }; + + config = + let + defaultServiceConfig = { + ReadWritePaths = "${cfg.dataDir}"; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + 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 { + # Set up a Nginx virtual host. + services.nginx = mkIf (cfg.virtualHost != null) { + 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 == poolName) { + ${poolName} = { + user = "freshrss"; + settings = { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "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 = { + FRESHRSS_DATA_PATH = "${cfg.dataDir}"; + }; + }; + }; + + users.users."${cfg.user}" = { + description = "FreshRSS service user"; + isSystemUser = true; + group = "${cfg.user}"; + home = cfg.dataDir; + }; + users.groups."${cfg.user}" = { }; + + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}' - ${cfg.user} ${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" = ''"form"''; + "--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}"''; + ${if cfg.database.host != null && cfg.database.port != null then "--db-host" else null} = ''"${cfg.database.host}:${toString cfg.database.port}"''; + }); + in + { + description = "Set up the state directory for FreshRSS before use"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = defaultServiceConfig //{ + Type = "oneshot"; + User = "freshrss"; + Group = "freshrss"; + StateDirectory = "freshrss"; + WorkingDirectory = cfg.package; + }; + environment = { + FRESHRSS_DATA_PATH = cfg.dataDir; + }; + + script = '' + # do installation or reconfigure + if test -f ${cfg.dataDir}/config.php; then + # reconfigure with settings + ./cli/reconfigure.php ${settingsFlags} + ./cli/update-user.php --user ${cfg.defaultUser} --password "$(cat ${cfg.passwordFile})" + else + # check correct folders in data folder + ./cli/prepare.php + # install with settings + ./cli/do-install.php ${settingsFlags} + ./cli/create-user.php --user ${cfg.defaultUser} --password "$(cat ${cfg.passwordFile})" + fi + ''; + }; + + systemd.services.freshrss-updater = { + description = "FreshRSS feed updater"; + after = [ "freshrss-config.service" ]; + wantedBy = [ "multi-user.target" ]; + startAt = "*:0/5"; + environment = { + FRESHRSS_DATA_PATH = cfg.dataDir; + }; + serviceConfig = defaultServiceConfig //{ + ExecStart = "${cfg.package}/app/actualize_script.php"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/galene.nix b/nixpkgs/nixos/modules/services/web-apps/galene.nix new file mode 100644 index 000000000000..747b85f94c65 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/galene.nix @@ -0,0 +1,214 @@ +{ 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 (lib.mdDoc "Galene Service"); + + stateDir = mkOption { + default = defaultstateDir; + type = types.str; + description = lib.mdDoc '' + 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 = lib.mdDoc "User account under which galene runs."; + }; + + group = mkOption { + type = types.str; + default = "galene"; + description = lib.mdDoc "Group under which galene runs."; + }; + + insecure = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + 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.str; + default = null; + example = "/path/to/your/cert.pem"; + description = lib.mdDoc '' + 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.str; + default = null; + example = "/path/to/your/key.pem"; + description = lib.mdDoc '' + 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 = lib.mdDoc "HTTP listen address for galene."; + }; + + httpPort = mkOption { + type = types.port; + default = 8443; + description = lib.mdDoc "HTTP listen port."; + }; + + staticDir = mkOption { + type = types.str; + default = "${cfg.package.static}/static"; + defaultText = literalExpression ''"''${package.static}/static"''; + example = "/var/lib/galene/static"; + description = lib.mdDoc "Web server directory."; + }; + + recordingsDir = mkOption { + type = types.str; + default = defaultrecordingsDir; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/recordings"''; + example = "/var/lib/galene/recordings"; + description = lib.mdDoc "Recordings directory."; + }; + + dataDir = mkOption { + type = types.str; + default = defaultdataDir; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/data"''; + example = "/var/lib/galene/data"; + description = lib.mdDoc "Data directory."; + }; + + groupsDir = mkOption { + type = types.str; + default = defaultgroupsDir; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/groups"''; + example = "/var/lib/galene/groups"; + description = lib.mdDoc "Web server directory."; + }; + + package = mkOption { + default = pkgs.galene; + defaultText = literalExpression "pkgs.galene"; + type = types.package; + description = lib.mdDoc '' + Package for running Galene. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.insecure || (cfg.certFile != null && cfg.keyFile != null); + message = '' + Galene needs both certFile and keyFile defined for encryption, or + the insecure flag. + ''; + } + ]; + + systemd.services.galene = { + description = "galene"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + preStart = '' + ${optionalString (cfg.insecure != true) '' + 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"} \ + -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" ]; + 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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/gerrit.nix b/nixpkgs/nixos/modules/services/web-apps/gerrit.nix new file mode 100644 index 000000000000..ab2eeea09bdc --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/gerrit.nix @@ -0,0 +1,242 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.gerrit; + + # NixOS option type for git-like configs + gitIniType = with types; + let + primitiveType = either str (either bool int); + multipleType = either primitiveType (listOf primitiveType); + sectionType = lazyAttrsOf multipleType; + supersectionType = lazyAttrsOf (either multipleType sectionType); + in 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 = mkEnableOption (lib.mdDoc "Gerrit service"); + + package = mkOption { + type = types.package; + default = pkgs.gerrit; + defaultText = literalExpression "pkgs.gerrit"; + description = lib.mdDoc "Gerrit package to use"; + }; + + jvmPackage = mkOption { + type = types.package; + default = pkgs.jre_headless; + defaultText = literalExpression "pkgs.jre_headless"; + description = lib.mdDoc "Java Runtime Environment package to use"; + }; + + jvmOpts = mkOption { + type = types.listOf 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 = lib.mdDoc "A list of JVM options to start gerrit with."; + }; + + jvmHeapLimit = mkOption { + type = types.str; + default = "1024m"; + description = lib.mdDoc '' + How much memory to allocate to the JVM heap + ''; + }; + + listenAddress = mkOption { + type = types.str; + default = "[::]:8080"; + description = lib.mdDoc '' + `hostname:port` to listen for HTTP traffic. + + This is bound using the systemd socket activation. + ''; + }; + + settings = mkOption { + type = gitIniType; + default = {}; + description = lib.mdDoc '' + Gerrit configuration. This will be generated to the + `etc/gerrit.config` file. + ''; + }; + + replicationSettings = mkOption { + type = gitIniType; + default = {}; + description = lib.mdDoc '' + Replication configuration. This will be generated to the + `etc/replication.config` file. + ''; + }; + + plugins = mkOption { + type = types.listOf types.package; + default = []; + description = lib.mdDoc '' + 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 = mkOption { + type = types.listOf (types.enum cfg.package.passthru.plugins); + default = []; + description = lib.mdDoc '' + List of builtins plugins to install. Those are shipped in the + `gerrit.war` file. + ''; + }; + + serverId = mkOption { + type = types.str; + description = lib.mdDoc '' + Set a UUID that uniquely identifies the server. + + This can be generated with + `nix-shell -p util-linux --run uuidgen`. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = cfg.replicationSettings != {} -> 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 = { + CacheDirectory = "gerrit"; + DynamicUser = true; + ExecStart = "${gerrit-cli}/bin/gerrit daemon --console-log"; + LimitNOFILE = 4096; + StandardInput = "socket"; + StandardOutput = "journal"; + StateDirectory = "gerrit"; + WorkingDirectory = "%S/gerrit"; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ edef zimbatm ]; + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/gotify-server.nix b/nixpkgs/nixos/modules/services/web-apps/gotify-server.nix new file mode 100644 index 000000000000..8db3a8ef3e81 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/gotify-server.nix @@ -0,0 +1,49 @@ +{ pkgs, lib, config, ... }: + +with lib; + +let + cfg = config.services.gotify; +in { + options = { + services.gotify = { + enable = mkEnableOption (lib.mdDoc "Gotify webserver"); + + port = mkOption { + type = types.port; + description = lib.mdDoc '' + Port the server listens to. + ''; + }; + + stateDirectoryName = mkOption { + type = types.str; + default = "gotify-server"; + description = lib.mdDoc '' + The name of the directory below {file}`/var/lib` where + gotify stores its runtime data. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.gotify-server = { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + description = "Simple server for sending and receiving messages"; + + environment = { + GOTIFY_SERVER_PORT = toString cfg.port; + }; + + serviceConfig = { + WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}"; + StateDirectory = cfg.stateDirectoryName; + Restart = "always"; + DynamicUser = "yes"; + ExecStart = "${pkgs.gotify-server}/bin/server"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/grocy.md b/nixpkgs/nixos/modules/services/web-apps/grocy.md new file mode 100644 index 000000000000..62aad4b103df --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/grocy.md @@ -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: +``` +{ 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: +``` +{ 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: +``` +{ lib, ... }: +{ + environment.etc."grocy/config.php".text = lib.mkAfter '' + // Arbitrary PHP code in grocy's configuration file + ''; +} +``` diff --git a/nixpkgs/nixos/modules/services/web-apps/grocy.nix b/nixpkgs/nixos/modules/services/web-apps/grocy.nix new file mode 100644 index 000000000000..688367cafaf5 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/grocy.nix @@ -0,0 +1,174 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.grocy; +in { + options.services.grocy = { + enable = mkEnableOption (lib.mdDoc "grocy"); + + hostName = mkOption { + type = types.str; + description = lib.mdDoc '' + FQDN for the grocy instance. + ''; + }; + + nginx.enableSSL = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Options for grocy's PHPFPM pool. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/grocy"; + description = lib.mdDoc '' + Home directory of the `grocy` user which contains + the application's state. + ''; + }; + + settings = { + currency = mkOption { + type = types.str; + default = "USD"; + example = "EUR"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Display language of the frontend. + ''; + }; + + calendar = { + showWeekNumber = mkOption { + default = true; + type = types.bool; + description = lib.mdDoc '' + Show the number of the weeks in the calendar views. + ''; + }; + firstDayOfWeek = mkOption { + default = null; + type = types.nullOr (types.enum (range 0 6)); + description = lib.mdDoc '' + 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.0 is the only version which is supported/tested by upstream: + # https://github.com/grocy/grocy/blob/v3.3.0/README.md#how-to-install + # Compatibility with PHP 8.1 is available on their development branch: + # https://github.com/grocy/grocy/commit/38a4ad8ec480c29a1bff057b3482fd103b036848 + phpPackage = pkgs.php81; + + 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"; + }; + }; + + services.nginx = { + enable = true; + virtualHosts."${cfg.hostName}" = mkMerge [ + { root = "${pkgs.grocy}/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-XSS-Protection "1; mode=block"; + 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; [ ma27 ]; + doc = ./grocy.md; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/healthchecks.nix b/nixpkgs/nixos/modules/services/web-apps/healthchecks.nix new file mode 100644 index 000000000000..b3fdb681e2f3 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/healthchecks.nix @@ -0,0 +1,249 @@ +{ config, lib, pkgs, buildEnv, ... }: + +with lib; + +let + defaultUser = "healthchecks"; + cfg = config.services.healthchecks; + pkg = cfg.package; + boolToPython = b: if b then "True" else "False"; + environment = { + PYTHONPATH = pkg.pythonPath; + STATIC_ROOT = cfg.dataDir + "/static"; + DB_NAME = "${cfg.dataDir}/healthchecks.sqlite"; + } // 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) + $sudo ${pkg}/opt/healthchecks/manage.py "$@" + ''; +in +{ + options.services.healthchecks = { + enable = mkEnableOption (lib.mdDoc "healthchecks") // { + description = lib.mdDoc '' + Enable healthchecks. + It is expected to be run behind a HTTP reverse proxy. + ''; + }; + + package = mkOption { + default = pkgs.healthchecks; + defaultText = literalExpression "pkgs.healthchecks"; + type = types.package; + description = lib.mdDoc "healthchecks package to use."; + }; + + user = mkOption { + default = defaultUser; + type = types.str; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc "Address the server will listen on."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = lib.mdDoc "Port the server will listen on."; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/healthchecks"; + description = lib.mdDoc '' + 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. + ::: + ''; + }; + + settings = lib.mkOption { + description = lib.mdDoc '' + Environment variables which are read by healthchecks `(local)_settings.py`. + + Settings which are explicitly covered in options bewlow, 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 two 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. + ''; + type = types.submodule { + freeformType = types.attrsOf types.str; + options = { + ALLOWED_HOSTS = lib.mkOption { + type = types.listOf types.str; + default = [ "*" ]; + description = lib.mdDoc "The host/domain names that this site can serve."; + apply = lib.concatStringsSep ","; + }; + + SECRET_KEY_FILE = mkOption { + type = types.path; + description = lib.mdDoc "Path to a file containing the secret key."; + }; + + DEBUG = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Enable debug mode."; + apply = boolToPython; + }; + + REGISTRATION_OPEN = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + 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; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ healthchecksManageScript ]; + + systemd.targets.healthchecks = { + description = "Target for all Healthchecks services"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "network-online.target" ]; + }; + + systemd.services = + let + commonConfig = { + WorkingDirectory = cfg.dataDir; + User = cfg.user; + Group = cfg.group; + EnvironmentFile = [ environmentFile ]; + 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 + ${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 ]; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/hedgedoc.nix b/nixpkgs/nixos/modules/services/web-apps/hedgedoc.nix new file mode 100644 index 000000000000..e2014a9b7e35 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/hedgedoc.nix @@ -0,0 +1,1075 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + 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 versionAtLeast config.system.stateVersion "21.03" + then "hedgedoc" + else "codimd"; + + settingsFormat = pkgs.formats.json {}; + + prettyJSON = conf: + pkgs.runCommandLocal "hedgedoc-config.json" { + nativeBuildInputs = [ pkgs.jq ]; + } '' + jq '{production:del(.[]|nulls)|del(.[][]?|nulls)}' \ + < ${settingsFormat.generate "hedgedoc-ugly.json" cfg.settings} \ + > $out + ''; +in +{ + imports = [ + (mkRenamedOptionModule [ "services" "codimd" ] [ "services" "hedgedoc" ]) + (mkRenamedOptionModule + [ "services" "hedgedoc" "configuration" ] [ "services" "hedgedoc" "settings" ]) + ]; + + options.services.hedgedoc = { + enable = mkEnableOption (lib.mdDoc "the HedgeDoc Markdown Editor"); + + groups = mkOption { + type = types.listOf types.str; + default = []; + description = lib.mdDoc '' + Groups to which the service user should be added. + ''; + }; + + workDir = mkOption { + type = types.path; + default = "/var/lib/${name}"; + description = lib.mdDoc '' + Working directory for the HedgeDoc service. + ''; + }; + + settings = let options = { + debug = mkEnableOption (lib.mdDoc "debug mode"); + domain = mkOption { + type = types.nullOr types.str; + default = null; + example = "hedgedoc.org"; + description = lib.mdDoc '' + Domain name for the HedgeDoc instance. + ''; + }; + urlPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/url/path/to/hedgedoc"; + description = lib.mdDoc '' + Path under which HedgeDoc is accessible. + ''; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc '' + Address to listen on. + ''; + }; + port = mkOption { + type = types.port; + default = 3000; + example = 80; + description = lib.mdDoc '' + Port to listen on. + ''; + }; + path = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/hedgedoc.sock"; + description = lib.mdDoc '' + Specify where a UNIX domain socket should be placed. + ''; + }; + allowOrigin = mkOption { + type = types.listOf types.str; + default = []; + example = [ "localhost" "hedgedoc.org" ]; + description = lib.mdDoc '' + List of domains to whitelist. + ''; + }; + useSSL = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable to use SSL server. This will also enable + {option}`protocolUseSSL`. + ''; + }; + hsts = { + enable = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to enable HSTS if HTTPS is also enabled. + ''; + }; + maxAgeSeconds = mkOption { + type = types.int; + default = 31536000; + description = lib.mdDoc '' + Max duration for clients to keep the HSTS status. + ''; + }; + includeSubdomains = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to include subdomains in HSTS. + ''; + }; + preload = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to allow preloading of the site's HSTS status. + ''; + }; + }; + csp = mkOption { + type = types.nullOr types.attrs; + default = null; + example = literalExpression '' + { + enable = true; + directives = { + scriptSrc = "trustworthy.scripts.example.com"; + }; + upgradeInsecureRequest = "auto"; + addDefaults = true; + } + ''; + description = lib.mdDoc '' + Specify the Content Security Policy which is passed to Helmet. + For configuration details see <https://helmetjs.github.io/docs/csp/>. + ''; + }; + protocolUseSSL = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable to use TLS for resource paths. + This only applies when {option}`domain` is set. + ''; + }; + urlAddPort = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable to add the port to callback URLs. + This only applies when {option}`domain` is set + and only for ports other than 80 and 443. + ''; + }; + useCDN = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to use CDN resources or not. + ''; + }; + allowAnonymous = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to allow anonymous usage. + ''; + }; + allowAnonymousEdits = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to allow guests to edit existing notes with the `freely` permission, + when {option}`allowAnonymous` is enabled. + ''; + }; + allowFreeURL = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to allow note creation by accessing a nonexistent note URL. + ''; + }; + requireFreeURLAuthentication = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to require authentication for FreeURL mode style note creation. + ''; + }; + defaultPermission = mkOption { + type = types.enum [ "freely" "editable" "limited" "locked" "private" ]; + default = "editable"; + description = lib.mdDoc '' + Default permissions for notes. + This only applies for signed-in users. + ''; + }; + dbURL = mkOption { + type = types.nullOr types.str; + default = null; + example = '' + postgres://user:pass@host:5432/dbname + ''; + description = lib.mdDoc '' + Specify which database to use. + HedgeDoc supports mysql, postgres, sqlite and mssql. + See [ + https://sequelize.readthedocs.io/en/v3/](https://sequelize.readthedocs.io/en/v3/) for more information. + Note: This option overrides {option}`db`. + ''; + }; + db = mkOption { + type = types.attrs; + default = {}; + example = literalExpression '' + { + dialect = "sqlite"; + storage = "/var/lib/${name}/db.${name}.sqlite"; + } + ''; + description = lib.mdDoc '' + Specify the configuration for sequelize. + HedgeDoc supports mysql, postgres, sqlite and mssql. + See [ + https://sequelize.readthedocs.io/en/v3/](https://sequelize.readthedocs.io/en/v3/) for more information. + Note: This option overrides {option}`db`. + ''; + }; + sslKeyPath= mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/hedgedoc/hedgedoc.key"; + description = lib.mdDoc '' + Path to the SSL key. Needed when {option}`useSSL` is enabled. + ''; + }; + sslCertPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/hedgedoc/hedgedoc.crt"; + description = lib.mdDoc '' + Path to the SSL cert. Needed when {option}`useSSL` is enabled. + ''; + }; + sslCAPath = mkOption { + type = types.listOf types.str; + default = []; + example = [ "/var/lib/hedgedoc/ca.crt" ]; + description = lib.mdDoc '' + SSL ca chain. Needed when {option}`useSSL` is enabled. + ''; + }; + dhParamPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/hedgedoc/dhparam.pem"; + description = lib.mdDoc '' + Path to the SSL dh params. Needed when {option}`useSSL` is enabled. + ''; + }; + tmpPath = mkOption { + type = types.str; + default = "/tmp"; + description = lib.mdDoc '' + Path to the temp directory HedgeDoc should use. + Note that {option}`serviceConfig.PrivateTmp` is enabled for + the HedgeDoc systemd service by default. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + defaultNotePath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/default.md"; + defaultText = literalExpression "\"\${cfg.package}/public/default.md\""; + description = lib.mdDoc '' + Path to the default Note file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + docsPath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/docs"; + defaultText = literalExpression "\"\${cfg.package}/public/docs\""; + description = lib.mdDoc '' + Path to the docs directory. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + indexPath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/views/index.ejs"; + defaultText = literalExpression "\"\${cfg.package}/public/views/index.ejs\""; + description = lib.mdDoc '' + Path to the index template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + hackmdPath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/views/hackmd.ejs"; + defaultText = literalExpression "\"\${cfg.package}/public/views/hackmd.ejs\""; + description = lib.mdDoc '' + Path to the hackmd template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + errorPath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/views/error.ejs"; + defaultText = literalExpression "\"\${cfg.package}/public/views/error.ejs\""; + description = lib.mdDoc '' + Path to the error template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + prettyPath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/views/pretty.ejs"; + defaultText = literalExpression "\"\${cfg.package}/public/views/pretty.ejs\""; + description = lib.mdDoc '' + Path to the pretty template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + slidePath = mkOption { + type = types.nullOr types.str; + default = "${cfg.package}/public/views/slide.hbs"; + defaultText = literalExpression "\"\${cfg.package}/public/views/slide.hbs\""; + description = lib.mdDoc '' + Path to the slide template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + uploadsPath = mkOption { + type = types.str; + default = "${cfg.workDir}/uploads"; + defaultText = literalExpression "\"\${cfg.workDir}/uploads\""; + description = lib.mdDoc '' + Path under which uploaded files are saved. + ''; + }; + sessionName = mkOption { + type = types.str; + default = "connect.sid"; + description = lib.mdDoc '' + Specify the name of the session cookie. + ''; + }; + sessionSecret = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Specify the secret used to sign the session cookie. + If unset, one will be generated on startup. + ''; + }; + sessionLife = mkOption { + type = types.int; + default = 1209600000; + description = lib.mdDoc '' + Session life time in milliseconds. + ''; + }; + heartbeatInterval = mkOption { + type = types.int; + default = 5000; + description = lib.mdDoc '' + Specify the socket.io heartbeat interval. + ''; + }; + heartbeatTimeout = mkOption { + type = types.int; + default = 10000; + description = lib.mdDoc '' + Specify the socket.io heartbeat timeout. + ''; + }; + documentMaxLength = mkOption { + type = types.int; + default = 100000; + description = lib.mdDoc '' + Specify the maximum document length. + ''; + }; + email = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to enable email sign-in. + ''; + }; + allowEmailRegister = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to enable email registration. + ''; + }; + allowGravatar = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to use gravatar as profile picture source. + ''; + }; + imageUploadType = mkOption { + type = types.enum [ "imgur" "s3" "minio" "filesystem" ]; + default = "filesystem"; + description = lib.mdDoc '' + Specify where to upload images. + ''; + }; + minio = mkOption { + type = types.nullOr (types.submodule { + options = { + accessKey = mkOption { + type = types.str; + description = lib.mdDoc '' + Minio access key. + ''; + }; + secretKey = mkOption { + type = types.str; + description = lib.mdDoc '' + Minio secret key. + ''; + }; + endPoint = mkOption { + type = types.str; + description = lib.mdDoc '' + Minio endpoint. + ''; + }; + port = mkOption { + type = types.port; + default = 9000; + description = lib.mdDoc '' + Minio listen port. + ''; + }; + secure = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to use HTTPS for Minio. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the minio third-party integration."; + }; + s3 = mkOption { + type = types.nullOr (types.submodule { + options = { + accessKeyId = mkOption { + type = types.str; + description = lib.mdDoc '' + AWS access key id. + ''; + }; + secretAccessKey = mkOption { + type = types.str; + description = lib.mdDoc '' + AWS access key. + ''; + }; + region = mkOption { + type = types.str; + description = lib.mdDoc '' + AWS S3 region. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the s3 third-party integration."; + }; + s3bucket = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Specify the bucket name for upload types `s3` and `minio`. + ''; + }; + allowPDFExport = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to enable PDF exports. + ''; + }; + imgur.clientId = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Imgur API client ID. + ''; + }; + azure = mkOption { + type = types.nullOr (types.submodule { + options = { + connectionString = mkOption { + type = types.str; + description = lib.mdDoc '' + Azure Blob Storage connection string. + ''; + }; + container = mkOption { + type = types.str; + description = lib.mdDoc '' + Azure Blob Storage container name. + It will be created if non-existent. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the azure third-party integration."; + }; + oauth2 = mkOption { + type = types.nullOr (types.submodule { + options = { + authorizationURL = mkOption { + type = types.str; + description = lib.mdDoc '' + Specify the OAuth authorization URL. + ''; + }; + tokenURL = mkOption { + type = types.str; + description = lib.mdDoc '' + Specify the OAuth token URL. + ''; + }; + baseURL = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the OAuth base URL. + ''; + }; + userProfileURL = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the OAuth userprofile URL. + ''; + }; + userProfileUsernameAttr = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the name of the attribute for the username from the claim. + ''; + }; + userProfileDisplayNameAttr = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the name of the attribute for the display name from the claim. + ''; + }; + userProfileEmailAttr = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the name of the attribute for the email from the claim. + ''; + }; + scope = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the OAuth scope. + ''; + }; + providerName = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the name to be displayed for this strategy. + ''; + }; + rolesClaim = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the role claim name. + ''; + }; + accessRole = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify role which should be included in the ID token roles claim to grant access + ''; + }; + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + Specify the OAuth client ID. + ''; + }; + clientSecret = mkOption { + type = with types; nullOr str; + default = null; + description = lib.mdDoc '' + Specify the OAuth client secret. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the OAuth integration."; + }; + facebook = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + Facebook API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + Facebook API client secret. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the facebook third-party integration"; + }; + twitter = mkOption { + type = types.nullOr (types.submodule { + options = { + consumerKey = mkOption { + type = types.str; + description = lib.mdDoc '' + Twitter API consumer key. + ''; + }; + consumerSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + Twitter API consumer secret. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the Twitter third-party integration."; + }; + github = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + GitHub API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + Github API client secret. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the GitHub third-party integration."; + }; + gitlab = mkOption { + type = types.nullOr (types.submodule { + options = { + baseURL = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + GitLab API authentication endpoint. + Only needed for other endpoints than gitlab.com. + ''; + }; + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + GitLab API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + GitLab API client secret. + ''; + }; + scope = mkOption { + type = types.enum [ "api" "read_user" ]; + default = "api"; + description = lib.mdDoc '' + GitLab API requested scope. + GitLab snippet import/export requires api scope. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the GitLab third-party integration."; + }; + mattermost = mkOption { + type = types.nullOr (types.submodule { + options = { + baseURL = mkOption { + type = types.str; + description = lib.mdDoc '' + Mattermost authentication endpoint. + ''; + }; + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + Mattermost API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + Mattermost API client secret. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the Mattermost third-party integration."; + }; + dropbox = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + Dropbox API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + Dropbox API client secret. + ''; + }; + appKey = mkOption { + type = types.str; + description = lib.mdDoc '' + Dropbox app key. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the Dropbox third-party integration."; + }; + google = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = lib.mdDoc '' + Google API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = lib.mdDoc '' + Google API client secret. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the Google third-party integration."; + }; + ldap = mkOption { + type = types.nullOr (types.submodule { + options = { + providerName = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Optional name to be displayed at login form, indicating the LDAP provider. + ''; + }; + url = mkOption { + type = types.str; + example = "ldap://localhost"; + description = lib.mdDoc '' + URL of LDAP server. + ''; + }; + bindDn = mkOption { + type = types.str; + description = lib.mdDoc '' + Bind DN for LDAP access. + ''; + }; + bindCredentials = mkOption { + type = types.str; + description = lib.mdDoc '' + Bind credentials for LDAP access. + ''; + }; + searchBase = mkOption { + type = types.str; + example = "o=users,dc=example,dc=com"; + description = lib.mdDoc '' + LDAP directory to begin search from. + ''; + }; + searchFilter = mkOption { + type = types.str; + example = "(uid={{username}})"; + description = lib.mdDoc '' + LDAP filter to search with. + ''; + }; + searchAttributes = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + example = [ "displayName" "mail" ]; + description = lib.mdDoc '' + LDAP attributes to search with. + ''; + }; + userNameField = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + LDAP field which is used as the username on HedgeDoc. + By default {option}`useridField` is used. + ''; + }; + useridField = mkOption { + type = types.str; + example = "uid"; + description = lib.mdDoc '' + LDAP field which is a unique identifier for users on HedgeDoc. + ''; + }; + tlsca = mkOption { + type = types.str; + default = "/etc/ssl/certs/ca-certificates.crt"; + example = "server-cert.pem,root.pem"; + description = lib.mdDoc '' + Root CA for LDAP TLS in PEM format. + ''; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the LDAP integration."; + }; + saml = mkOption { + type = types.nullOr (types.submodule { + options = { + idpSsoUrl = mkOption { + type = types.str; + example = "https://idp.example.com/sso"; + description = lib.mdDoc '' + IdP authentication endpoint. + ''; + }; + idpCert = mkOption { + type = types.path; + example = "/path/to/cert.pem"; + description = lib.mdDoc '' + Path to IdP certificate file in PEM format. + ''; + }; + issuer = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Optional identity of the service provider. + This defaults to the server URL. + ''; + }; + identifierFormat = mkOption { + type = types.str; + default = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + description = lib.mdDoc '' + Optional name identifier format. + ''; + }; + groupAttribute = mkOption { + type = types.str; + default = ""; + example = "memberOf"; + description = lib.mdDoc '' + Optional attribute name for group list. + ''; + }; + externalGroups = mkOption { + type = types.listOf types.str; + default = []; + example = [ "Temporary-staff" "External-users" ]; + description = lib.mdDoc '' + Excluded group names. + ''; + }; + requiredGroups = mkOption { + type = types.listOf types.str; + default = []; + example = [ "Hedgedoc-Users" ]; + description = lib.mdDoc '' + Required group names. + ''; + }; + providerName = mkOption { + type = types.str; + default = ""; + example = "My institution"; + description = lib.mdDoc '' + Optional name to be displayed at login form indicating the SAML provider. + ''; + }; + attribute = { + id = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Attribute map for `id`. + Defaults to `NameID` of SAML response. + ''; + }; + username = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Attribute map for `username`. + Defaults to `NameID` of SAML response. + ''; + }; + email = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Attribute map for `email`. + Defaults to `NameID` of SAML response if + {option}`identifierFormat` has + the default value. + ''; + }; + }; + }; + }); + default = null; + description = lib.mdDoc "Configure the SAML integration."; + }; + }; in lib.mkOption { + type = lib.types.submodule { + freeformType = settingsFormat.type; + inherit options; + }; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.hedgedoc; + defaultText = literalExpression "pkgs.hedgedoc"; + description = lib.mdDoc '' + Package that provides HedgeDoc. + ''; + }; + + }; + + config = mkIf cfg.enable { + assertions = [ + { assertion = cfg.settings.db == {} -> ( + cfg.settings.dbURL != "" && cfg.settings.dbURL != null + ); + message = "Database configuration for HedgeDoc missing."; } + ]; + users.groups.${name} = {}; + users.users.${name} = { + description = "HedgeDoc service user"; + group = name; + extraGroups = cfg.groups; + home = cfg.workDir; + createHome = true; + isSystemUser = true; + }; + + systemd.services.hedgedoc = { + description = "HedgeDoc Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + preStart = '' + ${pkgs.envsubst}/bin/envsubst \ + -o ${cfg.workDir}/config.json \ + -i ${prettyJSON cfg.settings} + mkdir -p ${cfg.settings.uploadsPath} + ''; + serviceConfig = { + WorkingDirectory = cfg.workDir; + StateDirectory = [ cfg.workDir cfg.settings.uploadsPath ]; + ExecStart = "${cfg.package}/bin/hedgedoc"; + EnvironmentFile = mkIf (cfg.environmentFile != null) [ cfg.environmentFile ]; + Environment = [ + "CMD_CONFIG_FILE=${cfg.workDir}/config.json" + "NODE_ENV=production" + ]; + Restart = "always"; + User = name; + PrivateTmp = true; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/hledger-web.nix b/nixpkgs/nixos/modules/services/web-apps/hledger-web.nix new file mode 100644 index 000000000000..0fc283ff5219 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/hledger-web.nix @@ -0,0 +1,142 @@ +{ lib, pkgs, config, ... }: +with lib; +let + cfg = config.services.hledger-web; +in { + options.services.hledger-web = { + + enable = mkEnableOption (lib.mdDoc "hledger-web service"); + + serveApi = mkEnableOption (lib.mdDoc "Serve only the JSON web API, without the web UI"); + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc '' + Address to listen on. + ''; + }; + + port = mkOption { + type = types.port; + default = 5000; + example = 80; + description = lib.mdDoc '' + Port to listen on. + ''; + }; + + capabilities = { + view = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Enable the view capability. + ''; + }; + add = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable the add capability. + ''; + }; + manage = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable the manage capability. + ''; + }; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/hledger-web"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Base URL, when sharing over a network. + ''; + }; + + extraOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "--forecast" ]; + description = lib.mdDoc '' + Extra command line arguments to pass to hledger-web. + ''; + }; + + }; + + 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 + capabilityString = with cfg.capabilities; concatStringsSep "," ( + (optional view "view") + ++ (optional add "add") + ++ (optional manage "manage") + ); + serverArgs = with cfg; escapeShellArgs ([ + "--serve" + "--host=${host}" + "--port=${toString port}" + "--capabilities=${capabilityString}" + (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 = [ "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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix b/nixpkgs/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix new file mode 100644 index 000000000000..67d235ab4475 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix @@ -0,0 +1,262 @@ +{ 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 = with maintainers; [ das_j ]; + + options.services.icingaweb2 = with types; { + enable = mkEnableOption (lib.mdDoc "the icingaweb2 web interface"); + + pool = mkOption { + type = str; + default = poolName; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc "PHP-compliant timezone specification"; + }; + + modules = { + doc.enable = mkEnableOption (lib.mdDoc "the icingaweb2 doc module"); + migrate.enable = mkEnableOption (lib.mdDoc "the icingaweb2 migrate module"); + setup.enable = mkEnableOption (lib.mdDoc "the icingaweb2 setup module"); + test.enable = mkEnableOption (lib.mdDoc "the icingaweb2 test module"); + translation.enable = mkEnableOption (lib.mdDoc "the icingaweb2 translation module"); + }; + + modulePackages = mkOption { + type = attrsOf package; + default = {}; + example = literalExpression '' + { + "snow" = icingaweb2Modules.theme-snow; + } + ''; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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.php.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; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix b/nixpkgs/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix new file mode 100644 index 000000000000..9a848870e9da --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix @@ -0,0 +1,157 @@ +{ 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 = lib.mdDoc "Whether to enable the icingaweb2 monitoring module."; + }; + + generalConfig = { + mutable = mkOption { + type = bool; + default = false; + description = lib.mdDoc "Make config.ini of the monitoring module mutable (e.g. via the web interface)."; + }; + + protectedVars = mkOption { + type = listOf str; + default = [ "*pw*" "*pass*" "community" ]; + description = lib.mdDoc "List of string patterns for custom variables which should be excluded from user’s view."; + }; + }; + + mutableBackends = mkOption { + type = bool; + default = false; + description = lib.mdDoc "Make backends.ini of the monitoring module mutable (e.g. via the web interface)."; + }; + + backends = mkOption { + default = { icinga = { resource = "icinga_ido"; }; }; + description = lib.mdDoc "Monitoring backends to define"; + type = attrsOf (submodule ({ name, ... }: { + options = { + name = mkOption { + visible = false; + default = name; + type = str; + description = lib.mdDoc "Name of this backend"; + }; + + resource = mkOption { + type = str; + description = lib.mdDoc "Name of the IDO resource"; + }; + + disabled = mkOption { + type = bool; + default = false; + description = lib.mdDoc "Disable this backend"; + }; + }; + })); + }; + + mutableTransports = mkOption { + type = bool; + default = true; + description = lib.mdDoc "Make commandtransports.ini of the monitoring module mutable (e.g. via the web interface)."; + }; + + transports = mkOption { + default = {}; + description = lib.mdDoc "Command transports to define"; + type = attrsOf (submodule ({ name, ... }: { + options = { + name = mkOption { + visible = false; + default = name; + type = str; + description = lib.mdDoc "Name of this transport"; + }; + + type = mkOption { + type = enum [ "api" "local" "remote" ]; + default = "api"; + description = lib.mdDoc "Type of this transport"; + }; + + instance = mkOption { + type = nullOr str; + default = null; + description = lib.mdDoc "Assign a icinga instance to this transport"; + }; + + path = mkOption { + type = str; + description = lib.mdDoc "Path to the socket for local or remote transports"; + }; + + host = mkOption { + type = str; + description = lib.mdDoc "Host for the api or remote transport"; + }; + + port = mkOption { + type = nullOr str; + default = null; + description = lib.mdDoc "Port to connect to for the api or remote transport"; + }; + + username = mkOption { + type = str; + description = lib.mdDoc "Username for the api or remote transport"; + }; + + password = mkOption { + type = str; + description = lib.mdDoc "Password for the api transport"; + }; + + resource = mkOption { + type = str; + description = lib.mdDoc "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; }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/invidious.nix b/nixpkgs/nixos/modules/services/web-apps/invidious.nix new file mode 100644 index 000000000000..61c52ee03dc6 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/invidious.nix @@ -0,0 +1,264 @@ +{ 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; + + serviceConfig = { + systemd.services.invidious = { + description = "Invidious (An alternative YouTube front-end)"; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + script = + let + jqFilter = "." + + lib.optionalString (cfg.database.host != null) "[0].db.password = \"'\"'\"$(cat ${lib.escapeShellArg cfg.database.passwordFile})\"'\"'\"" + + " | .[0]" + + lib.optionalString (cfg.extraSettingsFile != null) " * .[1]"; + jqFiles = [ settingsFile ] ++ lib.optional (cfg.extraSettingsFile != null) cfg.extraSettingsFile; + in + '' + export INVIDIOUS_CONFIG="$(${pkgs.jq}/bin/jq -s "${jqFilter}" ${lib.escapeShellArgs jqFiles})" + exec ${cfg.package}/bin/invidious + ''; + + serviceConfig = { + RestartSec = "2s"; + DynamicUser = true; + + 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" ]; + }; + }; + + services.invidious.settings = { + inherit (cfg) port; + + # Automatically initialises and migrates the database if necessary + check_tables = true; + + db = { + user = lib.mkDefault "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 = if cfg.database.host == null then "" else cfg.database.host; + # Not needed because peer authentication is enabled + password = lib.mkIf (cfg.database.host == null) ""; + }; + } // (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"; + }]; + }; + + # Settings necessary for running with an automatically managed local database + localDatabaseConfig = lib.mkIf cfg.database.createLocally { + # Default to using the local database if we create it + services.invidious.database.host = lib.mkDefault null; + + services.postgresql = { + enable = true; + ensureDatabases = lib.singleton cfg.settings.db.dbname; + ensureUsers = lib.singleton { + name = cfg.settings.db.user; + ensurePermissions = { + "DATABASE ${cfg.settings.db.dbname}" = "ALL PRIVILEGES"; + }; + }; + # This is only needed because the unix user invidious isn't the same as + # the database user. This tells postgres to map one to the other. + identMap = '' + invidious invidious ${cfg.settings.db.user} + ''; + # And this specifically enables peer authentication for only this + # database, which allows passwordless authentication over the postgres + # unix socket for the user map given above. + authentication = '' + local ${cfg.settings.db.dbname} ${cfg.settings.db.user} peer map=invidious + ''; + }; + + systemd.services.invidious-db-clean = { + description = "Invidious database cleanup"; + documentation = [ "https://docs.invidious.io/Database-Information-and-Maintenance.md" ]; + startAt = lib.mkDefault "weekly"; + path = [ config.services.postgresql.package ]; + script = '' + psql ${cfg.settings.db.dbname} ${cfg.settings.db.user} -c "DELETE FROM nonces * WHERE expire < current_timestamp" + psql ${cfg.settings.db.dbname} ${cfg.settings.db.user} -c "TRUNCATE TABLE videos" + ''; + serviceConfig = { + DynamicUser = true; + User = "invidious"; + }; + }; + + systemd.services.invidious = { + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + serviceConfig = { + User = "invidious"; + }; + }; + }; + + nginxConfig = lib.mkIf cfg.nginx.enable { + services.invidious.settings = { + https_only = config.services.nginx.virtualHosts.${cfg.domain}.forceSSL; + external_port = 80; + }; + + services.nginx = { + enable = true; + virtualHosts.${cfg.domain} = { + locations."/".proxyPass = "http://127.0.0.1:${toString cfg.port}"; + + enableACME = lib.mkDefault true; + forceSSL = lib.mkDefault true; + }; + }; + + 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 (lib.mdDoc "Invidious"); + + package = lib.mkOption { + type = types.package; + default = pkgs.invidious; + defaultText = lib.literalExpression "pkgs.invidious"; + description = lib.mdDoc "The Invidious package to use."; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + default = { }; + description = lib.mdDoc '' + 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. + ''; + }; + + extraSettingsFile = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + 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. + ''; + }; + + # 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 = lib.mdDoc '' + The FQDN Invidious is reachable on. + + This is used to configure nginx and for building absolute URLs. + ''; + }; + + port = lib.mkOption { + type = types.port; + # Default from https://docs.invidious.io/Configuration.md + default = 3000; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Whether to create a local database with PostgreSQL. + ''; + }; + + host = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + 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 = options.services.postgresql.port.default; + defaultText = lib.literalExpression "options.services.postgresql.port.default"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Path to file containing the database password. + ''; + }; + }; + + nginx.enable = lib.mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + 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. + ''; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + serviceConfig + localDatabaseConfig + nginxConfig + ]); +} diff --git a/nixpkgs/nixos/modules/services/web-apps/invoiceplane.nix b/nixpkgs/nixos/modules/services/web-apps/invoiceplane.nix new file mode 100644 index 000000000000..8be1fd3055d0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/invoiceplane.nix @@ -0,0 +1,358 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.invoiceplane; + eachSite = cfg.sites; + user = "invoiceplane"; + webserver = config.services.${cfg.webserver}; + + invoiceplane-config = hostName: cfg: pkgs.writeText "ipconfig.php" '' + IP_URL=http://${hostName} + ENABLE_DEBUG=false + DISABLE_SETUP=false + REMOVE_INDEXPHP=false + DB_HOSTNAME=${cfg.database.host} + DB_USERNAME=${cfg.database.user} + # NOTE: file_get_contents adds newline at the end of returned string + DB_PASSWORD=${if cfg.database.passwordFile == null then "" else "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")"} + DB_DATABASE=${cfg.database.name} + DB_PORT=${toString cfg.database.port} + SESS_EXPIRATION=864000 + ENABLE_INVOICE_DELETION=false + DISABLE_READ_ONLY=false + ENCRYPTION_KEY= + ENCRYPTION_CIPHER=AES-256 + SETUP_COMPLETED=false + REMOVE_INDEXPHP=true + ''; + + extraConfig = hostName: cfg: pkgs.writeText "extraConfig.php" '' + ${toString cfg.extraConfig} + ''; + + pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { + pname = "invoiceplane-${hostName}"; + version = src.version; + src = pkgs.invoiceplane; + + postPhase = '' + # Patch index.php file to load additional config file + substituteInPlace index.php \ + --replace "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = Dotenv\Dotenv::createImmutable(__DIR__, 'extraConfig.php'); \$dotenv->load();"; + ''; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + # symlink uploads and log directories + rm -r $out/uploads $out/application/logs $out/vendor/mpdf/mpdf/tmp + ln -sf ${cfg.stateDir}/uploads $out/ + ln -sf ${cfg.stateDir}/logs $out/application/ + ln -sf ${cfg.stateDir}/tmp $out/vendor/mpdf/mpdf/ + + # symlink the InvoicePlane config + ln -s ${cfg.stateDir}/ipconfig.php $out/ipconfig.php + + # symlink the extraConfig file + ln -s ${extraConfig hostName cfg} $out/extraConfig.php + + # symlink additional templates + ${concatMapStringsSep "\n" (template: "cp -r ${template}/. $out/application/views/invoice_templates/pdf/") cfg.invoiceTemplates} + ''; + }; + + siteOpts = { lib, name, ... }: + { + options = { + + enable = mkEnableOption (lib.mdDoc "InvoicePlane web application"); + + stateDir = mkOption { + type = types.path; + default = "/var/lib/invoiceplane/${name}"; + description = lib.mdDoc '' + This directory is used for uploads of attachments and cache. + The directory passed here is automatically created and permissions + adjusted as required. + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "invoiceplane"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "invoiceplane"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/invoiceplane-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + invoiceTemplates = mkOption { + type = types.listOf types.path; + default = []; + description = lib.mdDoc '' + List of path(s) to respective template(s) which are copied from the 'invoice_templates/pdf' directory. + + ::: {.note} + These templates need to be packaged before use, see example. + ::: + ''; + example = literalExpression '' + let + # Let's package an example template + template-vtdirektmarketing = pkgs.stdenv.mkDerivation { + name = "vtdirektmarketing"; + # Download the template from a public repository + src = pkgs.fetchgit { + url = "https://git.project-insanity.org/onny/invoiceplane-vtdirektmarketing.git"; + sha256 = "1hh0q7wzsh8v8x03i82p6qrgbxr4v5fb05xylyrpp975l8axyg2z"; + }; + sourceRoot = "."; + # Installing simply means copying template php file to the output directory + installPhase = "" + mkdir -p $out + cp invoiceplane-vtdirektmarketing/vtdirektmarketing.php $out/ + ""; + }; + # And then pass this package to the template list like this: + in [ template-vtdirektmarketing ] + ''; + }; + + 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 = lib.mdDoc '' + Options for the InvoicePlane PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.nullOr types.lines; + default = null; + example = '' + SETUP_COMPLETED=true + DISABLE_SETUP=true + IP_URL=https://invoice.example.com + ''; + description = lib.mdDoc '' + InvoicePlane configuration. Refer to + <https://github.com/InvoicePlane/InvoicePlane/blob/master/ipconfig.php.example> + for details on supported values. + ''; + }; + + cron = { + + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable cron service which periodically runs Invoiceplane tasks. + Requires key taken from the administration page. Refer to + <https://wiki.invoiceplane.com/en/1.0/modules/recurring-invoices> + on how to configure it. + ''; + }; + + key = mkOption { + type = types.str; + description = lib.mdDoc "Cron key taken from the administration page."; + }; + + }; + + }; + + }; +in +{ + # interface + options = { + services.invoiceplane = mkOption { + type = types.submodule { + + options.sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = {}; + description = lib.mdDoc "Specification of one or more WordPress sites to serve"; + }; + + options.webserver = mkOption { + type = types.enum [ "caddy" ]; + default = "caddy"; + description = lib.mdDoc '' + Which webserver to use for virtual host management. Currently only + caddy is supported. + ''; + }; + }; + default = {}; + description = lib.mdDoc "InvoicePlane configuration."; + }; + + }; + + # implementation + config = mkIf (eachSite != {}) (mkMerge [{ + + assertions = flatten (mapAttrsToList (hostName: cfg: + [{ assertion = cfg.database.createLocally -> cfg.database.user == user; + message = ''services.invoiceplane.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = ''services.invoiceplane.sites."${hostName}".database.passwordFile cannot be specified if services.invoiceplane.sites."${hostName}".database.createLocally is set to true.''; + } + { assertion = cfg.cron.enable -> cfg.cron.key != null; + message = ''services.invoiceplane.sites."${hostName}".cron.key must be set in order to use cron service.''; + } + ]) eachSite); + + services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { + enable = true; + package = mkDefault pkgs.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 = { + phpPackage = pkgs.php81; + pools = mapAttrs' (hostName: cfg: ( + nameValuePair "invoiceplane-${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} - -" + "f ${cfg.stateDir}/ipconfig.php 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/logs 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/archive 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/customer_files 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/temp 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/temp/mpdf 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/tmp 0750 ${user} ${webserver.group} - -" + ]) eachSite); + + systemd.services.invoiceplane-config = { + serviceConfig.Type = "oneshot"; + script = concatStrings (mapAttrsToList (hostName: cfg: + '' + mkdir -p ${cfg.stateDir}/logs \ + ${cfg.stateDir}/uploads + if ! grep -q IP_URL "${cfg.stateDir}/ipconfig.php"; then + cp "${invoiceplane-config hostName cfg}" "${cfg.stateDir}/ipconfig.php" + fi + '') eachSite); + wantedBy = [ "multi-user.target" ]; + }; + + users.users.${user} = { + group = webserver.group; + isSystemUser = true; + }; + + } + { + + # Cron service implementation + + systemd.timers = mapAttrs' (hostName: cfg: ( + nameValuePair "invoiceplane-cron-${hostName}" (mkIf cfg.cron.enable { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "5m"; + OnUnitActiveSec = "5m"; + Unit = "invoiceplane-cron-${hostName}.service"; + }; + }) + )) eachSite; + + systemd.services = + mapAttrs' (hostName: cfg: ( + nameValuePair "invoiceplane-cron-${hostName}" (mkIf cfg.cron.enable { + serviceConfig = { + Type = "oneshot"; + User = user; + ExecStart = "${pkgs.curl}/bin/curl --header 'Host: ${hostName}' http://localhost/invoices/cron/recur/${cfg.cron.key}"; + }; + }) + )) eachSite; + + } + + (mkIf (cfg.webserver == "caddy") { + services.caddy = { + enable = true; + virtualHosts = mapAttrs' (hostName: cfg: ( + nameValuePair "http://${hostName}" { + extraConfig = '' + root * ${pkg hostName cfg} + file_server + php_fastcgi unix/${config.services.phpfpm.pools."invoiceplane-${hostName}".socket} + ''; + } + )) eachSite; + }; + }) + + ]); +} diff --git a/nixpkgs/nixos/modules/services/web-apps/isso.nix b/nixpkgs/nixos/modules/services/web-apps/isso.nix new file mode 100644 index 000000000000..1a852ec352f2 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/isso.nix @@ -0,0 +1,91 @@ +{ 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 (lib.mdDoc '' + 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 = lib.mdDoc '' + 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"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/jirafeau.nix b/nixpkgs/nixos/modules/services/web-apps/jirafeau.nix new file mode 100644 index 000000000000..b2e274167164 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/jirafeau.nix @@ -0,0 +1,173 @@ +{ 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 = lib.mdDoc '' + SHA-256 of the desired administration password. Leave blank/unset for no password. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/jirafeau/data/"; + description = lib.mdDoc "Location of Jirafeau storage directory."; + }; + + enable = mkEnableOption (lib.mdDoc "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 + lib.mdDoc '' + Jirefeau configuration. Refer to <${documentationLink}> for supported + values. + ''; + }; + + hostName = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "URL of instance. Must have trailing slash."; + }; + + maxUploadSizeMegabytes = mkOption { + type = types.int; + default = 0; + description = lib.mdDoc "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 + lib.mdDoc '' + 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 = lib.mdDoc "Extra configuration for the nginx virtual host of Jirafeau."; + }; + + package = mkOption { + type = types.package; + default = pkgs.jirafeau; + defaultText = literalExpression "pkgs.jirafeau"; + description = lib.mdDoc "Jirafeau package to use"; + }; + + 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 = lib.mdDoc '' + 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; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/jitsi-meet.md b/nixpkgs/nixos/modules/services/web-apps/jitsi-meet.md new file mode 100644 index 000000000000..060ef9752650 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/jitsi-meet.md @@ -0,0 +1,45 @@ +# 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: +``` +{ + 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; +} +``` + +## Configuration {#module-services-jitsi-configuration} + +Here is the minimal configuration with additional configurations: +``` +{ + services.jitsi-meet = { + enable = true; + hostName = "jitsi.example.com"; + 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; +} +``` diff --git a/nixpkgs/nixos/modules/services/web-apps/jitsi-meet.nix b/nixpkgs/nixos/modules/services/web-apps/jitsi-meet.nix new file mode 100644 index 000000000000..3825b03c2449 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/jitsi-meet.nix @@ -0,0 +1,459 @@ +{ 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}"; + }; + 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 (lib.mdDoc "Jitsi Meet - Secure, Simple and Scalable Video Conferences"); + + hostName = mkOption { + type = str; + example = "meet.example.org"; + description = lib.mdDoc '' + FQDN of the Jitsi Meet instance. + ''; + }; + + config = mkOption { + type = attrs; + default = { }; + example = literalExpression '' + { + enableWelcomePage = false; + defaultLang = "fi"; + } + ''; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Whether to enable 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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. + ''; + }; + + nginx.enable = mkOption { + type = bool; + default = true; + description = lib.mdDoc '' + 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 (lib.mdDoc "Whether to enable caddy reverse proxy to expose jitsi-meet"); + + prosody.enable = mkOption { + type = bool; + default = true; + description = lib.mdDoc '' + Whether to configure Prosody to relay XMPP messages between Jitsi Meet components. Turn this + off if you want to configure it manually. + ''; + }; + }; + + config = mkIf cfg.enable { + services.prosody = mkIf cfg.prosody.enable { + 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; + }; + muc = [ + { + domain = "conference.${cfg.hostName}"; + name = "Jitsi Meet MUC"; + roomLocking = false; + roomDefaultPublicJids = true; + extraConfig = '' + storage = "memory" + ''; + } + { + domain = "internal.${cfg.hostName}"; + name = "Jitsi Meet Videobridge MUC"; + extraConfig = '' + storage = "memory" + admins = { "focus@auth.${cfg.hostName}", "jvb@auth.${cfg.hostName}" } + ''; + #-- muc_room_cache_size = 1000 + } + ]; + extraModules = [ "pubsub" "smacks" ]; + extraPluginPaths = [ "${pkgs.jitsi-meet-prosody}/share/prosody-plugins" ]; + extraConfig = lib.mkMerge [ (mkAfter '' + Component "focus.${cfg.hostName}" "client_proxy" + target_address = "focus@auth.${cfg.hostName}" + '') + (mkBefore '' + cross_domain_websocket = true; + consider_websocket_secure = true; + '') + ]; + virtualHosts.${cfg.hostName} = { + enabled = true; + domain = cfg.hostName; + extraConfig = '' + authentication = "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 + ''; + 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_plain" + ''; + 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 + ''; + }; + }; + 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)" + ''; + 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"); + serviceConfig = { + Type = "oneshot"; + }; + + script = let + secrets = [ "jicofo-component-secret" "jicofo-user-secret" "jibri-auth-secret" "jibri-recorder-secret" ] ++ (optional (cfg.videobridge.passwordFile == null) "videobridge-secret"); + in + '' + cd /var/lib/jitsi-meet + ${concatMapStringsSep "\n" (s: '' + if [ ! -f ${s} ]; then + tr -dc a-zA-Z0-9 </dev/urandom | head -c 64 > ${s} + chown root:jitsi-meet ${s} + chmod 640 ${s} + fi + '') secrets} + + # for easy access in prosody + echo "JICOFO_COMPONENT_SECRET=$(cat jicofo-component-secret)" > secrets-env + chown root:jitsi-meet secrets-env + chmod 640 secrets-env + '' + + optionalString cfg.prosody.enable '' + # generate self-signed certificates + if [ ! -f /var/lib/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.{crt,key} + chown root:jitsi-meet /var/lib/jitsi-meet/jitsi-meet.{crt,key} + fi + ''; + }; + + 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."=/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 ""; + }; + }; + }; + + services.caddy = mkIf cfg.caddy.enable { + enable = mkDefault true; + virtualHosts.${cfg.hostName} = { + extraConfig = + let + templatedJitsiMeet = pkgs.runCommand "templated-jitsi-meet" {} '' + cp -R ${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 '' + 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-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.${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.${cfg.hostName}"; + config = mkMerge [{ + jicofo.xmpp.service.disable-certificate-verification = true; + jicofo.xmpp.client.disable-certificate-verification = true; + #} (lib.mkIf cfg.jibri.enable { + } (lib.mkIf (config.services.jibri.enable || cfg.jibri.enable) { + jicofo.jibri = { + brewery-jid = "JibriBrewery@internal.${cfg.hostName}"; + pending-timeout = "90"; + }; + })]; + }; + + services.jibri = mkIf cfg.jibri.enable { + enable = true; + + xmppEnvironments."jitsi-meet" = { + xmppServerHosts = [ "localhost" ]; + xmppDomain = cfg.hostName; + + control.muc = { + domain = "internal.${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."; + }; + }; + }; + + meta.doc = ./jitsi-meet.md; + meta.maintainers = lib.teams.jitsi.members; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/kasmweb/default.nix b/nixpkgs/nixos/modules/services/web-apps/kasmweb/default.nix new file mode 100644 index 000000000000..0d78025ecf0f --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/kasmweb/default.nix @@ -0,0 +1,275 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.kasmweb; +in +{ + options.services.kasmweb = { + enable = lib.mkEnableOption (lib.mdDoc "kasmweb"); + + networkSubnet = lib.mkOption { + default = "172.20.0.0/16"; + type = lib.types.str; + description = lib.mdDoc '' + The network subnet to use for the containers. + ''; + }; + + postgres = { + user = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + Username to use for the postgres database. + ''; + }; + password = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + password to use for the postgres database. + ''; + }; + }; + + redisPassword = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + password to use for the redis cache. + ''; + }; + + defaultAdminPassword = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + default admin password to use. + ''; + }; + + defaultUserPassword = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + default user password to use. + ''; + }; + + defaultManagerToken = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + default manager token to use. + ''; + }; + + defaultGuacToken = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + default guac token to use. + ''; + }; + + defaultRegistrationToken = lib.mkOption { + default = "kasmweb"; + type = lib.types.str; + description = lib.mdDoc '' + default registration token to use. + ''; + }; + + datastorePath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/kasmweb"; + description = lib.mdDoc '' + The directory used to store all data for kasmweb. + ''; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = lib.mdDoc '' + The address on which kasmweb should listen. + ''; + }; + + listenPort = lib.mkOption { + type = lib.types.int; + default = 443; + description = lib.mdDoc '' + The port on which kasmweb should listen. + ''; + }; + + sslCertificate = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = lib.mdDoc '' + The SSL certificate to be used for kasmweb. + ''; + }; + + sslCertificateKey = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = lib.mdDoc '' + 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" + ]; + before = [ + "docker-kasm_db.service" + "docker-kasm_redis.service" + "docker-kasm_db_init.service" + "docker-kasm_api.service" + "docker-kasm_agent.service" + "docker-kasm_manager.service" + "docker-kasm_share.service" + "docker-kasm_guac.service" + "docker-kasm_proxy.service" + ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.substituteAll { + src = ./initialize_kasmweb.sh; + isExecutable = true; + binPath = lib.makeBinPath [ pkgs.docker pkgs.openssl pkgs.gnused ]; + runtimeShell = pkgs.runtimeShell; + kasmweb = pkgs.kasmweb; + postgresUser = cfg.postgres.user; + postgresPassword = cfg.postgres.password; + inherit (cfg) + datastorePath + sslCertificate + sslCertificateKey + redisPassword + defaultUserPassword + defaultAdminPassword + defaultManagerToken + defaultRegistrationToken + defaultGuacToken; + }; + }; + }; + }; + + virtualisation = { + oci-containers.containers = { + kasm_db = { + image = "postgres:12-alpine"; + environment = { + POSTGRES_PASSWORD = cfg.postgres.password; + POSTGRES_USER = cfg.postgres.user; + 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"; + 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"; + cmd = [ + "-c" + "redis-server --requirepass ${cfg.redisPassword}" + ]; + extraOptions = [ "--network=kasm_default_network" "--userns=host" ]; + }; + kasm_api = { + image = "kasmweb/api:${pkgs.kasmweb.version}"; + 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}"; + user = "root:root"; + volumes = [ + "${cfg.datastorePath}/:/opt/kasm/current/" + ]; + dependsOn = [ "kasm_db" "kasm_api" ]; + extraOptions = [ "--network=kasm_default_network" "--userns=host" "--read-only"]; + }; + kasm_agent = { + image = "kasmweb/agent:${pkgs.kasmweb.version}"; + 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}"; + user = "root:root"; + volumes = [ + "${cfg.datastorePath}/:/opt/kasm/current/" + ]; + dependsOn = [ "kasm_db" "kasm_redis" ]; + extraOptions = [ "--network=kasm_default_network" "--userns=host" "--read-only" ]; + }; + kasm_guac = { + image = "kasmweb/kasm-guac:${pkgs.kasmweb.version}"; + 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"; + 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"]; + }; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/kasmweb/initialize_kasmweb.sh b/nixpkgs/nixos/modules/services/web-apps/kasmweb/initialize_kasmweb.sh new file mode 100644 index 000000000000..dbf043b98693 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/kasmweb/initialize_kasmweb.sh @@ -0,0 +1,114 @@ +#! @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@ + + +docker network inspect kasm_default_network >/dev/null || docker network create kasm_default_network --subnet @networkSubnet@ +if docker volume inspect kasmweb_db >/dev/null; then + source @datastorePath@/ids.env + echo 'echo "skipping database init"' > @datastorePath@/init_seeds.sh + echo 'while true; do sleep 10 ; done' >> @datastorePath@/init_seeds.sh +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 + + docker volume create kasmweb_db + rm @datastorePath@/.done_initing_data + cat >@datastorePath@/init_seeds.sh <<EOF +#!/bin/bash +if [ ! -e /opt/kasm/current/.done_initing_data ]; then + sleep 4 + /usr/bin/kasm_server.so --initialize-database --cfg \ + /opt/kasm/current/conf/app/api.app.config.yaml \ + --populate-production \ + --seed-file \ + /opt/kasm/current/conf/database/seed_data/default_properties.yaml \ + 2>&1 | grep -v UserWarning + /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 \ + 2>&1 | grep -v UserWarning + /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_connection_proxies.yaml \ + 2>&1 | grep -v UserWarning + /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 \ + 2>&1 | grep -v UserWarning + 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 +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 + +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/redisPassword.*/redisPassword: @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 + + +sed -i -e "s/password: admin.*/password: \"@defaultAdminPassword@\"/g" \ + -e "s/password: user.*/password: \"@defaultUserPassword@\"/g" \ + -e "s/default-manager-token/@defaultManagerToken@/g" \ + -e "s/default-registration-token/@defaultRegistrationToken@/g" \ + -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 -e "s/GUACTOKEN/@defaultGuacToken@/g" \ + -e "s/APIHOSTNAME/proxy/g" \ + @datastorePath@/conf/database/seed_data/default_connection_proxies.yaml + +sed -i "s/00000000-0000-0000-0000-000000000000/$SERVER_ID/g" \ + @datastorePath@/conf/database/seed_data/default_agents.yaml + diff --git a/nixpkgs/nixos/modules/services/web-apps/kavita.nix b/nixpkgs/nixos/modules/services/web-apps/kavita.nix new file mode 100644 index 000000000000..ca9cd01d403d --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/kavita.nix @@ -0,0 +1,83 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.kavita; +in { + options.services.kavita = { + enable = lib.mkEnableOption (lib.mdDoc "Kavita reading server"); + + user = lib.mkOption { + type = lib.types.str; + default = "kavita"; + description = lib.mdDoc "User account under which Kavita runs."; + }; + + package = lib.mkPackageOptionMD pkgs "kavita" { }; + + dataDir = lib.mkOption { + default = "/var/lib/kavita"; + type = lib.types.str; + description = lib.mdDoc "The directory where Kavita stores its state."; + }; + + tokenKeyFile = lib.mkOption { + type = lib.types.path; + description = lib.mdDoc '' + A file containing the TokenKey, a secret with at 128+ bits. + It can be generated with `head -c 32 /dev/urandom | base64`. + ''; + }; + port = lib.mkOption { + default = 5000; + type = lib.types.port; + description = lib.mdDoc "Port to bind to."; + }; + ipAdresses = lib.mkOption { + default = ["0.0.0.0" "::"]; + type = lib.types.listOf lib.types.str; + description = lib.mdDoc "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 = '' + umask u=rwx,g=rx,o= + cat > "${cfg.dataDir}/config/appsettings.json" <<EOF + { + "TokenKey": "$(cat ${cfg.tokenKeyFile})", + "Port": ${toString cfg.port}, + "IpAddresses": "${lib.concatStringsSep "," cfg.ipAdresses}" + } + EOF + ''; + serviceConfig = { + WorkingDirectory = cfg.dataDir; + 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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/keycloak.md b/nixpkgs/nixos/modules/services/web-apps/keycloak.md new file mode 100644 index 000000000000..aa8de40d642b --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/keycloak.md @@ -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-strict-backchannel) +determines whether Keycloak should force all requests to go +through the frontend URL. By default, +Keycloak allows backend requests to +instead use its local hostname or IP address and may also +advertise it to clients through its OpenID Connect Discovery +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: +``` +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"; +}; +``` diff --git a/nixpkgs/nixos/modules/services/web-apps/keycloak.nix b/nixpkgs/nixos/modules/services/web-apps/keycloak.nix new file mode 100644 index 000000000000..a7e4fab8ea28 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/keycloak.nix @@ -0,0 +1,679 @@ +{ config, options, pkgs, lib, ... }: + +let + cfg = config.services.keycloak; + opt = options.services.keycloak; + + inherit (lib) + types + mkMerge + mkOption + mkChangedOptionModule + mkRenamedOptionModule + mkRemovedOptionModule + concatStringsSep + mapAttrsToList + escapeShellArg + mkIf + optionalString + optionals + mkDefault + literalExpression + isAttrs + literalMD + maintainers + catAttrs + collect + splitString + 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; + + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + The type of database Keycloak should connect to. + ''; + }; + + host = mkOption { + type = str; + default = "localhost"; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + Port of the database to connect to. + ''; + }; + + useSSL = mkOption { + type = bool; + default = cfg.database.host != "localhost"; + defaultText = literalExpression ''config.${opt.database.host} != "localhost"''; + description = lib.mdDoc '' + Whether the database connection should be secured by SSL / + TLS. + ''; + }; + + caCert = mkOption { + type = nullOr path; + default = null; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + The path to a file containing the database password. + ''; + }; + }; + + package = mkOption { + type = package; + default = pkgs.keycloak; + defaultText = literalExpression "pkgs.keycloak"; + description = lib.mdDoc '' + Keycloak package to use. + ''; + }; + + initialAdminPassword = mkOption { + type = str; + default = "changeme"; + description = lib.mdDoc '' + Initial password set for the `admin` + user. The password is not stored safely and should be changed + immediately in the admin panel. + ''; + }; + + themes = mkOption { + type = attrsOf package; + default = { }; + description = lib.mdDoc '' + 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. + ''; + }; + + settings = mkOption { + type = lib.types.submodule { + freeformType = attrsOf (nullOr (oneOf [ str int bool (attrsOf path) ])); + + options = { + http-host = mkOption { + type = str; + default = "0.0.0.0"; + example = "127.0.0.1"; + description = lib.mdDoc '' + On which address Keycloak should accept new connections. + ''; + }; + + http-port = mkOption { + type = port; + default = 80; + example = 8080; + description = lib.mdDoc '' + On which port Keycloak should listen for new HTTP connections. + ''; + }; + + https-port = mkOption { + type = port; + default = 443; + example = 8443; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + The path relative to `/` for serving + resources. + + ::: {.note} + In versions of Keycloak using Wildfly (<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 = str; + example = "keycloak.example.com"; + description = lib.mdDoc '' + 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-strict-backchannel = mkOption { + type = bool; + default = false; + example = true; + description = lib.mdDoc '' + Whether Keycloak should force all requests to go + through the frontend URL. By default, Keycloak allows + backend requests to instead use its local hostname or + IP address and may also advertise it to clients + through its OpenID Connect Discovery endpoint. + + See <https://www.keycloak.org/server/hostname> + for more information about hostname configuration. + ''; + }; + + proxy = mkOption { + type = enum [ "edge" "reencrypt" "passthrough" "none" ]; + default = "none"; + example = "edge"; + description = lib.mdDoc '' + The proxy address forwarding mode if the server is + behind a reverse proxy. + + - `edge`: + Enables communication through HTTP between the + proxy and Keycloak. + - `reencrypt`: + Requires communication through HTTPS between the + proxy and Keycloak. + - `passthrough`: + Enables communication through HTTP or HTTPS between + the proxy and Keycloak. + + See <https://www.keycloak.org/server/reverseproxy> for more information. + ''; + }; + }; + }; + + example = literalExpression '' + { + hostname = "keycloak.example.com"; + proxy = "reencrypt"; + https-key-store-file = "/path/to/file"; + https-key-store-password = { _secret = "/run/keys/store_password"; }; + } + ''; + + description = lib.mdDoc '' + 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: 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}"; + }; + }; + + 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; + }; + 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"; + } + ]; + + 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.service" ]; + before = [ "keycloak.service" ]; + bindsTo = [ "postgresql.service" ]; + 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"' + ''; + }; + + 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 + ''; + }; + + systemd.services.keycloak = + let + databaseServices = + if createLocalPostgreSQL then [ + "keycloakPostgreSQLInit.service" + "postgresql.service" + ] + 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"; + }; + 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"; + }; + 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/ + + 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/ + '' + '' + export KEYCLOAK_ADMIN=admin + export KEYCLOAK_ADMIN_PASSWORD=${escapeShellArg cfg.initialAdminPassword} + kc.sh start --optimized + ''; + }; + + 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 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/komga.nix b/nixpkgs/nixos/modules/services/web-apps/komga.nix new file mode 100644 index 000000000000..31f475fc7b04 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/komga.nix @@ -0,0 +1,99 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.komga; + +in { + options = { + services.komga = { + enable = mkEnableOption (lib.mdDoc "Komga, a free and open source comics/mangas media server"); + + port = mkOption { + type = types.port; + default = 8080; + description = lib.mdDoc '' + The port that Komga will listen on. + ''; + }; + + user = mkOption { + type = types.str; + default = "komga"; + description = lib.mdDoc '' + User account under which Komga runs. + ''; + }; + + group = mkOption { + type = types.str; + default = "komga"; + description = lib.mdDoc '' + Group under which Komga runs. + ''; + }; + + stateDir = mkOption { + type = types.str; + default = "/var/lib/komga"; + description = lib.mdDoc '' + State and configuration directory Komga will use. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to open the firewall for the port in {option}`services.komga.port`. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + + users.groups = mkIf (cfg.group == "komga") { + komga = {}; + }; + + users.users = mkIf (cfg.user == "komga") { + komga = { + group = cfg.group; + home = cfg.stateDir; + description = "Komga Daemon user"; + isSystemUser = true; + }; + }; + + systemd.services.komga = { + environment = { + SERVER_PORT = builtins.toString cfg.port; + KOMGA_CONFIGDIR = cfg.stateDir; + }; + + description = "Komga is a free and open source comics/mangas media server"; + + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + + Type = "simple"; + Restart = "on-failure"; + ExecStart = "${pkgs.komga}/bin/komga"; + + StateDirectory = mkIf (cfg.stateDir == "/var/lib/komga") "komga"; + }; + + }; + }; + + meta.maintainers = with maintainers; [ govanify ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/lemmy.md b/nixpkgs/nixos/modules/services/web-apps/lemmy.md new file mode 100644 index 000000000000..faafe096d138 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/lemmy.md @@ -0,0 +1,31 @@ +# Lemmy {#module-services-lemmy} + +Lemmy is a federated alternative to reddit in rust. + +## Quickstart {#module-services-lemmy-quickstart} + +the minimum to start lemmy is + +```nix +services.lemmy = { + enable = true; + settings = { + hostname = "lemmy.union.rocks"; + database.createLocally = true; + }; + caddy.enable = true; +} +``` + +this will start the backend on port 8536 and the frontend on port 1234. +It will expose your instance with a caddy reverse proxy to the hostname you've provided. +Postgres will be initialized on that same instance automatically. + +## Usage {#module-services-lemmy-usage} + +On first connection you will be asked to define an admin user. + +## Missing {#module-services-lemmy-missing} + +- Exposing with nginx is not implemented yet. +- This has been tested using a local database with a unix socket connection. Using different database settings will likely require modifications diff --git a/nixpkgs/nixos/modules/services/web-apps/lemmy.nix b/nixpkgs/nixos/modules/services/web-apps/lemmy.nix new file mode 100644 index 000000000000..f48afd98a6c9 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/lemmy.nix @@ -0,0 +1,206 @@ +{ lib, pkgs, config, ... }: +with lib; +let + cfg = config.services.lemmy; + settingsFormat = pkgs.formats.json { }; +in +{ + meta.maintainers = with maintainers; [ happysalada ]; + meta.doc = ./lemmy.md; + + imports = [ + (mkRemovedOptionModule [ "services" "lemmy" "jwtSecretPath" ] "As of v0.13.0, Lemmy auto-generates the JWT secret.") + ]; + + options.services.lemmy = { + + enable = mkEnableOption (lib.mdDoc "lemmy a federated alternative to reddit in rust"); + + ui = { + port = mkOption { + type = types.port; + default = 1234; + description = lib.mdDoc "Port where lemmy-ui should listen for incoming requests."; + }; + }; + + caddy.enable = mkEnableOption (lib.mdDoc "exposing lemmy with the caddy reverse proxy"); + + database.createLocally = mkEnableOption (lib.mdDoc "creation of database on the instance"); + + settings = mkOption { + default = { }; + description = lib.mdDoc "Lemmy configuration"; + + type = types.submodule { + freeformType = settingsFormat.type; + + options.hostname = mkOption { + type = types.str; + default = null; + description = lib.mdDoc "The domain name of your instance (eg 'lemmy.ml')."; + }; + + options.port = mkOption { + type = types.port; + default = 8536; + description = lib.mdDoc "Port where lemmy should listen for incoming requests."; + }; + + options.federation = { + enabled = mkEnableOption (lib.mdDoc "activitypub federation"); + }; + + options.captcha = { + enabled = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Enable Captcha."; + }; + difficulty = mkOption { + type = types.enum [ "easy" "medium" "hard" ]; + default = "medium"; + description = lib.mdDoc "The difficultly of the captcha to solve."; + }; + }; + }; + }; + + }; + + config = + lib.mkIf cfg.enable { + services.lemmy.settings = (mapAttrs (name: mkDefault) + { + bind = "127.0.0.1"; + tls_enabled = true; + pictrs_url = with config.services.pict-rs; "http://${address}:${toString port}"; + actor_name_max_length = 20; + + rate_limit.message = 180; + rate_limit.message_per_second = 60; + rate_limit.post = 6; + rate_limit.post_per_second = 600; + rate_limit.register = 3; + rate_limit.register_per_second = 3600; + rate_limit.image = 6; + rate_limit.image_per_second = 3600; + } // { + database = mapAttrs (name: mkDefault) { + user = "lemmy"; + host = "/run/postgresql"; + port = 5432; + database = "lemmy"; + pool_size = 5; + }; + }); + + services.postgresql = mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = [ cfg.settings.database.database ]; + ensureUsers = [{ + name = cfg.settings.database.user; + ensurePermissions."DATABASE ${cfg.settings.database.database}" = "ALL PRIVILEGES"; + }]; + }; + + services.pict-rs.enable = true; + + services.caddy = mkIf cfg.caddy.enable { + enable = mkDefault true; + virtualHosts."${cfg.settings.hostname}" = { + extraConfig = '' + handle_path /static/* { + root * ${pkgs.lemmy-ui}/dist + file_server + } + @for_backend { + path /api/* /pictrs/* /feeds/* /nodeinfo/* + } + handle @for_backend { + reverse_proxy 127.0.0.1:${toString cfg.settings.port} + } + @post { + method POST + } + handle @post { + reverse_proxy 127.0.0.1:${toString cfg.settings.port} + } + @jsonld { + header Accept "application/activity+json" + header Accept "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + } + handle @jsonld { + reverse_proxy 127.0.0.1:${toString cfg.settings.port} + } + handle { + reverse_proxy 127.0.0.1:${toString cfg.ui.port} + } + ''; + }; + }; + + assertions = [{ + assertion = cfg.database.createLocally -> cfg.settings.database.host == "localhost" || cfg.settings.database.host == "/run/postgresql"; + message = "if you want to create the database locally, you need to use a local database"; + }]; + + systemd.services.lemmy = { + description = "Lemmy server"; + + environment = { + LEMMY_CONFIG_LOCATION = "/run/lemmy/config.hjson"; + + # Verify how this is used, and don't put the password in the nix store + LEMMY_DATABASE_URL = with cfg.settings.database;"postgres:///${database}?host=${host}"; + }; + + documentation = [ + "https://join-lemmy.org/docs/en/admins/from_scratch.html" + "https://join-lemmy.org/docs/en/" + ]; + + wantedBy = [ "multi-user.target" ]; + + after = [ "pict-rs.service" ] ++ lib.optionals cfg.database.createLocally [ "postgresql.service" ]; + + requires = lib.optionals cfg.database.createLocally [ "postgresql.service" ]; + + serviceConfig = { + DynamicUser = true; + RuntimeDirectory = "lemmy"; + ExecStartPre = "${pkgs.coreutils}/bin/install -m 600 ${settingsFormat.generate "config.hjson" cfg.settings} /run/lemmy/config.hjson"; + ExecStart = "${pkgs.lemmy-server}/bin/lemmy_server"; + }; + }; + + systemd.services.lemmy-ui = { + description = "Lemmy ui"; + + environment = { + LEMMY_UI_HOST = "127.0.0.1:${toString cfg.ui.port}"; + LEMMY_INTERNAL_HOST = "127.0.0.1:${toString cfg.settings.port}"; + LEMMY_EXTERNAL_HOST = cfg.settings.hostname; + LEMMY_HTTPS = "false"; + }; + + documentation = [ + "https://join-lemmy.org/docs/en/admins/from_scratch.html" + "https://join-lemmy.org/docs/en/" + ]; + + wantedBy = [ "multi-user.target" ]; + + after = [ "lemmy.service" ]; + + requires = [ "lemmy.service" ]; + + serviceConfig = { + DynamicUser = true; + WorkingDirectory = "${pkgs.lemmy-ui}"; + ExecStart = "${pkgs.nodejs}/bin/node ${pkgs.lemmy-ui}/dist/js/server.js"; + }; + }; + }; + +} diff --git a/nixpkgs/nixos/modules/services/web-apps/limesurvey.nix b/nixpkgs/nixos/modules/services/web-apps/limesurvey.nix new file mode 100644 index 000000000000..920e6928ef5c --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/limesurvey.nix @@ -0,0 +1,309 @@ +{ config, lib, pkgs, ... }: + +let + + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption; + inherit (lib) literalExpression mapAttrs optional optionalString types; + + cfg = config.services.limesurvey; + fpm = config.services.phpfpm.pools.limesurvey; + + user = "limesurvey"; + group = config.services.httpd.group; + stateDir = "/var/lib/limesurvey"; + + pkg = pkgs.limesurvey; + + configType = with types; oneOf [ (attrsOf configType) str int bool ] // { + description = "limesurvey config type (str, int, bool or attribute set thereof)"; + }; + + limesurveyConfig = pkgs.writeText "config.php" '' + <?php + return json_decode('${builtins.toJSON cfg.config}', true); + ?> + ''; + + mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql"; + +in +{ + # interface + + options.services.limesurvey = { + enable = mkEnableOption (lib.mdDoc "Limesurvey web application"); + + encryptionKey = mkOption { + type = types.str; + default = "E17687FC77CEE247F0E22BB3ECF27FDE8BEC310A892347EC13013ABA11AA7EB5"; + description = lib.mdDoc '' + This is a 32-byte key used to encrypt variables in the database. + You _must_ change this from the default value. + ''; + }; + + encryptionNonce = mkOption { + type = types.str; + default = "1ACC8555619929DB91310BE848025A427B0F364A884FFA77"; + description = lib.mdDoc '' + This is a 24-byte nonce used to encrypt variables in the database. + You _must_ change this from the default value. + ''; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "pgsql" "odbc" "mssql" ]; + example = "pgsql"; + default = "mysql"; + description = lib.mdDoc "Database engine to use."; + }; + + dbEngine = mkOption { + type = types.enum [ "MyISAM" "InnoDB" ]; + default = "InnoDB"; + description = lib.mdDoc "Database storage engine to use."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + default = if cfg.database.type == "pgsql" then 5442 else 3306; + defaultText = literalExpression "3306"; + description = lib.mdDoc "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "limesurvey"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "limesurvey"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/limesurvey-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = + if mysqlLocal then "/run/mysqld/mysqld.sock" + else if pgsqlLocal then "/run/postgresql" + else null + ; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = lib.mdDoc "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = cfg.database.type == "mysql"; + defaultText = literalExpression "true"; + description = lib.mdDoc '' + Create the database and database user locally. + This currently only applies if database type "mysql" is selected. + ''; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "survey.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + Apache configuration can be done by adapting `services.httpd.virtualHosts.<name>`. + See [](#opt-services.httpd.virtualHosts) for further information. + ''; + }; + + 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 = lib.mdDoc '' + Options for the LimeSurvey PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + config = mkOption { + type = configType; + default = {}; + description = lib.mdDoc '' + LimeSurvey configuration. Refer to + <https://manual.limesurvey.org/Optional_settings> + for details on supported values. + ''; + }; + }; + + # implementation + + config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.type == "mysql"; + message = "services.limesurvey.createLocally is currently only supported for database type 'mysql'"; + } + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.limesurvey.database.user must be set to ${user} if services.limesurvey.database.createLocally is set true"; + } + { assertion = cfg.database.createLocally -> cfg.database.socket != null; + message = "services.limesurvey.database.socket must be set if services.limesurvey.database.createLocally is set to true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.limesurvey.database.createLocally is set to true"; + } + ]; + + services.limesurvey.config = mapAttrs (name: mkDefault) { + runtimePath = "${stateDir}/tmp/runtime"; + components = { + db = { + connectionString = "${cfg.database.type}:dbname=${cfg.database.name};host=${if pgsqlLocal then cfg.database.socket else cfg.database.host};port=${toString cfg.database.port}" + + optionalString mysqlLocal ";socket=${cfg.database.socket}"; + username = cfg.database.user; + password = mkIf (cfg.database.passwordFile != null) "file_get_contents(\"${toString cfg.database.passwordFile}\");"; + tablePrefix = "limesurvey_"; + }; + assetManager.basePath = "${stateDir}/tmp/assets"; + urlManager = { + urlFormat = "path"; + showScriptName = false; + }; + }; + config = { + tempdir = "${stateDir}/tmp"; + uploaddir = "${stateDir}/upload"; + encryptionnonce = cfg.encryptionNonce; + encryptionsecretboxkey = cfg.encryptionKey; + force_ssl = mkIf (cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL) "on"; + config.defaultlang = "en"; + }; + }; + + services.mysql = mkIf mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "SELECT, CREATE, INSERT, UPDATE, DELETE, ALTER, DROP, INDEX"; + }; + } + ]; + }; + + services.phpfpm.pools.limesurvey = { + inherit user group; + phpPackage = pkgs.php81; + phpEnv.DBENGINE = "${cfg.database.dbEngine}"; + phpEnv.LIMESURVEY_CONFIG = "${limesurveyConfig}"; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + adminAddr = mkDefault cfg.virtualHost.adminAddr; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${pkg}/share/limesurvey"; + extraConfig = '' + Alias "/tmp" "${stateDir}/tmp" + <Directory "${stateDir}"> + AllowOverride all + Require all granted + Options -Indexes +FollowSymlinks + </Directory> + + Alias "/upload" "${stateDir}/upload" + <Directory "${stateDir}/upload"> + AllowOverride all + Require all granted + Options -Indexes + </Directory> + + <Directory "${pkg}/share/limesurvey"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + + AllowOverride all + Options -Indexes + DirectoryIndex index.php + </Directory> + ''; + } ]; + }; + + systemd.tmpfiles.rules = [ + "d ${stateDir} 0750 ${user} ${group} - -" + "d ${stateDir}/tmp 0750 ${user} ${group} - -" + "d ${stateDir}/tmp/assets 0750 ${user} ${group} - -" + "d ${stateDir}/tmp/runtime 0750 ${user} ${group} - -" + "d ${stateDir}/tmp/upload 0750 ${user} ${group} - -" + "C ${stateDir}/upload 0750 ${user} ${group} - ${pkg}/share/limesurvey/upload" + ]; + + systemd.services.limesurvey-init = { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-limesurvey.service" ]; + after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + environment.DBENGINE = "${cfg.database.dbEngine}"; + environment.LIMESURVEY_CONFIG = limesurveyConfig; + script = '' + # update or install the database as required + ${pkgs.php81}/bin/php ${pkg}/share/limesurvey/application/commands/console.php updatedb || \ + ${pkgs.php81}/bin/php ${pkg}/share/limesurvey/application/commands/console.php install admin password admin admin@example.com verbose + ''; + serviceConfig = { + User = user; + Group = group; + Type = "oneshot"; + }; + }; + + systemd.services.httpd.after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + + users.users.${user} = { + group = group; + isSystemUser = true; + }; + + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/mainsail.nix b/nixpkgs/nixos/modules/services/web-apps/mainsail.nix new file mode 100644 index 000000000000..f335d9b015d4 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/mainsail.nix @@ -0,0 +1,66 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.mainsail; + moonraker = config.services.moonraker; +in +{ + options.services.mainsail = { + enable = mkEnableOption (lib.mdDoc "a modern and responsive user interface for Klipper"); + + package = mkOption { + type = types.package; + description = lib.mdDoc "Mainsail package to be used in the module"; + default = pkgs.mainsail; + defaultText = literalExpression "pkgs.mainsail"; + }; + + hostName = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Hostname to serve mainsail on"; + }; + + nginx = mkOption { + type = types.submodule + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }); + default = { }; + example = literalExpression '' + { + serverAliases = [ "mainsail.''${config.networking.domain}" ]; + } + ''; + description = lib.mdDoc "Extra configuration for the nginx virtual host of mainsail."; + }; + }; + + config = mkIf cfg.enable { + services.nginx = { + enable = true; + upstreams.mainsail-apiserver.servers."${moonraker.address}:${toString moonraker.port}" = { }; + virtualHosts."${cfg.hostName}" = mkMerge [ + cfg.nginx + { + root = mkForce "${cfg.package}/share/mainsail"; + 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://mainsail-apiserver/websocket"; + }; + "~ ^/(printer|api|access|machine|server)/" = { + proxyWebsockets = true; + proxyPass = "http://mainsail-apiserver$request_uri"; + }; + }; + } + ]; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/mastodon.nix b/nixpkgs/nixos/modules/services/web-apps/mastodon.nix new file mode 100644 index 000000000000..2ad6cd6aae19 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/mastodon.nix @@ -0,0 +1,829 @@ +{ lib, pkgs, config, options, ... }: + +let + cfg = config.services.mastodon; + opt = options.services.mastodon; + + # We only want to create a database if we're actually going to connect to it. + databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "/run/postgresql"; + + env = { + RAILS_ENV = "production"; + NODE_ENV = "production"; + + LD_PRELOAD = "${pkgs.jemalloc}/lib/libjemalloc.so"; + + # mastodon-web concurrency. + WEB_CONCURRENCY = toString cfg.webProcesses; + MAX_THREADS = toString cfg.webThreads; + + # mastodon-streaming concurrency. + STREAMING_CLUSTER_NUM = toString cfg.streamingProcesses; + + DB_USER = cfg.database.user; + + REDIS_HOST = cfg.redis.host; + REDIS_PORT = toString(cfg.redis.port); + DB_HOST = cfg.database.host; + DB_NAME = cfg.database.name; + LOCAL_DOMAIN = cfg.localDomain; + SMTP_SERVER = cfg.smtp.host; + SMTP_PORT = toString(cfg.smtp.port); + SMTP_FROM_ADDRESS = cfg.smtp.fromAddress; + PAPERCLIP_ROOT_PATH = "/var/lib/mastodon/public-system"; + PAPERCLIP_ROOT_URL = "/system"; + ES_ENABLED = if (cfg.elasticsearch.host != null) then "true" else "false"; + ES_HOST = cfg.elasticsearch.host; + ES_PORT = toString(cfg.elasticsearch.port); + + TRUSTED_PROXY_IP = cfg.trustedProxy; + } + // lib.optionalAttrs (cfg.database.host != "/run/postgresql" && cfg.database.port != null) { DB_PORT = toString cfg.database.port; } + // lib.optionalAttrs cfg.smtp.authenticate { SMTP_LOGIN = cfg.smtp.user; } + // cfg.extraConfig; + + systemCallsList = [ "@cpu-emulation" "@debug" "@keyring" "@ipc" "@mount" "@obsolete" "@privileged" "@setuid" ]; + + cfgService = { + # User and group + User = cfg.user; + Group = cfg.group; + # Working directory + WorkingDirectory = cfg.package; + # State directory and mode + StateDirectory = "mastodon"; + StateDirectoryMode = "0750"; + # Logs directory and mode + LogsDirectory = "mastodon"; + LogsDirectoryMode = "0750"; + # Proc filesystem + ProcSubset = "pid"; + ProtectProc = "invisible"; + # Access write directories + UMask = "0027"; + # Capabilities + CapabilityBoundingSet = ""; + # Security + NoNewPrivileges = true; + # Sandboxing + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = false; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + PrivateMounts = true; + # System Call Filtering + SystemCallArchitectures = "native"; + }; + + envFile = pkgs.writeText "mastodon.env" (lib.concatMapStrings (s: s + "\n") ( + (lib.concatLists (lib.mapAttrsToList (name: value: + if value != null then [ + "${name}=\"${toString value}\"" + ] else [] + ) env)))); + + mastodonTootctl = let + sourceExtraEnv = lib.concatMapStrings (p: "source ${p}\n") cfg.extraEnvFiles; + in pkgs.writeShellScriptBin "mastodon-tootctl" '' + set -a + export RAILS_ROOT="${cfg.package}" + source "${envFile}" + source /var/lib/mastodon/.secrets_env + ${sourceExtraEnv} + + sudo=exec + if [[ "$USER" != ${cfg.user} ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} --preserve-env' + fi + $sudo ${cfg.package}/bin/tootctl "$@" + ''; + + sidekiqUnits = lib.attrsets.mapAttrs' (name: processCfg: + lib.nameValuePair "mastodon-sidekiq-${name}" (let + jobClassArgs = toString (builtins.map (c: "-q ${c}") processCfg.jobClasses); + jobClassLabel = toString ([""] ++ processCfg.jobClasses); + threads = toString (if processCfg.threads == null then cfg.sidekiqThreads else processCfg.threads); + in { + after = [ "network.target" "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service" + ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service"; + requires = [ "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service" + ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service"; + description = "Mastodon sidekiq${jobClassLabel}"; + wantedBy = [ "mastodon.target" ]; + environment = env // { + PORT = toString(cfg.sidekiqPort); + DB_POOL = threads; + }; + serviceConfig = { + ExecStart = "${cfg.package}/bin/sidekiq ${jobClassArgs} -c ${threads} -r ${cfg.package}"; + Restart = "always"; + RestartSec = 20; + EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles; + WorkingDirectory = cfg.package; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ]; + } // cfgService; + path = with pkgs; [ file imagemagick ffmpeg ]; + }) + ) cfg.sidekiqProcesses; + +in { + + options = { + services.mastodon = { + enable = lib.mkEnableOption (lib.mdDoc "Mastodon, a federated social network server"); + + configureNginx = lib.mkOption { + description = lib.mdDoc '' + Configure nginx as a reverse proxy for mastodon. + 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 to serve these paths: + + `/ -> $(nix-instantiate --eval '<nixpkgs>' -A mastodon.outPath)/public` + + `/ -> 127.0.0.1:{{ webPort }} `(If there was no file in the directory above.) + + `/system/ -> /var/lib/mastodon/public-system/` + + `/api/v1/streaming/ -> 127.0.0.1:{{ streamingPort }}` + + Make sure that websockets are forwarded properly. You might want to set up caching + of some requests. Take a look at mastodon's provided nginx configuration at + `https://github.com/mastodon/mastodon/blob/master/dist/nginx.conf`. + ''; + type = lib.types.bool; + default = false; + }; + + user = lib.mkOption { + description = lib.mdDoc '' + User under which mastodon runs. If it is set to "mastodon", + that user will be created, otherwise it should be set to the + name of a user created elsewhere. + In both cases, the `mastodon` package will be added to the user's package set + and a tootctl wrapper to system packages that switches to the configured account + and load the right environment. + ''; + type = lib.types.str; + default = "mastodon"; + }; + + group = lib.mkOption { + description = lib.mdDoc '' + Group under which mastodon runs. + ''; + type = lib.types.str; + default = "mastodon"; + }; + + streamingPort = lib.mkOption { + description = lib.mdDoc "TCP port used by the mastodon-streaming service."; + type = lib.types.port; + default = 55000; + }; + streamingProcesses = lib.mkOption { + description = lib.mdDoc '' + Processes used by the mastodon-streaming service. + Defaults to the number of CPU cores minus one. + ''; + type = lib.types.nullOr lib.types.int; + default = null; + }; + + webPort = lib.mkOption { + description = lib.mdDoc "TCP port used by the mastodon-web service."; + type = lib.types.port; + default = 55001; + }; + webProcesses = lib.mkOption { + description = lib.mdDoc "Processes used by the mastodon-web service."; + type = lib.types.int; + default = 2; + }; + webThreads = lib.mkOption { + description = lib.mdDoc "Threads per process used by the mastodon-web service."; + type = lib.types.int; + default = 5; + }; + + sidekiqPort = lib.mkOption { + description = lib.mdDoc "TCP port used by the mastodon-sidekiq service."; + type = lib.types.port; + default = 55002; + }; + + sidekiqThreads = lib.mkOption { + description = lib.mdDoc "Worker threads used by the mastodon-sidekiq-all service. If `sidekiqProcesses` is configured and any processes specify null `threads`, this value is used."; + type = lib.types.int; + default = 25; + }; + + sidekiqProcesses = lib.mkOption { + description = lib.mdDoc "How many Sidekiq processes should be used to handle background jobs, and which job classes they handle. *Read the [upstream documentation](https://docs.joinmastodon.org/admin/scaling/#sidekiq) before configuring this!*"; + type = with lib.types; attrsOf (submodule { + options = { + jobClasses = lib.mkOption { + type = listOf (enum [ "default" "push" "pull" "mailers" "scheduler" "ingress" ]); + description = lib.mdDoc "If not empty, which job classes should be executed by this process. *Only one process should handle the 'scheduler' class. If left empty, this process will handle the 'scheduler' class.*"; + }; + threads = lib.mkOption { + type = nullOr int; + description = lib.mdDoc "Number of threads this process should use for executing jobs. If null, the configured `sidekiqThreads` are used."; + }; + }; + }); + default = { + all = { + jobClasses = [ ]; + threads = null; + }; + }; + example = { + all = { + jobClasses = [ ]; + threads = null; + }; + ingress = { + jobClasses = [ "ingress" ]; + threads = 5; + }; + default = { + jobClasses = [ "default" ]; + threads = 10; + }; + push-pull = { + jobClasses = [ "push" "pull" ]; + threads = 5; + }; + }; + }; + + vapidPublicKeyFile = lib.mkOption { + description = lib.mdDoc '' + Path to file containing the public key used for Web Push + Voluntary Application Server Identification. A new keypair can + be generated by running: + + `nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys` + + If {option}`mastodon.vapidPrivateKeyFile`does not + exist, it and this file will be created with a new keypair. + ''; + default = "/var/lib/mastodon/secrets/vapid-public-key"; + type = lib.types.str; + }; + + localDomain = lib.mkOption { + description = lib.mdDoc "The domain serving your Mastodon instance."; + example = "social.example.org"; + type = lib.types.str; + }; + + secretKeyBaseFile = lib.mkOption { + description = lib.mdDoc '' + Path to file containing the secret key base. + A new secret key base can be generated by running: + + `nix build -f '<nixpkgs>' mastodon; cd result; bin/rake secret` + + If this file does not exist, it will be created with a new secret key base. + ''; + default = "/var/lib/mastodon/secrets/secret-key-base"; + type = lib.types.str; + }; + + otpSecretFile = lib.mkOption { + description = lib.mdDoc '' + Path to file containing the OTP secret. + A new OTP secret can be generated by running: + + `nix build -f '<nixpkgs>' mastodon; cd result; bin/rake secret` + + If this file does not exist, it will be created with a new OTP secret. + ''; + default = "/var/lib/mastodon/secrets/otp-secret"; + type = lib.types.str; + }; + + vapidPrivateKeyFile = lib.mkOption { + description = lib.mdDoc '' + Path to file containing the private key used for Web Push + Voluntary Application Server Identification. A new keypair can + be generated by running: + + `nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys` + + If this file does not exist, it will be created with a new + private key. + ''; + default = "/var/lib/mastodon/secrets/vapid-private-key"; + type = lib.types.str; + }; + + trustedProxy = lib.mkOption { + description = lib.mdDoc '' + You need to set it to the IP from which your reverse proxy sends requests to Mastodon's web process, + otherwise Mastodon will record the reverse proxy's own IP as the IP of all requests, which would be + bad because IP addresses are used for important rate limits and security functions. + ''; + type = lib.types.str; + default = "127.0.0.1"; + }; + + enableUnixSocket = lib.mkOption { + description = lib.mdDoc '' + Instead of binding to an IP address like 127.0.0.1, you may bind to a Unix socket. This variable + is process-specific, e.g. you need different values for every process, and it works for both web (Puma) + processes and streaming API (Node.js) processes. + ''; + type = lib.types.bool; + default = true; + }; + + redis = { + createLocally = lib.mkOption { + description = lib.mdDoc "Configure local Redis server for Mastodon."; + type = lib.types.bool; + default = true; + }; + + host = lib.mkOption { + description = lib.mdDoc "Redis host."; + type = lib.types.str; + default = "127.0.0.1"; + }; + + port = lib.mkOption { + description = lib.mdDoc "Redis port."; + type = lib.types.port; + default = 31637; + }; + }; + + database = { + createLocally = lib.mkOption { + description = lib.mdDoc "Configure local PostgreSQL database server for Mastodon."; + type = lib.types.bool; + default = true; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "/run/postgresql"; + example = "192.168.23.42"; + description = lib.mdDoc "Database host address or unix socket."; + }; + + port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = if cfg.database.createLocally then null else 5432; + defaultText = lib.literalExpression '' + if config.${opt.database.createLocally} + then null + else 5432 + ''; + description = lib.mdDoc "Database host port."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "mastodon"; + description = lib.mdDoc "Database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "mastodon"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/mastodon/secrets/db-password"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + }; + + smtp = { + createLocally = lib.mkOption { + description = lib.mdDoc "Configure local Postfix SMTP server for Mastodon."; + type = lib.types.bool; + default = true; + }; + + authenticate = lib.mkOption { + description = lib.mdDoc "Authenticate with the SMTP server using username and password."; + type = lib.types.bool; + default = false; + }; + + host = lib.mkOption { + description = lib.mdDoc "SMTP host used when sending emails to users."; + type = lib.types.str; + default = "127.0.0.1"; + }; + + port = lib.mkOption { + description = lib.mdDoc "SMTP port used when sending emails to users."; + type = lib.types.port; + default = 25; + }; + + fromAddress = lib.mkOption { + description = lib.mdDoc ''"From" address used when sending Emails to users.''; + type = lib.types.str; + }; + + user = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "mastodon@example.com"; + description = lib.mdDoc "SMTP login name."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/mastodon/secrets/smtp-password"; + description = lib.mdDoc '' + Path to file containing the SMTP password. + ''; + }; + }; + + elasticsearch = { + host = lib.mkOption { + description = lib.mdDoc '' + Elasticsearch host. + If it is not null, Elasticsearch full text search will be enabled. + ''; + type = lib.types.nullOr lib.types.str; + default = null; + }; + + port = lib.mkOption { + description = lib.mdDoc "Elasticsearch port."; + type = lib.types.port; + default = 9200; + }; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.mastodon; + defaultText = lib.literalExpression "pkgs.mastodon"; + description = lib.mdDoc "Mastodon package to use."; + }; + + extraConfig = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = lib.mdDoc '' + Extra environment variables to pass to all mastodon services. + ''; + }; + + extraEnvFiles = lib.mkOption { + type = with lib.types; listOf path; + default = []; + description = lib.mdDoc '' + Extra environment files to pass to all mastodon services. Useful for passing down environmental secrets. + ''; + example = [ "/etc/mastodon/s3config.env" ]; + }; + + automaticMigrations = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc '' + Do automatic database migrations. + ''; + }; + + mediaAutoRemove = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = lib.mdDoc '' + Automatically remove remote media attachments and preview cards older than the configured amount of days. + + Recommended in https://docs.joinmastodon.org/admin/setup/. + ''; + }; + + startAt = lib.mkOption { + type = lib.types.str; + default = "daily"; + example = "hourly"; + description = lib.mdDoc '' + How often to remove remote media. + + The format is described in {manpage}`systemd.time(7)`. + ''; + }; + + olderThanDays = lib.mkOption { + type = lib.types.int; + default = 30; + example = 14; + description = lib.mdDoc '' + How old remote media needs to be in order to be removed. + ''; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [{ + assertions = [ + { + assertion = databaseActuallyCreateLocally -> (cfg.user == cfg.database.user); + message = '' + For local automatic database provisioning (services.mastodon.database.createLocally == true) with peer + authentication (services.mastodon.database.host == "/run/postgresql") to work services.mastodon.user + and services.mastodon.database.user must be identical. + ''; + } + { + assertion = !databaseActuallyCreateLocally -> (cfg.database.host != "/run/postgresql"); + message = '' + <option>services.mastodon.database.host</option> needs to be set if + <option>services.mastodon.database.createLocally</option> is not enabled. + ''; + } + { + assertion = cfg.smtp.authenticate -> (cfg.smtp.user != null); + message = '' + <option>services.mastodon.smtp.user</option> needs to be set if + <option>services.mastodon.smtp.authenticate</option> is enabled. + ''; + } + { + assertion = cfg.smtp.authenticate -> (cfg.smtp.passwordFile != null); + message = '' + <option>services.mastodon.smtp.passwordFile</option> needs to be set if + <option>services.mastodon.smtp.authenticate</option> is enabled. + ''; + } + { + assertion = 1 == + (lib.count (x: x) + (lib.mapAttrsToList + (_: v: builtins.elem "scheduler" v.jobClasses || v.jobClasses == [ ]) + cfg.sidekiqProcesses)); + message = "There must be exactly one Sidekiq queue in services.mastodon.sidekiqProcesses with jobClass \"scheduler\"."; + } + ]; + + environment.systemPackages = [ mastodonTootctl ]; + + systemd.targets.mastodon = { + description = "Target for all Mastodon services"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + }; + + systemd.services.mastodon-init-dirs = { + script = '' + umask 077 + + if ! test -f ${cfg.secretKeyBaseFile}; then + mkdir -p $(dirname ${cfg.secretKeyBaseFile}) + bin/rake secret > ${cfg.secretKeyBaseFile} + fi + if ! test -f ${cfg.otpSecretFile}; then + mkdir -p $(dirname ${cfg.otpSecretFile}) + bin/rake secret > ${cfg.otpSecretFile} + fi + if ! test -f ${cfg.vapidPrivateKeyFile}; then + mkdir -p $(dirname ${cfg.vapidPrivateKeyFile}) $(dirname ${cfg.vapidPublicKeyFile}) + keypair=$(bin/rake webpush:generate_keys) + echo $keypair | grep --only-matching "Private -> [^ ]\+" | sed 's/^Private -> //' > ${cfg.vapidPrivateKeyFile} + echo $keypair | grep --only-matching "Public -> [^ ]\+" | sed 's/^Public -> //' > ${cfg.vapidPublicKeyFile} + fi + + cat > /var/lib/mastodon/.secrets_env <<EOF + SECRET_KEY_BASE="$(cat ${cfg.secretKeyBaseFile})" + OTP_SECRET="$(cat ${cfg.otpSecretFile})" + VAPID_PRIVATE_KEY="$(cat ${cfg.vapidPrivateKeyFile})" + VAPID_PUBLIC_KEY="$(cat ${cfg.vapidPublicKeyFile})" + '' + lib.optionalString (cfg.database.passwordFile != null) '' + DB_PASS="$(cat ${cfg.database.passwordFile})" + '' + lib.optionalString cfg.smtp.authenticate '' + SMTP_PASSWORD="$(cat ${cfg.smtp.passwordFile})" + '' + '' + EOF + ''; + environment = env; + serviceConfig = { + Type = "oneshot"; + SyslogIdentifier = "mastodon-init-dirs"; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ])) "@chown" "pipe" "pipe2" ]; + } // cfgService; + + after = [ "network.target" ]; + }; + + systemd.services.mastodon-init-db = lib.mkIf cfg.automaticMigrations { + script = lib.optionalString (!databaseActuallyCreateLocally) '' + umask 077 + + export PGPASSFILE + PGPASSFILE=$(mktemp) + cat > $PGPASSFILE <<EOF + ${cfg.database.host}:${toString cfg.database.port}:${cfg.database.name}:${cfg.database.user}:$(cat ${cfg.database.passwordFile}) + EOF + + '' + '' + if [ `psql ${cfg.database.name} -c \ + "select count(*) from pg_class c \ + join pg_namespace s on s.oid = c.relnamespace \ + where s.nspname not in ('pg_catalog', 'pg_toast', 'information_schema') \ + and s.nspname not like 'pg_temp%';" | sed -n 3p` -eq 0 ]; then + SAFETY_ASSURED=1 rails db:schema:load + rails db:seed + else + rails db:migrate + fi + '' + lib.optionalString (!databaseActuallyCreateLocally) '' + rm $PGPASSFILE + unset PGPASSFILE + ''; + path = [ cfg.package pkgs.postgresql ]; + environment = env // lib.optionalAttrs (!databaseActuallyCreateLocally) { + PGHOST = cfg.database.host; + PGUSER = cfg.database.user; + }; + serviceConfig = { + Type = "oneshot"; + EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles; + WorkingDirectory = cfg.package; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ])) "@chown" "pipe" "pipe2" ]; + } // cfgService; + after = [ "network.target" "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service"; + requires = [ "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service"; + }; + + systemd.services.mastodon-streaming = { + after = [ "network.target" "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service" + ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service"; + requires = [ "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service" + ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service"; + wantedBy = [ "mastodon.target" ]; + description = "Mastodon streaming"; + environment = env // (if cfg.enableUnixSocket + then { SOCKET = "/run/mastodon-streaming/streaming.socket"; } + else { PORT = toString(cfg.streamingPort); } + ); + serviceConfig = { + ExecStart = "${cfg.package}/run-streaming.sh"; + Restart = "always"; + RestartSec = 20; + EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles; + WorkingDirectory = cfg.package; + # Runtime directory and mode + RuntimeDirectory = "mastodon-streaming"; + RuntimeDirectoryMode = "0750"; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@memlock" "@resources" ])) "pipe" "pipe2" ]; + } // cfgService; + }; + + systemd.services.mastodon-web = { + after = [ "network.target" "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service" + ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service"; + requires = [ "mastodon-init-dirs.service" ] + ++ lib.optional databaseActuallyCreateLocally "postgresql.service" + ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service"; + wantedBy = [ "mastodon.target" ]; + description = "Mastodon web"; + environment = env // (if cfg.enableUnixSocket + then { SOCKET = "/run/mastodon-web/web.socket"; } + else { PORT = toString(cfg.webPort); } + ); + serviceConfig = { + ExecStart = "${cfg.package}/bin/puma -C config/puma.rb"; + Restart = "always"; + RestartSec = 20; + EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles; + WorkingDirectory = cfg.package; + # Runtime directory and mode + RuntimeDirectory = "mastodon-web"; + RuntimeDirectoryMode = "0750"; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ]; + } // cfgService; + path = with pkgs; [ file imagemagick ffmpeg ]; + }; + + systemd.services.mastodon-media-auto-remove = lib.mkIf cfg.mediaAutoRemove.enable { + description = "Mastodon media auto remove"; + environment = env; + serviceConfig = { + Type = "oneshot"; + EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles; + } // cfgService; + script = let + olderThanDays = toString cfg.mediaAutoRemove.olderThanDays; + in '' + ${cfg.package}/bin/tootctl media remove --days=${olderThanDays} + ${cfg.package}/bin/tootctl preview_cards remove --days=${olderThanDays} + ''; + startAt = cfg.mediaAutoRemove.startAt; + }; + + services.nginx = lib.mkIf cfg.configureNginx { + enable = true; + recommendedProxySettings = true; # required for redirections to work + virtualHosts."${cfg.localDomain}" = { + root = "${cfg.package}/public/"; + # mastodon only supports https, but you can override this if you offload tls elsewhere. + forceSSL = lib.mkDefault true; + enableACME = lib.mkDefault true; + + locations."/system/".alias = "/var/lib/mastodon/public-system/"; + + locations."/" = { + tryFiles = "$uri @proxy"; + }; + + locations."@proxy" = { + proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-web/web.socket" else "http://127.0.0.1:${toString(cfg.webPort)}"); + proxyWebsockets = true; + }; + + locations."/api/v1/streaming/" = { + proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-streaming/streaming.socket" else "http://127.0.0.1:${toString(cfg.streamingPort)}/"); + proxyWebsockets = true; + }; + }; + }; + + services.postfix = lib.mkIf (cfg.smtp.createLocally && cfg.smtp.host == "127.0.0.1") { + enable = true; + hostname = lib.mkDefault "${cfg.localDomain}"; + }; + services.redis.servers.mastodon = lib.mkIf (cfg.redis.createLocally && cfg.redis.host == "127.0.0.1") { + enable = true; + port = cfg.redis.port; + bind = "127.0.0.1"; + }; + services.postgresql = lib.mkIf databaseActuallyCreateLocally { + enable = true; + ensureUsers = [ + { + name = cfg.database.user; + ensurePermissions."DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; + } + ]; + ensureDatabases = [ cfg.database.name ]; + }; + + users.users = lib.mkMerge [ + (lib.mkIf (cfg.user == "mastodon") { + mastodon = { + isSystemUser = true; + home = cfg.package; + inherit (cfg) group; + }; + }) + (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package pkgs.imagemagick ]) + ]; + + users.groups.${cfg.group}.members = lib.optional cfg.configureNginx config.services.nginx.user; + } + { systemd.services = sidekiqUnits; } + ]); + + meta.maintainers = with lib.maintainers; [ happy-river erictapen ]; + +} diff --git a/nixpkgs/nixos/modules/services/web-apps/matomo.md b/nixpkgs/nixos/modules/services/web-apps/matomo.md new file mode 100644 index 000000000000..e750c0c14775 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/matomo.md @@ -0,0 +1,77 @@ +# Matomo {#module-services-matomo} + +Matomo is a real-time web analytics application. This module configures +php-fpm as backend for Matomo, optionally configuring an nginx vhost as well. + +An automatic setup is not supported by Matomo, so you need to configure Matomo +itself in the browser-based Matomo setup. + +## Database Setup {#module-services-matomo-database-setup} + +You also need to configure a MariaDB or MySQL database and -user for Matomo +yourself, and enter those credentials in your browser. You can use +passwordless database authentication via the UNIX_SOCKET authentication +plugin with the following SQL commands: +``` +# For MariaDB +INSTALL PLUGIN unix_socket SONAME 'auth_socket'; +CREATE DATABASE matomo; +CREATE USER 'matomo'@'localhost' IDENTIFIED WITH unix_socket; +GRANT ALL PRIVILEGES ON matomo.* TO 'matomo'@'localhost'; + +# For MySQL +INSTALL PLUGIN auth_socket SONAME 'auth_socket.so'; +CREATE DATABASE matomo; +CREATE USER 'matomo'@'localhost' IDENTIFIED WITH auth_socket; +GRANT ALL PRIVILEGES ON matomo.* TO 'matomo'@'localhost'; +``` +Then fill in `matomo` as database user and database name, +and leave the password field blank. This authentication works by allowing +only the `matomo` unix user to authenticate as the +`matomo` database user (without needing a password), but no +other users. For more information on passwordless login, see +<https://mariadb.com/kb/en/mariadb/unix_socket-authentication-plugin/>. + +Of course, you can use password based authentication as well, e.g. when the +database is not on the same host. + +## Archive Processing {#module-services-matomo-archive-processing} + +This module comes with the systemd service +`matomo-archive-processing.service` and a timer that +automatically triggers archive processing every hour. This means that you +can safely +[disable browser triggers for Matomo archiving]( +https://matomo.org/docs/setup-auto-archiving/#disable-browser-triggers-for-matomo-archiving-and-limit-matomo-reports-to-updating-every-hour +) at +`Administration > System > General Settings`. + +With automatic archive processing, you can now also enable to +[delete old visitor logs](https://matomo.org/docs/privacy/#step-2-delete-old-visitors-logs) +at `Administration > System > Privacy`, but make sure that you run `systemctl start +matomo-archive-processing.service` at least once without errors if +you have already collected data before, so that the reports get archived +before the source data gets deleted. + +## Backup {#module-services-matomo-backups} + +You only need to take backups of your MySQL database and the +{file}`/var/lib/matomo/config/config.ini.php` file. Use a user +in the `matomo` group or root to access the file. For more +information, see +<https://matomo.org/faq/how-to-install/faq_138/>. + +## Issues {#module-services-matomo-issues} + + - Matomo will warn you that the JavaScript tracker is not writable. This is + because it's located in the read-only nix store. You can safely ignore + this, unless you need a plugin that needs JavaScript tracker access. + +## Using other Web Servers than nginx {#module-services-matomo-other-web-servers} + +You can use other web servers by forwarding calls for +{file}`index.php` and {file}`piwik.php` to the +[`services.phpfpm.pools.<name>.socket`](#opt-services.phpfpm.pools._name_.socket) +fastcgi unix socket. You can use +the nginx configuration in the module code as a reference to what else +should be configured. diff --git a/nixpkgs/nixos/modules/services/web-apps/matomo.nix b/nixpkgs/nixos/modules/services/web-apps/matomo.nix new file mode 100644 index 000000000000..eadf8b62b977 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/matomo.nix @@ -0,0 +1,331 @@ +{ config, lib, options, pkgs, ... }: +with lib; +let + cfg = config.services.matomo; + fpm = config.services.phpfpm.pools.${pool}; + + user = "matomo"; + dataDir = "/var/lib/${user}"; + deprecatedDataDir = "/var/lib/piwik"; + + pool = user; + phpExecutionUnit = "phpfpm-${pool}"; + databaseService = "mysql.service"; + +in { + imports = [ + (mkRenamedOptionModule [ "services" "piwik" "enable" ] [ "services" "matomo" "enable" ]) + (mkRenamedOptionModule [ "services" "piwik" "webServerUser" ] [ "services" "matomo" "webServerUser" ]) + (mkRemovedOptionModule [ "services" "piwik" "phpfpmProcessManagerConfig" ] "Use services.phpfpm.pools.<name>.settings") + (mkRemovedOptionModule [ "services" "matomo" "phpfpmProcessManagerConfig" ] "Use services.phpfpm.pools.<name>.settings") + (mkRenamedOptionModule [ "services" "piwik" "nginx" ] [ "services" "matomo" "nginx" ]) + (mkRenamedOptionModule [ "services" "matomo" "periodicArchiveProcessingUrl" ] [ "services" "matomo" "hostname" ]) + ]; + + options = { + services.matomo = { + # NixOS PR for database setup: https://github.com/NixOS/nixpkgs/pull/6963 + # Matomo issue for automatic Matomo setup: https://github.com/matomo-org/matomo/issues/10257 + # TODO: find a nice way to do this when more NixOS MySQL and / or Matomo automatic setup stuff is implemented. + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable Matomo web analytics with php-fpm backend. + Either the nginx option or the webServerUser option is mandatory. + ''; + }; + + package = mkOption { + type = types.package; + description = lib.mdDoc '' + Matomo package for the service to use. + This can be used to point to newer releases from nixos-unstable, + as they don't get backported if they are not security-relevant. + ''; + default = pkgs.matomo; + defaultText = literalExpression "pkgs.matomo"; + }; + + webServerUser = mkOption { + type = types.nullOr types.str; + default = null; + example = "lighttpd"; + description = lib.mdDoc '' + Name of the web server user that forwards requests to {option}`services.phpfpm.pools.<name>.socket` the fastcgi socket for Matomo if the nginx + option is not used. Either this option or the nginx option is mandatory. + If you want to use another webserver than nginx, you need to set this to that server's user + and pass fastcgi requests to `index.php`, `matomo.php` and `piwik.php` (legacy name) to this socket. + ''; + }; + + periodicArchiveProcessing = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Enable periodic archive processing, which generates aggregated reports from the visits. + + This means that you can safely disable browser triggers for Matomo archiving, + and safely enable to delete old visitor logs. + Before deleting visitor logs, + make sure though that you run `systemctl start matomo-archive-processing.service` + at least once without errors if you have already collected data before. + ''; + }; + + hostname = mkOption { + type = types.str; + default = "${user}.${config.networking.fqdnOrHostName}"; + defaultText = literalExpression '' + "${user}.''${config.${options.networking.fqdnOrHostName}}" + ''; + example = "matomo.yourdomain.org"; + description = lib.mdDoc '' + URL of the host, without https prefix. You may want to change it if you + run Matomo on a different URL than matomo.yourdomain. + ''; + }; + + nginx = mkOption { + type = types.nullOr (types.submodule ( + recursiveUpdate + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) + { + # enable encryption by default, + # as sensitive login and Matomo data should not be transmitted in clear text. + options.forceSSL.default = true; + options.enableACME.default = true; + } + ) + ); + default = null; + example = literalExpression '' + { + serverAliases = [ + "matomo.''${config.networking.domain}" + "stats.''${config.networking.domain}" + ]; + enableACME = false; + } + ''; + description = lib.mdDoc '' + With this option, you can customize an nginx virtualHost which already has sensible defaults for Matomo. + Either this option or the webServerUser option is mandatory. + Set this to {} to just enable the virtualHost if you don't need any customization. + If enabled, then by default, the {option}`serverName` is + `''${user}.''${config.networking.hostName}.''${config.networking.domain}`, + SSL is active, and certificates are acquired via ACME. + If this is set to null (the default), no nginx virtualHost will be configured. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + warnings = mkIf (cfg.nginx != null && cfg.webServerUser != null) [ + "If services.matomo.nginx is set, services.matomo.nginx.webServerUser is ignored and should be removed." + ]; + + assertions = [ { + assertion = cfg.nginx != null || cfg.webServerUser != null; + message = "Either services.matomo.nginx or services.matomo.nginx.webServerUser is mandatory"; + }]; + + users.users.${user} = { + isSystemUser = true; + createHome = true; + home = dataDir; + group = user; + }; + users.groups.${user} = {}; + + systemd.services.matomo-setup-update = { + # everything needs to set up and up to date before Matomo php files are executed + requiredBy = [ "${phpExecutionUnit}.service" ]; + before = [ "${phpExecutionUnit}.service" ]; + # the update part of the script can only work if the database is already up and running + requires = [ databaseService ]; + after = [ databaseService ]; + path = [ cfg.package ]; + environment.PIWIK_USER_PATH = dataDir; + serviceConfig = { + Type = "oneshot"; + User = user; + # hide especially config.ini.php from other + UMask = "0007"; + # TODO: might get renamed to MATOMO_USER_PATH in future versions + # chown + chmod in preStart needs root + PermissionsStartOnly = true; + }; + + # correct ownership and permissions in case they're not correct anymore, + # e.g. after restoring from backup or moving from another system. + # Note that ${dataDir}/config/config.ini.php might contain the MySQL password. + preStart = '' + # migrate data from piwik to Matomo folder + if [ -d ${deprecatedDataDir} ]; then + echo "Migrating from ${deprecatedDataDir} to ${dataDir}" + mv -T ${deprecatedDataDir} ${dataDir} + fi + chown -R ${user}:${user} ${dataDir} + chmod -R ug+rwX,o-rwx ${dataDir} + + if [ -e ${dataDir}/current-package ]; then + CURRENT_PACKAGE=$(readlink ${dataDir}/current-package) + NEW_PACKAGE=${cfg.package} + if [ "$CURRENT_PACKAGE" != "$NEW_PACKAGE" ]; then + # keeping tmp around between upgrades seems to bork stuff, so delete it + rm -rf ${dataDir}/tmp + fi + elif [ -e ${dataDir}/tmp ]; then + # upgrade from 4.4.1 + rm -rf ${dataDir}/tmp + fi + ln -sfT ${cfg.package} ${dataDir}/current-package + ''; + script = '' + # Use User-Private Group scheme to protect Matomo data, but allow administration / backup via 'matomo' group + # Copy config folder + chmod g+s "${dataDir}" + cp -r "${cfg.package}/share/config" "${dataDir}/" + mkdir -p "${dataDir}/misc" + chmod -R u+rwX,g+rwX,o-rwx "${dataDir}" + + # check whether user setup has already been done + if test -f "${dataDir}/config/config.ini.php"; then + # then execute possibly pending database upgrade + matomo-console core:update --yes + fi + ''; + }; + + # If this is run regularly via the timer, + # 'Browser trigger archiving' can be disabled in Matomo UI > Settings > General Settings. + systemd.services.matomo-archive-processing = { + description = "Archive Matomo reports"; + # the archiving can only work if the database is already up and running + requires = [ databaseService ]; + after = [ databaseService ]; + + # TODO: might get renamed to MATOMO_USER_PATH in future versions + environment.PIWIK_USER_PATH = dataDir; + serviceConfig = { + Type = "oneshot"; + User = user; + UMask = "0007"; + CPUSchedulingPolicy = "idle"; + IOSchedulingClass = "idle"; + ExecStart = "${cfg.package}/bin/matomo-console core:archive --url=https://${cfg.hostname}"; + }; + }; + + systemd.timers.matomo-archive-processing = mkIf cfg.periodicArchiveProcessing { + description = "Automatically archive Matomo reports every hour"; + + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "hourly"; + Persistent = "yes"; + AccuracySec = "10m"; + }; + }; + + systemd.services.${phpExecutionUnit} = { + # stop phpfpm on package upgrade, do database upgrade via matomo-setup-update, and then restart + restartTriggers = [ cfg.package ]; + # stop config.ini.php from getting written with read permission for others + serviceConfig.UMask = "0007"; + }; + + services.phpfpm.pools = let + # workaround for when both are null and need to generate a string, + # which is illegal, but as assertions apparently are being triggered *after* config generation, + # we have to avoid already throwing errors at this previous stage. + socketOwner = if (cfg.nginx != null) then config.services.nginx.user + else if (cfg.webServerUser != null) then cfg.webServerUser else ""; + in { + ${pool} = { + inherit user; + phpOptions = '' + error_log = 'stderr' + log_errors = on + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = socketOwner; + "listen.group" = "root"; + "listen.mode" = "0660"; + "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" = true; + }; + phpEnv.PIWIK_USER_PATH = dataDir; + }; + }; + + + services.nginx.virtualHosts = mkIf (cfg.nginx != null) { + # References: + # https://fralef.me/piwik-hardening-with-nginx-and-php-fpm.html + # https://github.com/perusio/piwik-nginx + "${cfg.hostname}" = mkMerge [ cfg.nginx { + # don't allow to override the root easily, as it will almost certainly break Matomo. + # disadvantage: not shown as default in docs. + root = mkForce "${cfg.package}/share"; + + # define locations here instead of as the submodule option's default + # so that they can easily be extended with additional locations if required + # without needing to redefine the Matomo ones. + # disadvantage: not shown as default in docs. + locations."/" = { + index = "index.php"; + }; + # allow index.php for webinterface + locations."= /index.php".extraConfig = '' + fastcgi_pass unix:${fpm.socket}; + ''; + # allow matomo.php for tracking + locations."= /matomo.php".extraConfig = '' + fastcgi_pass unix:${fpm.socket}; + ''; + # allow piwik.php for tracking (deprecated name) + locations."= /piwik.php".extraConfig = '' + fastcgi_pass unix:${fpm.socket}; + ''; + # Any other attempt to access any php files is forbidden + locations."~* ^.+\\.php$".extraConfig = '' + return 403; + ''; + # Disallow access to unneeded directories + # config and tmp are already removed + locations."~ ^/(?:core|lang|misc)/".extraConfig = '' + return 403; + ''; + # Disallow access to several helper files + locations."~* \\.(?:bat|git|ini|sh|txt|tpl|xml|md)$".extraConfig = '' + return 403; + ''; + # No crawling of this site for bots that obey robots.txt - no useful information here. + locations."= /robots.txt".extraConfig = '' + return 200 "User-agent: *\nDisallow: /\n"; + ''; + # let browsers cache matomo.js + locations."= /matomo.js".extraConfig = '' + expires 1M; + ''; + # let browsers cache piwik.js (deprecated name) + locations."= /piwik.js".extraConfig = '' + expires 1M; + ''; + }]; + }; + }; + + meta = { + doc = ./matomo.md; + maintainers = with lib.maintainers; [ florianjacob ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/mattermost.nix b/nixpkgs/nixos/modules/services/web-apps/mattermost.nix new file mode 100644 index 000000000000..db5122e79f00 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/mattermost.nix @@ -0,0 +1,361 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + + cfg = config.services.mattermost; + + database = "postgres://${cfg.localDatabaseUser}:${cfg.localDatabasePassword}@localhost:5432/${cfg.localDatabaseName}?sslmode=disable&connect_timeout=10"; + + postgresPackage = config.services.postgresql.package; + + createDb = { + statePath ? cfg.statePath, + localDatabaseUser ? cfg.localDatabaseUser, + localDatabasePassword ? cfg.localDatabasePassword, + localDatabaseName ? cfg.localDatabaseName, + useSudo ? true + }: '' + if ! test -e ${escapeShellArg "${statePath}/.db-created"}; then + ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"} + ${postgresPackage}/bin/psql postgres -c \ + "CREATE ROLE ${localDatabaseUser} WITH LOGIN NOCREATEDB NOCREATEROLE ENCRYPTED PASSWORD '${localDatabasePassword}'" + ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"} + ${postgresPackage}/bin/createdb \ + --owner ${escapeShellArg localDatabaseUser} ${escapeShellArg localDatabaseName} + touch ${escapeShellArg "${statePath}/.db-created"} + fi + ''; + + mattermostPluginDerivations = with pkgs; + map (plugin: stdenv.mkDerivation { + name = "mattermost-plugin"; + installPhase = '' + mkdir -p $out/share + cp ${plugin} $out/share/plugin.tar.gz + ''; + dontUnpack = true; + dontPatch = true; + dontConfigure = true; + dontBuild = true; + preferLocalBuild = true; + }) cfg.plugins; + + mattermostPlugins = with pkgs; + if mattermostPluginDerivations == [] then null + else stdenv.mkDerivation { + name = "${cfg.package.name}-plugins"; + nativeBuildInputs = [ + autoPatchelfHook + ] ++ mattermostPluginDerivations; + buildInputs = [ + cfg.package + ]; + installPhase = '' + mkdir -p $out/data/plugins + plugins=(${escapeShellArgs (map (plugin: "${plugin}/share/plugin.tar.gz") mattermostPluginDerivations)}) + for plugin in "''${plugins[@]}"; do + hash="$(sha256sum "$plugin" | cut -d' ' -f1)" + mkdir -p "$hash" + tar -C "$hash" -xzf "$plugin" + autoPatchelf "$hash" + GZIP_OPT=-9 tar -C "$hash" -cvzf "$out/data/plugins/$hash.tar.gz" . + rm -rf "$hash" + done + ''; + + dontUnpack = true; + dontPatch = true; + dontConfigure = true; + dontBuild = true; + preferLocalBuild = true; + }; + + mattermostConfWithoutPlugins = recursiveUpdate + { ServiceSettings.SiteURL = cfg.siteUrl; + ServiceSettings.ListenAddress = cfg.listenAddress; + TeamSettings.SiteName = cfg.siteName; + SqlSettings.DriverName = "postgres"; + SqlSettings.DataSource = database; + PluginSettings.Directory = "${cfg.statePath}/plugins/server"; + PluginSettings.ClientDirectory = "${cfg.statePath}/plugins/client"; + } + cfg.extraConfig; + + mattermostConf = recursiveUpdate + mattermostConfWithoutPlugins + ( + if mattermostPlugins == null then {} + else { + PluginSettings = { + Enable = true; + }; + } + ); + + mattermostConfJSON = pkgs.writeText "mattermost-config.json" (builtins.toJSON mattermostConf); + +in + +{ + options = { + services.mattermost = { + enable = mkEnableOption (lib.mdDoc "Mattermost chat server"); + + package = mkOption { + type = types.package; + default = pkgs.mattermost; + defaultText = lib.literalExpression "pkgs.mattermost"; + description = lib.mdDoc "Mattermost derivation to use."; + }; + + statePath = mkOption { + type = types.str; + default = "/var/lib/mattermost"; + description = lib.mdDoc "Mattermost working directory"; + }; + + siteUrl = mkOption { + type = types.str; + example = "https://chat.example.com"; + description = lib.mdDoc '' + URL this Mattermost instance is reachable under, without trailing slash. + ''; + }; + + siteName = mkOption { + type = types.str; + default = "Mattermost"; + description = lib.mdDoc "Name of this Mattermost site."; + }; + + listenAddress = mkOption { + type = types.str; + default = ":8065"; + example = "[::1]:8065"; + description = lib.mdDoc '' + Address and port this Mattermost instance listens to. + ''; + }; + + mutableConfig = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether the Mattermost config.json is writeable by Mattermost. + + Most of the settings can be edited in the system console of + Mattermost if this option is enabled. A template config using + the options specified in services.mattermost will be generated + but won't be overwritten on changes or rebuilds. + + If this option is disabled, changes in the system console won't + be possible (default). If an config.json is present, it will be + overwritten! + ''; + }; + + preferNixConfig = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + If both mutableConfig and this option are set, the Nix configuration + will take precedence over any settings configured in the server + console. + ''; + }; + + extraConfig = mkOption { + type = types.attrs; + default = { }; + description = lib.mdDoc '' + Additional configuration options as Nix attribute set in config.json schema. + ''; + }; + + plugins = mkOption { + type = types.listOf (types.oneOf [types.path types.package]); + default = []; + example = "[ ./com.github.moussetc.mattermost.plugin.giphy-2.0.0.tar.gz ]"; + description = lib.mdDoc '' + Plugins to add to the configuration. Overrides any installed if non-null. + This is a list of paths to .tar.gz files or derivations evaluating to + .tar.gz files. + ''; + }; + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc '' + Environment file (see {manpage}`systemd.exec(5)` + "EnvironmentFile=" section for the syntax) which sets config options + for mattermost (see [the mattermost documentation](https://docs.mattermost.com/configure/configuration-settings.html#environment-variables)). + + Settings defined in the environment file will overwrite settings + set via nix or via the {option}`services.mattermost.extraConfig` + option. + + Useful for setting config options without their value ending up in the + (world-readable) nix store, e.g. for a database password. + ''; + }; + + localDatabaseCreate = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Create a local PostgreSQL database for Mattermost automatically. + ''; + }; + + localDatabaseName = mkOption { + type = types.str; + default = "mattermost"; + description = lib.mdDoc '' + Local Mattermost database name. + ''; + }; + + localDatabaseUser = mkOption { + type = types.str; + default = "mattermost"; + description = lib.mdDoc '' + Local Mattermost database username. + ''; + }; + + localDatabasePassword = mkOption { + type = types.str; + default = "mmpgsecret"; + description = lib.mdDoc '' + Password for local Mattermost database user. + ''; + }; + + user = mkOption { + type = types.str; + default = "mattermost"; + description = lib.mdDoc '' + User which runs the Mattermost service. + ''; + }; + + group = mkOption { + type = types.str; + default = "mattermost"; + description = lib.mdDoc '' + Group which runs the Mattermost service. + ''; + }; + + matterircd = { + enable = mkEnableOption (lib.mdDoc "Mattermost IRC bridge"); + package = mkOption { + type = types.package; + default = pkgs.matterircd; + defaultText = lib.literalExpression "pkgs.matterircd"; + description = lib.mdDoc "matterircd derivation to use."; + }; + parameters = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "-mmserver chat.example.com" "-bind [::]:6667" ]; + description = lib.mdDoc '' + Set commandline parameters to pass to matterircd. See + https://github.com/42wim/matterircd#usage for more information. + ''; + }; + }; + }; + }; + + config = mkMerge [ + (mkIf cfg.enable { + users.users = optionalAttrs (cfg.user == "mattermost") { + mattermost = { + group = cfg.group; + uid = config.ids.uids.mattermost; + home = cfg.statePath; + }; + }; + + users.groups = optionalAttrs (cfg.group == "mattermost") { + mattermost.gid = config.ids.gids.mattermost; + }; + + services.postgresql.enable = cfg.localDatabaseCreate; + + # The systemd service will fail to execute the preStart hook + # if the WorkingDirectory does not exist + system.activationScripts.mattermost = '' + mkdir -p "${cfg.statePath}" + ''; + + systemd.services.mattermost = { + description = "Mattermost chat service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "postgresql.service" ]; + + preStart = '' + mkdir -p "${cfg.statePath}"/{data,config,logs,plugins} + mkdir -p "${cfg.statePath}/plugins"/{client,server} + ln -sf ${cfg.package}/{bin,fonts,i18n,templates,client} "${cfg.statePath}" + '' + lib.optionalString (mattermostPlugins != null) '' + rm -rf "${cfg.statePath}/data/plugins" + ln -sf ${mattermostPlugins}/data/plugins "${cfg.statePath}/data" + '' + lib.optionalString (!cfg.mutableConfig) '' + rm -f "${cfg.statePath}/config/config.json" + ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json" + '' + lib.optionalString cfg.mutableConfig '' + if ! test -e "${cfg.statePath}/config/.initial-created"; then + rm -f ${cfg.statePath}/config/config.json + ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json" + touch "${cfg.statePath}/config/.initial-created" + fi + '' + lib.optionalString (cfg.mutableConfig && cfg.preferNixConfig) '' + new_config="$(${pkgs.jq}/bin/jq -s '.[0] * .[1]' "${cfg.statePath}/config/config.json" ${mattermostConfJSON})" + + rm -f "${cfg.statePath}/config/config.json" + echo "$new_config" > "${cfg.statePath}/config/config.json" + '' + lib.optionalString cfg.localDatabaseCreate (createDb {}) + '' + # Don't change permissions recursively on the data, current, and symlinked directories (see ln -sf command above). + # This dramatically decreases startup times for installations with a lot of files. + find . -maxdepth 1 -not -name data -not -name client -not -name templates -not -name i18n -not -name fonts -not -name bin -not -name . \ + -exec chown "${cfg.user}:${cfg.group}" -R {} \; -exec chmod u+rw,g+r,o-rwx -R {} \; + + chown "${cfg.user}:${cfg.group}" "${cfg.statePath}/data" . + chmod u+rw,g+r,o-rwx "${cfg.statePath}/data" . + ''; + + serviceConfig = { + PermissionsStartOnly = true; + User = cfg.user; + Group = cfg.group; + ExecStart = "${cfg.package}/bin/mattermost"; + WorkingDirectory = "${cfg.statePath}"; + Restart = "always"; + RestartSec = "10"; + LimitNOFILE = "49152"; + EnvironmentFile = cfg.environmentFile; + }; + unitConfig.JoinsNamespaceOf = mkIf cfg.localDatabaseCreate "postgresql.service"; + }; + }) + (mkIf cfg.matterircd.enable { + systemd.services.matterircd = { + description = "Mattermost IRC bridge service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = "nobody"; + Group = "nogroup"; + ExecStart = "${cfg.matterircd.package}/bin/matterircd ${escapeShellArgs cfg.matterircd.parameters}"; + WorkingDirectory = "/tmp"; + PrivateTmp = true; + Restart = "always"; + RestartSec = "5"; + }; + }; + }) + ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/mediawiki.nix b/nixpkgs/nixos/modules/services/web-apps/mediawiki.nix new file mode 100644 index 000000000000..21c587694c6e --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/mediawiki.nix @@ -0,0 +1,563 @@ +{ config, pkgs, lib, ... }: + +let + + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption; + inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionals optionalString types; + + cfg = config.services.mediawiki; + fpm = config.services.phpfpm.pools.mediawiki; + user = "mediawiki"; + group = if cfg.webserver == "apache" then config.services.httpd.group else "mediawiki"; + + cacheDir = "/var/cache/mediawiki"; + stateDir = "/var/lib/mediawiki"; + + pkg = pkgs.stdenv.mkDerivation rec { + pname = "mediawiki-full"; + version = src.version; + src = cfg.package; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + rm -rf $out/share/mediawiki/skins/* + rm -rf $out/share/mediawiki/extensions/* + + ${concatStringsSep "\n" (mapAttrsToList (k: v: '' + ln -s ${v} $out/share/mediawiki/skins/${k} + '') cfg.skins)} + + ${concatStringsSep "\n" (mapAttrsToList (k: v: '' + ln -s ${if v != null then v else "$src/share/mediawiki/extensions/${k}"} $out/share/mediawiki/extensions/${k} + '') cfg.extensions)} + ''; + }; + + mediawikiScripts = pkgs.runCommand "mediawiki-scripts" { + nativeBuildInputs = [ pkgs.makeWrapper ]; + preferLocalBuild = true; + } '' + mkdir -p $out/bin + for i in changePassword.php createAndPromote.php userOptions.php edit.php nukePage.php update.php; do + makeWrapper ${pkgs.php}/bin/php $out/bin/mediawiki-$(basename $i .php) \ + --set MEDIAWIKI_CONFIG ${mediawikiConfig} \ + --add-flags ${pkg}/share/mediawiki/maintenance/$i + done + ''; + + dbAddr = if cfg.database.socket == null then + "${cfg.database.host}:${toString cfg.database.port}" + else if cfg.database.type == "mysql" then + "${cfg.database.host}:${cfg.database.socket}" + else if cfg.database.type == "postgres" then + "${cfg.database.socket}" + else + throw "Unsupported database type: ${cfg.database.type} for socket: ${cfg.database.socket}"; + + mediawikiConfig = pkgs.writeText "LocalSettings.php" '' + <?php + # Protect against web entry + if ( !defined( 'MEDIAWIKI' ) ) { + exit; + } + + $wgSitename = "${cfg.name}"; + $wgMetaNamespace = false; + + ## The URL base path to the directory containing the wiki; + ## defaults for all runtime URL paths are based off of this. + ## For more information on customizing the URLs + ## (like /w/index.php/Page_title to /wiki/Page_title) please see: + ## https://www.mediawiki.org/wiki/Manual:Short_URL + $wgScriptPath = ""; + + ## The protocol and server name to use in fully-qualified URLs + $wgServer = "${cfg.url}"; + + ## The URL path to static resources (images, scripts, etc.) + $wgResourceBasePath = $wgScriptPath; + + ## The URL path to the logo. Make sure you change this from the default, + ## or else you'll overwrite your logo when you upgrade! + $wgLogo = "$wgResourceBasePath/resources/assets/wiki.png"; + + ## UPO means: this is also a user preference option + + $wgEnableEmail = true; + $wgEnableUserEmail = true; # UPO + + $wgPasswordSender = "${cfg.passwordSender}"; + + $wgEnotifUserTalk = false; # UPO + $wgEnotifWatchlist = false; # UPO + $wgEmailAuthentication = true; + + ## Database settings + $wgDBtype = "${cfg.database.type}"; + $wgDBserver = "${dbAddr}"; + $wgDBport = "${toString cfg.database.port}"; + $wgDBname = "${cfg.database.name}"; + $wgDBuser = "${cfg.database.user}"; + ${optionalString (cfg.database.passwordFile != null) "$wgDBpassword = file_get_contents(\"${cfg.database.passwordFile}\");"} + + ${optionalString (cfg.database.type == "mysql" && cfg.database.tablePrefix != null) '' + # MySQL specific settings + $wgDBprefix = "${cfg.database.tablePrefix}"; + ''} + + ${optionalString (cfg.database.type == "mysql") '' + # MySQL table options to use during installation or update + $wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary"; + ''} + + ## Shared memory settings + $wgMainCacheType = CACHE_NONE; + $wgMemCachedServers = []; + + ${optionalString (cfg.uploadsDir != null) '' + $wgEnableUploads = true; + $wgUploadDirectory = "${cfg.uploadsDir}"; + ''} + + $wgUseImageMagick = true; + $wgImageMagickConvertCommand = "${pkgs.imagemagick}/bin/convert"; + + # InstantCommons allows wiki to use images from https://commons.wikimedia.org + $wgUseInstantCommons = false; + + # Periodically send a pingback to https://www.mediawiki.org/ with basic data + # about this MediaWiki instance. The Wikimedia Foundation shares this data + # with MediaWiki developers to help guide future development efforts. + $wgPingback = true; + + ## If you use ImageMagick (or any other shell command) on a + ## Linux server, this will need to be set to the name of an + ## available UTF-8 locale + $wgShellLocale = "C.UTF-8"; + + ## Set $wgCacheDirectory to a writable directory on the web server + ## to make your wiki go slightly faster. The directory should not + ## be publicly accessible from the web. + $wgCacheDirectory = "${cacheDir}"; + + # Site language code, should be one of the list in ./languages/data/Names.php + $wgLanguageCode = "en"; + + $wgSecretKey = file_get_contents("${stateDir}/secret.key"); + + # Changing this will log out all existing sessions. + $wgAuthenticationTokenVersion = ""; + + ## For attaching licensing metadata to pages, and displaying an + ## appropriate copyright notice / icon. GNU Free Documentation + ## License and Creative Commons licenses are supported so far. + $wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright + $wgRightsUrl = ""; + $wgRightsText = ""; + $wgRightsIcon = ""; + + # Path to the GNU diff3 utility. Used for conflict resolution. + $wgDiff = "${pkgs.diffutils}/bin/diff"; + $wgDiff3 = "${pkgs.diffutils}/bin/diff3"; + + # Enabled skins. + ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadSkin('${k}');") cfg.skins)} + + # Enabled extensions. + ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadExtension('${k}');") cfg.extensions)} + + + # End of automatically generated settings. + # Add more configuration options below. + + ${cfg.extraConfig} + ''; + +in +{ + # interface + options = { + services.mediawiki = { + + enable = mkEnableOption (lib.mdDoc "MediaWiki"); + + package = mkOption { + type = types.package; + default = pkgs.mediawiki; + defaultText = literalExpression "pkgs.mediawiki"; + description = lib.mdDoc "Which MediaWiki package to use."; + }; + + finalPackage = mkOption { + type = types.package; + readOnly = true; + default = pkg; + defaultText = literalExpression "pkg"; + description = lib.mdDoc '' + The final package used by the module. This is the package that will have extensions and skins installed. + ''; + }; + + name = mkOption { + type = types.str; + default = "MediaWiki"; + example = "Foobar Wiki"; + description = lib.mdDoc "Name of the wiki."; + }; + + url = mkOption { + type = types.str; + default = if cfg.webserver == "apache" then + "${if cfg.httpd.virtualHost.addSSL || cfg.httpd.virtualHost.forceSSL || cfg.httpd.virtualHost.onlySSL then "https" else "http"}://${cfg.httpd.virtualHost.hostName}" + else + "http://localhost"; + defaultText = literalExpression '' + if cfg.webserver == "apache" then + "''${if cfg.httpd.virtualHost.addSSL || cfg.httpd.virtualHost.forceSSL || cfg.httpd.virtualHost.onlySSL then "https" else "http"}://''${cfg.httpd.virtualHost.hostName}" + else + "http://localhost"; + ''; + example = "https://wiki.example.org"; + description = lib.mdDoc "URL of the wiki."; + }; + + uploadsDir = mkOption { + type = types.nullOr types.path; + default = "${stateDir}/uploads"; + description = lib.mdDoc '' + This directory is used for uploads of pictures. The directory passed here is automatically + created and permissions adjusted as required. + ''; + }; + + passwordFile = mkOption { + type = types.path; + description = lib.mdDoc "A file containing the initial password for the admin user."; + example = "/run/keys/mediawiki-password"; + }; + + passwordSender = mkOption { + type = types.str; + default = + if cfg.webserver == "apache" then + if cfg.httpd.virtualHost.adminAddr != null then + cfg.httpd.virtualHost.adminAddr + else + config.services.httpd.adminAddr else "root@localhost"; + defaultText = literalExpression '' + if cfg.webserver == "apache" then + if cfg.httpd.virtualHost.adminAddr != null then + cfg.httpd.virtualHost.adminAddr + else + config.services.httpd.adminAddr else "root@localhost" + ''; + description = lib.mdDoc "Contact address for password reset."; + }; + + skins = mkOption { + default = {}; + type = types.attrsOf types.path; + description = lib.mdDoc '' + Attribute set of paths whose content is copied to the {file}`skins` + subdirectory of the MediaWiki installation in addition to the default skins. + ''; + }; + + extensions = mkOption { + default = {}; + type = types.attrsOf (types.nullOr types.path); + description = lib.mdDoc '' + Attribute set of paths whose content is copied to the {file}`extensions` + subdirectory of the MediaWiki installation and enabled in configuration. + + Use `null` instead of path to enable extensions that are part of MediaWiki. + ''; + example = literalExpression '' + { + Matomo = pkgs.fetchzip { + url = "https://github.com/DaSchTour/matomo-mediawiki-extension/archive/v4.0.1.tar.gz"; + sha256 = "0g5rd3zp0avwlmqagc59cg9bbkn3r7wx7p6yr80s644mj6dlvs1b"; + }; + ParserFunctions = null; + } + ''; + }; + + webserver = mkOption { + type = types.enum [ "apache" "none" ]; + default = "apache"; + description = lib.mdDoc "Webserver to use."; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "postgres" "sqlite" "mssql" "oracle" ]; + default = "mysql"; + description = lib.mdDoc "Database engine to use. MySQL/MariaDB is the database of choice by MediaWiki developers."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + default = if cfg.database.type == "mysql" then 3306 else 5432; + defaultText = literalExpression "3306"; + description = lib.mdDoc "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "mediawiki"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "mediawiki"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/mediawiki-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + tablePrefix = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + If you only have access to a single database and wish to install more than + one version of MediaWiki, or have other applications that also use the + database, you can give the table names a unique prefix to stop any naming + conflicts or confusion. + See <https://www.mediawiki.org/wiki/Manual:$wgDBprefix>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = if (cfg.database.type == "mysql" && cfg.database.createLocally) then + "/run/mysqld/mysqld.sock" + else if (cfg.database.type == "postgres" && cfg.database.createLocally) then + "/run/postgresql" + else + null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = lib.mdDoc "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = cfg.database.type == "mysql" || cfg.database.type == "postgres"; + defaultText = literalExpression "true"; + description = lib.mdDoc '' + Create the database and database user locally. + This currently only applies if database type "mysql" is selected. + ''; + }; + }; + + httpd.virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "mediawiki.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`. + See [](#opt-services.httpd.virtualHosts) for further information. + ''; + }; + + 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 = lib.mdDoc '' + Options for the MediaWiki PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + description = lib.mdDoc '' + Any additional text to be appended to MediaWiki's + LocalSettings.php configuration file. For configuration + settings, see <https://www.mediawiki.org/wiki/Manual:Configuration_settings>. + ''; + default = ""; + example = '' + $wgEnableEmail = false; + ''; + }; + + }; + }; + + imports = [ + (lib.mkRenamedOptionModule [ "services" "mediawiki" "virtualHost" ] [ "services" "mediawiki" "httpd" "virtualHost" ]) + ]; + + # implementation + config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.database.createLocally -> (cfg.database.type == "mysql" || cfg.database.type == "postgres"); + message = "services.mediawiki.createLocally is currently only supported for database type 'mysql' and 'postgres'"; + } + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.mediawiki.database.user must be set to ${user} if services.mediawiki.database.createLocally is set true"; + } + { assertion = cfg.database.createLocally -> cfg.database.socket != null; + message = "services.mediawiki.database.socket must be set if services.mediawiki.database.createLocally is set to true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.mediawiki.database.createLocally is set to true"; + } + ]; + + services.mediawiki.skins = { + MonoBook = "${cfg.package}/share/mediawiki/skins/MonoBook"; + Timeless = "${cfg.package}/share/mediawiki/skins/Timeless"; + Vector = "${cfg.package}/share/mediawiki/skins/Vector"; + }; + + services.mysql = mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = cfg.database.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + }]; + }; + + services.postgresql = mkIf (cfg.database.type == "postgres" && cfg.database.createLocally) { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = cfg.database.user; + ensurePermissions = { "DATABASE \"${cfg.database.name}\"" = "ALL PRIVILEGES"; }; + }]; + }; + + services.phpfpm.pools.mediawiki = { + inherit user group; + phpEnv.MEDIAWIKI_CONFIG = "${mediawikiConfig}"; + settings = (if (cfg.webserver == "apache") then { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } else { + "listen.owner" = user; + "listen.group" = group; + }) // cfg.poolConfig; + }; + + services.httpd = lib.mkIf (cfg.webserver == "apache") { + enable = true; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.httpd.virtualHost.hostName} = mkMerge [ + cfg.httpd.virtualHost + { + documentRoot = mkForce "${pkg}/share/mediawiki"; + extraConfig = '' + <Directory "${pkg}/share/mediawiki"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + + Require all granted + DirectoryIndex index.php + AllowOverride All + </Directory> + '' + optionalString (cfg.uploadsDir != null) '' + Alias "/images" "${cfg.uploadsDir}" + <Directory "${cfg.uploadsDir}"> + Require all granted + </Directory> + ''; + } + ]; + }; + + systemd.tmpfiles.rules = [ + "d '${stateDir}' 0750 ${user} ${group} - -" + "d '${cacheDir}' 0750 ${user} ${group} - -" + ] ++ optionals (cfg.uploadsDir != null) [ + "d '${cfg.uploadsDir}' 0750 ${user} ${group} - -" + "Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -" + ]; + + systemd.services.mediawiki-init = { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-mediawiki.service" ]; + after = optional (cfg.database.type == "mysql" && cfg.database.createLocally) "mysql.service" + ++ optional (cfg.database.type == "postgres" && cfg.database.createLocally) "postgresql.service"; + script = '' + if ! test -e "${stateDir}/secret.key"; then + tr -dc A-Za-z0-9 </dev/urandom 2>/dev/null | head -c 64 > ${stateDir}/secret.key + fi + + echo "exit( wfGetDB( DB_MASTER )->tableExists( 'user' ) ? 1 : 0 );" | \ + ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/eval.php --conf ${mediawikiConfig} && \ + ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/install.php \ + --confpath /tmp \ + --scriptpath / \ + --dbserver "${dbAddr}" \ + --dbport ${toString cfg.database.port} \ + --dbname ${cfg.database.name} \ + ${optionalString (cfg.database.tablePrefix != null) "--dbprefix ${cfg.database.tablePrefix}"} \ + --dbuser ${cfg.database.user} \ + ${optionalString (cfg.database.passwordFile != null) "--dbpassfile ${cfg.database.passwordFile}"} \ + --passfile ${cfg.passwordFile} \ + --dbtype ${cfg.database.type} \ + ${cfg.name} \ + admin + + ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/update.php --conf ${mediawikiConfig} --quick + ''; + + serviceConfig = { + Type = "oneshot"; + User = user; + Group = group; + PrivateTmp = true; + }; + }; + + systemd.services.httpd.after = optional (cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "mysql") "mysql.service" + ++ optional (cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "postgres") "postgresql.service"; + + users.users.${user} = { + group = group; + isSystemUser = true; + }; + users.groups.${group} = {}; + + environment.systemPackages = [ mediawikiScripts ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/miniflux.nix b/nixpkgs/nixos/modules/services/web-apps/miniflux.nix new file mode 100644 index 000000000000..7cc8ce10ffe0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/miniflux.nix @@ -0,0 +1,134 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.miniflux; + + defaultAddress = "localhost:8080"; + + dbUser = "miniflux"; + dbName = "miniflux"; + + pgbin = "${config.services.postgresql.package}/bin"; + preStart = pkgs.writeScript "miniflux-pre-start" '' + #!${pkgs.runtimeShell} + ${pgbin}/psql "${dbName}" -c "CREATE EXTENSION IF NOT EXISTS hstore" + ''; +in + +{ + options = { + services.miniflux = { + enable = mkEnableOption (lib.mdDoc "miniflux and creates a local postgres database for it"); + + package = mkOption { + type = types.package; + default = pkgs.miniflux; + defaultText = literalExpression "pkgs.miniflux"; + description = lib.mdDoc "Miniflux package to use."; + }; + + config = mkOption { + type = types.attrsOf types.str; + example = literalExpression '' + { + CLEANUP_FREQUENCY = "48"; + LISTEN_ADDR = "localhost:8080"; + } + ''; + description = lib.mdDoc '' + Configuration for Miniflux, refer to + <https://miniflux.app/docs/configuration.html> + for documentation on the supported values. + + Correct configuration for the database is already provided. + By default, listens on ${defaultAddress}. + ''; + }; + + adminCredentialsFile = mkOption { + type = types.path; + description = lib.mdDoc '' + File containing the ADMIN_USERNAME and + ADMIN_PASSWORD (length >= 6) in the format of + an EnvironmentFile=, as described by systemd.exec(5). + ''; + example = "/etc/nixos/miniflux-admin-credentials"; + }; + }; + }; + + config = mkIf cfg.enable { + + services.miniflux.config = { + LISTEN_ADDR = mkDefault defaultAddress; + DATABASE_URL = "user=${dbUser} host=/run/postgresql dbname=${dbName}"; + RUN_MIGRATIONS = "1"; + CREATE_ADMIN = "1"; + }; + + services.postgresql = { + enable = true; + ensureUsers = [ { + name = dbUser; + ensurePermissions = { + "DATABASE ${dbName}" = "ALL PRIVILEGES"; + }; + } ]; + ensureDatabases = [ dbName ]; + }; + + systemd.services.miniflux-dbsetup = { + description = "Miniflux database setup"; + requires = [ "postgresql.service" ]; + after = [ "network.target" "postgresql.service" ]; + serviceConfig = { + Type = "oneshot"; + User = config.services.postgresql.superUser; + ExecStart = preStart; + }; + }; + + systemd.services.miniflux = { + description = "Miniflux service"; + wantedBy = [ "multi-user.target" ]; + requires = [ "miniflux-dbsetup.service" ]; + after = [ "network.target" "postgresql.service" "miniflux-dbsetup.service" ]; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/miniflux"; + User = dbUser; + DynamicUser = true; + RuntimeDirectory = "miniflux"; + RuntimeDirectoryMode = "0700"; + EnvironmentFile = cfg.adminCredentialsFile; + # Hardening + CapabilityBoundingSet = [ "" ]; + DeviceAllow = [ "" ]; + LockPersonality = true; + MemoryDenyWriteExecute = 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" "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" ]; + UMask = "0077"; + }; + + environment = cfg.config; + }; + environment.systemPackages = [ cfg.package ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/monica.nix b/nixpkgs/nixos/modules/services/web-apps/monica.nix new file mode 100644 index 000000000000..2bff42f7ffa4 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/monica.nix @@ -0,0 +1,468 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + cfg = config.services.monica; + monica = pkgs.monica.override { + dataDir = cfg.dataDir; + }; + db = cfg.database; + mail = cfg.mail; + + user = cfg.user; + group = cfg.group; + + # shell script for local administration + artisan = pkgs.writeScriptBin "monica" '' + #! ${pkgs.runtimeShell} + cd ${monica} + sudo() { + if [[ "$USER" != ${user} ]]; then + exec /run/wrappers/bin/sudo -u ${user} "$@" + else + exec "$@" + fi + } + sudo ${pkgs.php}/bin/php artisan "$@" + ''; + + tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME; +in { + options.services.monica = { + enable = mkEnableOption (lib.mdDoc "monica"); + + user = mkOption { + default = "monica"; + description = lib.mdDoc "User monica runs as."; + type = types.str; + }; + + group = mkOption { + default = "monica"; + description = lib.mdDoc "Group monica runs as."; + type = types.str; + }; + + appKeyFile = mkOption { + description = lib.mdDoc '' + 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/monica-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 = "monica.example.com"; + description = lib.mdDoc '' + The hostname to serve monica on. + ''; + }; + + appURL = mkOption { + description = lib.mdDoc '' + The root URL that you want to host monica on. All URLs in monica 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 monica: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 = lib.mdDoc "monica data directory"; + default = "/var/lib/monica"; + type = types.path; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + name = mkOption { + type = types.str; + default = "monica"; + description = lib.mdDoc "Database name."; + }; + user = mkOption { + type = types.str; + default = user; + defaultText = lib.literalExpression "user"; + description = lib.mdDoc "Database username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/monica-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + mail = { + driver = mkOption { + type = types.enum ["smtp" "sendmail"]; + default = "smtp"; + description = lib.mdDoc "Mail driver to use."; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Mail host address."; + }; + port = mkOption { + type = types.port; + default = 1025; + description = lib.mdDoc "Mail host port."; + }; + fromName = mkOption { + type = types.str; + default = "monica"; + description = lib.mdDoc "Mail \"from\" name."; + }; + from = mkOption { + type = types.str; + default = "mail@monica.com"; + description = lib.mdDoc "Mail \"from\" email."; + }; + user = mkOption { + type = with types; nullOr str; + default = null; + example = "monica"; + description = lib.mdDoc "Mail username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/monica-mailpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + <option>mail.user</option>. + ''; + }; + encryption = mkOption { + type = with types; nullOr (enum ["tls"]); + default = null; + description = lib.mdDoc "SMTP encryption mechanism to use."; + }; + }; + + maxUploadSize = mkOption { + type = types.str; + default = "18M"; + example = "1G"; + description = lib.mdDoc "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 = lib.mdDoc '' + Options for the monica 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 = [ + "monica.''${config.networking.domain}" + ]; + # To enable encryption and let let's encrypt take care of certificate + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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"; + WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf"; + AUTH_METHOD = "oidc"; + OIDC_NAME = "MyLogin"; + OIDC_DISPLAY_NAME_CLAIMS = "name"; + OIDC_CLIENT_ID = "monica"; + OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"}; + OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm"; + OIDC_ISSUER_DISCOVER = true; + } + ''; + description = lib.mdDoc '' + monica configuration options to set in the + <filename>.env</filename> file. + + Refer to <link xlink:href="https://github.com/monicahq/monica"/> + 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.monica.database.user must be set to ${user} if services.monica.database.createLocally is set true."; + } + { + assertion = db.createLocally -> db.passwordFile == null; + message = "services.monica.database.passwordFile cannot be specified if services.monica.database.createLocally is set to true."; + } + ]; + + services.monica.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/monica/cache/services.php"; + APP_PACKAGES_CACHE = "/run/monica/cache/packages.php"; + APP_CONFIG_CACHE = "/run/monica/cache/config.php"; + APP_ROUTES_CACHE = "/run/monica/cache/routes-v7.php"; + APP_EVENTS_CACHE = "/run/monica/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.monica = { + inherit user group; + 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 "${monica}/public"; + locations = { + "/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php?$query_string"; + }; + "~ \.php$".extraConfig = '' + fastcgi_pass unix:${config.services.phpfpm.pools."monica".socket}; + ''; + "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = { + extraConfig = "expires 365d;"; + }; + }; + } + ]; + }; + + systemd.services.monica-setup = { + description = "Preparation tasks for monica"; + before = ["phpfpm-monica.service"]; + after = optional db.createLocally "mysql.service"; + wantedBy = ["multi-user.target"]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = user; + UMask = 077; + WorkingDirectory = "${monica}"; + RuntimeDirectory = "monica/cache"; + RuntimeDirectoryMode = 0700; + }; + path = [pkgs.replace-secret]; + script = let + isSecret = v: isAttrs v && v ? _secret && isString v._secret; + monicaEnvVars = 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; + monicaEnv = pkgs.writeText "monica.env" (monicaEnvVars filteredConfig); + in '' + # error handling + set -euo pipefail + + # create .env file + install -T -m 0600 -o ${user} ${monicaEnv} "${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 + ${pkgs.php}/bin/php artisan key:generate --force + ${pkgs.php}/bin/php artisan setup:production -v --force + ''; + }; + + systemd.services.monica-scheduler = { + description = "Background tasks for monica"; + startAt = "minutely"; + after = ["monica-setup.service"]; + serviceConfig = { + Type = "oneshot"; + User = user; + WorkingDirectory = "${monica}"; + ExecStart = "${pkgs.php}/bin/php ${monica}/artisan schedule:run -v"; + }; + }; + + 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 == "monica") { + monica = { + inherit group; + isSystemUser = true; + }; + "${config.services.nginx.user}".extraGroups = [group]; + }; + groups = mkIf (group == "monica") { + monica = {}; + }; + }; + }; +} + diff --git a/nixpkgs/nixos/modules/services/web-apps/moodle.nix b/nixpkgs/nixos/modules/services/web-apps/moodle.nix new file mode 100644 index 000000000000..b617e9a59379 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/moodle.nix @@ -0,0 +1,318 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types; + inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionalString; + + cfg = config.services.moodle; + fpm = config.services.phpfpm.pools.moodle; + + user = "moodle"; + group = config.services.httpd.group; + stateDir = "/var/lib/moodle"; + + moodleConfig = pkgs.writeText "config.php" '' + <?php // Moodle configuration file + + unset($CFG); + global $CFG; + $CFG = new stdClass(); + + $CFG->dbtype = '${ { mysql = "mariadb"; pgsql = "pgsql"; }.${cfg.database.type} }'; + $CFG->dblibrary = 'native'; + $CFG->dbhost = '${cfg.database.host}'; + $CFG->dbname = '${cfg.database.name}'; + $CFG->dbuser = '${cfg.database.user}'; + ${optionalString (cfg.database.passwordFile != null) "$CFG->dbpass = file_get_contents('${cfg.database.passwordFile}');"} + $CFG->prefix = 'mdl_'; + $CFG->dboptions = array ( + 'dbpersist' => 0, + 'dbport' => '${toString cfg.database.port}', + ${optionalString (cfg.database.socket != null) "'dbsocket' => '${cfg.database.socket}',"} + 'dbcollation' => 'utf8mb4_unicode_ci', + ); + + $CFG->wwwroot = '${if cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL then "https" else "http"}://${cfg.virtualHost.hostName}'; + $CFG->dataroot = '${stateDir}'; + $CFG->admin = 'admin'; + + $CFG->directorypermissions = 02777; + $CFG->disableupdateautodeploy = true; + + $CFG->pathtogs = '${pkgs.ghostscript}/bin/gs'; + $CFG->pathtophp = '${phpExt}/bin/php'; + $CFG->pathtodu = '${pkgs.coreutils}/bin/du'; + $CFG->aspellpath = '${pkgs.aspell}/bin/aspell'; + $CFG->pathtodot = '${pkgs.graphviz}/bin/dot'; + + ${cfg.extraConfig} + + require_once('${cfg.package}/share/moodle/lib/setup.php'); + + // There is no php closing tag in this file, + // it is intentional because it prevents trailing whitespace problems! + ''; + + mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql"; + + phpExt = pkgs.php81.buildEnv { + extensions = { all, ... }: with all; [ iconv mbstring curl openssl tokenizer soap ctype zip gd simplexml dom intl sqlite3 pgsql pdo_sqlite pdo_pgsql pdo_odbc pdo_mysql pdo mysqli session zlib xmlreader fileinfo filter opcache exif sodium ]; + extraConfig = "max_input_vars = 5000"; + }; +in +{ + # interface + options.services.moodle = { + enable = mkEnableOption (lib.mdDoc "Moodle web application"); + + package = mkOption { + type = types.package; + default = pkgs.moodle; + defaultText = literalExpression "pkgs.moodle"; + description = lib.mdDoc "The Moodle package to use."; + }; + + initialPassword = mkOption { + type = types.str; + example = "correcthorsebatterystaple"; + description = lib.mdDoc '' + Specifies the initial password for the admin, i.e. the password assigned if the user does not already exist. + The password specified here is world-readable in the Nix store, so it should be changed promptly. + ''; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "pgsql" ]; + default = "mysql"; + description = lib.mdDoc "Database engine to use."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + description = lib.mdDoc "Database host port."; + default = { + mysql = 3306; + pgsql = 5432; + }.${cfg.database.type}; + defaultText = literalExpression "3306"; + }; + + name = mkOption { + type = types.str; + default = "moodle"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "moodle"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/moodle-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = + if mysqlLocal then "/run/mysqld/mysqld.sock" + else if pgsqlLocal then "/run/postgresql" + else null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = lib.mdDoc "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "moodle.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`. + See [](#opt-services.httpd.virtualHosts) for further information. + ''; + }; + + 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 = lib.mdDoc '' + Options for the Moodle PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Any additional text to be appended to the config.php + configuration file. This is a PHP script. For configuration + details, see <https://docs.moodle.org/37/en/Configuration_file>. + ''; + example = '' + $CFG->disableupdatenotifications = true; + ''; + }; + }; + + # implementation + config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.moodle.database.user must be set to ${user} if services.moodle.database.createLocally is set true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.moodle.database.createLocally is set to true"; + } + ]; + + services.mysql = mkIf mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "SELECT, INSERT, UPDATE, DELETE, CREATE, CREATE TEMPORARY TABLES, DROP, INDEX, ALTER"; + }; + } + ]; + }; + + services.postgresql = mkIf pgsqlLocal { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; }; + } + ]; + }; + + services.phpfpm.pools.moodle = { + inherit user group; + phpPackage = phpExt; + phpEnv.MOODLE_CONFIG = "${moodleConfig}"; + phpOptions = '' + zend_extension = opcache.so + opcache.enable = 1 + max_input_vars = 5000 + ''; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + adminAddr = mkDefault cfg.virtualHost.adminAddr; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${cfg.package}/share/moodle"; + extraConfig = '' + <Directory "${cfg.package}/share/moodle"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + Options -Indexes + DirectoryIndex index.php + </Directory> + ''; + } ]; + }; + + systemd.tmpfiles.rules = [ + "d '${stateDir}' 0750 ${user} ${group} - -" + ]; + + systemd.services.moodle-init = { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-moodle.service" ]; + after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + environment.MOODLE_CONFIG = moodleConfig; + script = '' + ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/check_database_schema.php && rc=$? || rc=$? + + [ "$rc" == 1 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/upgrade.php \ + --non-interactive \ + --allow-unstable + + [ "$rc" == 2 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/install_database.php \ + --agree-license \ + --adminpass=${cfg.initialPassword} + + true + ''; + serviceConfig = { + User = user; + Group = group; + Type = "oneshot"; + }; + }; + + systemd.services.moodle-cron = { + description = "Moodle cron service"; + after = [ "moodle-init.service" ]; + environment.MOODLE_CONFIG = moodleConfig; + serviceConfig = { + User = user; + Group = group; + ExecStart = "${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/cron.php"; + }; + }; + + systemd.timers.moodle-cron = { + description = "Moodle cron timer"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "minutely"; + }; + }; + + systemd.services.httpd.after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + + users.users.${user} = { + group = group; + isSystemUser = true; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/netbox.nix b/nixpkgs/nixos/modules/services/web-apps/netbox.nix new file mode 100644 index 000000000000..0ecb20e8c2c0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/netbox.nix @@ -0,0 +1,356 @@ +{ config, lib, pkgs, buildEnv, ... }: + +with lib; + +let + cfg = config.services.netbox; + pythonFmt = pkgs.formats.pythonVars {}; + staticDir = cfg.dataDir + "/static"; + + settingsFile = pythonFmt.generate "netbox-settings.py" cfg.settings; + extraConfigFile = pkgs.writeTextFile { + name = "netbox-extraConfig.py"; + text = cfg.extraConfig; + }; + configFile = pkgs.concatText "configuration.py" [ settingsFile extraConfigFile ]; + + pkg = (cfg.package.overrideAttrs (old: { + installPhase = old.installPhase + '' + ln -s ${configFile} $out/opt/netbox/netbox/netbox/configuration.py + '' + optionalString cfg.enableLdap '' + ln -s ${cfg.ldapConfigPath} $out/opt/netbox/netbox/netbox/ldap_config.py + ''; + })).override { + inherit (cfg) plugins; + }; + netboxManageScript = with pkgs; (writeScriptBin "netbox-manage" '' + #!${stdenv.shell} + export PYTHONPATH=${pkg.pythonPath} + sudo -u netbox ${pkg}/bin/netbox "$@" + ''); + +in { + options.services.netbox = { + enable = mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Enable Netbox. + + This module requires a reverse proxy that serves `/static` separately. + See this [example](https://github.com/netbox-community/netbox/blob/develop/contrib/nginx.conf/) on how to configure this. + ''; + }; + + settings = lib.mkOption { + description = lib.mdDoc '' + Configuration options to set in `configuration.py`. + See the [documentation](https://docs.netbox.dev/en/stable/configuration/) for more possible options. + ''; + + default = { }; + + type = lib.types.submodule { + freeformType = pythonFmt.type; + + options = { + ALLOWED_HOSTS = lib.mkOption { + type = with lib.types; listOf str; + default = ["*"]; + description = lib.mdDoc '' + A list of valid fully-qualified domain names (FQDNs) and/or IP + addresses that can be used to reach the NetBox service. + ''; + }; + }; + }; + }; + + listenAddress = mkOption { + type = types.str; + default = "[::1]"; + description = lib.mdDoc '' + Address the server will listen on. + ''; + }; + + package = mkOption { + type = types.package; + default = if versionAtLeast config.system.stateVersion "23.05" then pkgs.netbox else pkgs.netbox_3_3; + defaultText = literalExpression '' + if versionAtLeast config.system.stateVersion "23.05" then pkgs.netbox else pkgs.netbox_3_3; + ''; + description = lib.mdDoc '' + NetBox package to use. + ''; + }; + + port = mkOption { + type = types.port; + default = 8001; + description = lib.mdDoc '' + Port the server will listen on. + ''; + }; + + plugins = mkOption { + type = types.functionTo (types.listOf types.package); + default = _: []; + defaultText = literalExpression '' + python3Packages: with python3Packages; []; + ''; + description = lib.mdDoc '' + List of plugin packages to install. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/netbox"; + description = lib.mdDoc '' + Storage path of netbox. + ''; + }; + + secretKeyFile = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to a file containing the secret key. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Additional lines of configuration appended to the `configuration.py`. + See the [documentation](https://docs.netbox.dev/en/stable/configuration/) for more possible options. + ''; + }; + + enableLdap = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable LDAP-Authentication for Netbox. + + This requires a configuration file being pass through `ldapConfigPath`. + ''; + }; + + ldapConfigPath = mkOption { + type = types.path; + default = ""; + description = lib.mdDoc '' + Path to the Configuration-File for LDAP-Authentication, will be loaded as `ldap_config.py`. + See the [documentation](https://netbox.readthedocs.io/en/stable/installation/6-ldap/#configuration) for possible options. + ''; + example = '' + import ldap + from django_auth_ldap.config import LDAPSearch, PosixGroupType + + AUTH_LDAP_SERVER_URI = "ldaps://ldap.example.com/" + + AUTH_LDAP_USER_SEARCH = LDAPSearch( + "ou=accounts,ou=posix,dc=example,dc=com", + ldap.SCOPE_SUBTREE, + "(uid=%(user)s)", + ) + + AUTH_LDAP_GROUP_SEARCH = LDAPSearch( + "ou=groups,ou=posix,dc=example,dc=com", + ldap.SCOPE_SUBTREE, + "(objectClass=posixGroup)", + ) + AUTH_LDAP_GROUP_TYPE = PosixGroupType() + + # Mirror LDAP group assignments. + AUTH_LDAP_MIRROR_GROUPS = True + + # For more granular permissions, we can map LDAP groups to Django groups. + AUTH_LDAP_FIND_GROUP_PERMS = True + ''; + }; + }; + + config = mkIf cfg.enable { + services.netbox = { + plugins = mkIf cfg.enableLdap (ps: [ ps.django-auth-ldap ]); + settings = { + STATIC_ROOT = staticDir; + MEDIA_ROOT = "${cfg.dataDir}/media"; + REPORTS_ROOT = "${cfg.dataDir}/reports"; + SCRIPTS_ROOT = "${cfg.dataDir}/scripts"; + + DATABASE = { + NAME = "netbox"; + USER = "netbox"; + HOST = "/run/postgresql"; + }; + + # Redis database settings. Redis is used for caching and for queuing + # background tasks such as webhook events. A separate configuration + # exists for each. Full connection details are required in both + # sections, and it is strongly recommended to use two separate database + # IDs. + REDIS = { + tasks = { + URL = "unix://${config.services.redis.servers.netbox.unixSocket}?db=0"; + SSL = false; + }; + caching = { + URL = "unix://${config.services.redis.servers.netbox.unixSocket}?db=1"; + SSL = false; + }; + }; + + REMOTE_AUTH_BACKEND = lib.mkIf cfg.enableLdap "netbox.authentication.LDAPBackend"; + + LOGGING = lib.mkDefault { + version = 1; + + formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s"; + + handlers.console = { + class = "logging.StreamHandler"; + formatter = "precise"; + }; + + # log to console/systemd instead of file + root = { + level = "INFO"; + handlers = [ "console" ]; + }; + }; + }; + + extraConfig = '' + with open("${cfg.secretKeyFile}", "r") as file: + SECRET_KEY = file.readline() + ''; + }; + + services.redis.servers.netbox.enable = true; + + services.postgresql = { + enable = true; + ensureDatabases = [ "netbox" ]; + ensureUsers = [ + { + name = "netbox"; + ensurePermissions = { + "DATABASE netbox" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + environment.systemPackages = [ netboxManageScript ]; + + systemd.targets.netbox = { + description = "Target for all NetBox services"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "redis-netbox.service" ]; + }; + + systemd.services = let + defaultServiceConfig = { + WorkingDirectory = "${cfg.dataDir}"; + User = "netbox"; + Group = "netbox"; + StateDirectory = "netbox"; + StateDirectoryMode = "0750"; + Restart = "on-failure"; + }; + in { + netbox-migration = { + description = "NetBox migrations"; + wantedBy = [ "netbox.target" ]; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + Type = "oneshot"; + ExecStart = '' + ${pkg}/bin/netbox migrate + ''; + }; + }; + + netbox = { + description = "NetBox WSGI Service"; + wantedBy = [ "netbox.target" ]; + after = [ "netbox-migration.service" ]; + + preStart = '' + ${pkg}/bin/netbox trace_paths --no-input + ${pkg}/bin/netbox collectstatic --no-input + ${pkg}/bin/netbox remove_stale_contenttypes --no-input + ''; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${pkgs.python3Packages.gunicorn}/bin/gunicorn netbox.wsgi \ + --bind ${cfg.listenAddress}:${toString cfg.port} \ + --pythonpath ${pkg}/opt/netbox/netbox + ''; + }; + }; + + netbox-rq = { + description = "NetBox Request Queue Worker"; + wantedBy = [ "netbox.target" ]; + after = [ "netbox.service" ]; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${pkg}/bin/netbox rqworker high default low + ''; + }; + }; + + netbox-housekeeping = { + description = "NetBox housekeeping job"; + after = [ "netbox.service" ]; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + Type = "oneshot"; + ExecStart = '' + ${pkg}/bin/netbox housekeeping + ''; + }; + }; + }; + + systemd.timers.netbox-housekeeping = { + description = "Run NetBox housekeeping job"; + wantedBy = [ "timers.target" ]; + + timerConfig = { + OnCalendar = "daily"; + }; + }; + + users.users.netbox = { + home = "${cfg.dataDir}"; + isSystemUser = true; + group = "netbox"; + }; + users.groups.netbox = {}; + users.groups."${config.services.redis.servers.netbox.user}".members = [ "netbox" ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/nextcloud-notify_push.nix b/nixpkgs/nixos/modules/services/web-apps/nextcloud-notify_push.nix new file mode 100644 index 000000000000..759daa0c50dc --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/nextcloud-notify_push.nix @@ -0,0 +1,123 @@ +{ config, options, lib, pkgs, ... }: + +let + cfg = config.services.nextcloud.notify_push; + cfgN = config.services.nextcloud; +in +{ + options.services.nextcloud.notify_push = { + enable = lib.mkEnableOption (lib.mdDoc "Notify push"); + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.nextcloud-notify_push; + defaultText = lib.literalMD "pkgs.nextcloud-notify_push"; + description = lib.mdDoc "Which package to use for notify_push"; + }; + + socketPath = lib.mkOption { + type = lib.types.str; + default = "/run/nextcloud-notify_push/sock"; + description = lib.mdDoc "Socket path to use for notify_push"; + }; + + logLevel = lib.mkOption { + type = lib.types.enum [ "error" "warn" "info" "debug" "trace" ]; + default = "error"; + description = lib.mdDoc "Log level"; + }; + + bendDomainToLocalhost = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Whether to add an entry to `/etc/hosts` for the configured nextcloud domain to point to `localhost` and add `localhost `to nextcloud's `trusted_proxies` config option. + + This is useful when nextcloud's domain is not a static IP address and when the reverse proxy cannot be bypassed because the backend connection is done via unix socket. + ''; + }; + } // ( + lib.genAttrs [ + "dbtype" + "dbname" + "dbuser" + "dbpassFile" + "dbhost" + "dbport" + "dbtableprefix" + ] ( + opt: options.services.nextcloud.config.${opt} // { + default = config.services.nextcloud.config.${opt}; + defaultText = "config.services.nextcloud.config.${opt}"; + } + ) + ); + + config = lib.mkIf cfg.enable { + systemd.services.nextcloud-notify_push = let + nextcloudUrl = "http${lib.optionalString cfgN.https "s"}://${cfgN.hostName}"; + in { + description = "Push daemon for Nextcloud clients"; + documentation = [ "https://github.com/nextcloud/notify_push" ]; + after = [ + "phpfpm-nextcloud.service" + "redis-nextcloud.service" + ]; + wantedBy = [ "multi-user.target" ]; + environment = { + NEXTCLOUD_URL = nextcloudUrl; + SOCKET_PATH = cfg.socketPath; + DATABASE_PREFIX = cfg.dbtableprefix; + LOG = cfg.logLevel; + }; + postStart = '' + ${cfgN.occ}/bin/nextcloud-occ notify_push:setup ${nextcloudUrl}/push + ''; + script = let + dbType = if cfg.dbtype == "pgsql" then "postgresql" else cfg.dbtype; + dbUser = lib.optionalString (cfg.dbuser != null) cfg.dbuser; + dbPass = lib.optionalString (cfg.dbpassFile != null) ":$DATABASE_PASSWORD"; + isSocket = lib.hasPrefix "/" (toString cfg.dbhost); + dbHost = lib.optionalString (cfg.dbhost != null) (if + isSocket then + if dbType == "postgresql" then "?host=${cfg.dbhost}" else + if dbType == "mysql" then "?socket=${cfg.dbhost}" else throw "unsupported dbtype" + else + "@${cfg.dbhost}"); + dbName = lib.optionalString (cfg.dbname != null) "/${cfg.dbname}"; + dbUrl = "${dbType}://${dbUser}${dbPass}${lib.optionalString (!isSocket) dbHost}${dbName}${lib.optionalString isSocket dbHost}"; + in lib.optionalString (dbPass != "") '' + export DATABASE_PASSWORD="$(<"${cfg.dbpassFile}")" + '' + '' + export DATABASE_URL="${dbUrl}" + ${cfg.package}/bin/notify_push '${cfgN.datadir}/config/config.php' + ''; + serviceConfig = { + User = "nextcloud"; + Group = "nextcloud"; + RuntimeDirectory = [ "nextcloud-notify_push" ]; + Restart = "on-failure"; + RestartSec = "5s"; + }; + }; + + networking.hosts = lib.mkIf cfg.bendDomainToLocalhost { + "127.0.0.1" = [ cfgN.hostName ]; + "::1" = [ cfgN.hostName ]; + }; + + services = lib.mkMerge [ + { + nginx.virtualHosts.${cfgN.hostName}.locations."^~ /push/" = { + proxyPass = "http://unix:${cfg.socketPath}"; + proxyWebsockets = true; + recommendedProxySettings = true; + }; + } + + (lib.mkIf cfg.bendDomainToLocalhost { + nextcloud.extraOptions.trusted_proxies = [ "127.0.0.1" "::1" ]; + }) + ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/nextcloud.md b/nixpkgs/nixos/modules/services/web-apps/nextcloud.md new file mode 100644 index 000000000000..5be81a18dfec --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/nextcloud.md @@ -0,0 +1,227 @@ +# Nextcloud {#module-services-nextcloud} + +[Nextcloud](https://nextcloud.com/) is an open-source, +self-hostable cloud platform. The server setup can be automated using +[services.nextcloud](#opt-services.nextcloud.enable). A +desktop client is packaged at `pkgs.nextcloud-client`. + +The current default by NixOS is `nextcloud26` which is also the latest +major version available. + +## Basic usage {#module-services-nextcloud-basic-usage} + +Nextcloud is a PHP-based application which requires an HTTP server +([`services.nextcloud`](#opt-services.nextcloud.enable) +and optionally supports +[`services.nginx`](#opt-services.nginx.enable)). + +For the database, you can set +[`services.nextcloud.config.dbtype`](#opt-services.nextcloud.config.dbtype) to +either `sqlite` (the default), `mysql`, or `pgsql`. The simplest is `sqlite`, +which will be automatically created and managed by the application. For the +last two, you can easily create a local database by setting +[`services.nextcloud.database.createLocally`](#opt-services.nextcloud.database.createLocally) +to `true`, Nextcloud will automatically be configured to connect to it through +socket. + +A very basic configuration may look like this: +``` +{ pkgs, ... }: +{ + services.nextcloud = { + enable = true; + hostName = "nextcloud.tld"; + database.createLocally = true; + config = { + dbtype = "pgsql"; + adminpassFile = "/path/to/admin-pass-file"; + }; + }; + + networking.firewall.allowedTCPPorts = [ 80 443 ]; +} +``` + +The `hostName` option is used internally to configure an HTTP +server using [`PHP-FPM`](https://php-fpm.org/) +and `nginx`. The `config` attribute set is +used by the imperative installer and all values are written to an additional file +to ensure that changes can be applied by changing the module's options. + +In case the application serves multiple domains (those are checked with +[`$_SERVER['HTTP_HOST']`](http://php.net/manual/en/reserved.variables.server.php)) +it's needed to add them to +[`services.nextcloud.config.extraTrustedDomains`](#opt-services.nextcloud.config.extraTrustedDomains). + +Auto updates for Nextcloud apps can be enabled using +[`services.nextcloud.autoUpdateApps`](#opt-services.nextcloud.autoUpdateApps.enable). + +## Common problems {#module-services-nextcloud-pitfalls-during-upgrade} + + - **General notes.** + Unfortunately Nextcloud appears to be very stateful when it comes to + managing its own configuration. The config file lives in the home directory + of the `nextcloud` user (by default + `/var/lib/nextcloud/config/config.php`) and is also used to + track several states of the application (e.g., whether installed or not). + + All configuration parameters are also stored in + {file}`/var/lib/nextcloud/config/override.config.php` which is generated by + the module and linked from the store to ensure that all values from + {file}`config.php` can be modified by the module. + However {file}`config.php` manages the application's state and shouldn't be + touched manually because of that. + + ::: {.warning} + Don't delete {file}`config.php`! This file + tracks the application's state and a deletion can cause unwanted + side-effects! + ::: + + ::: {.warning} + Don't rerun `nextcloud-occ maintenance:install`! + This command tries to install the application + and can cause unwanted side-effects! + ::: + - **Multiple version upgrades.** + Nextcloud doesn't allow to move more than one major-version forward. E.g., if you're on + `v16`, you cannot upgrade to `v18`, you need to upgrade to + `v17` first. This is ensured automatically as long as the + [stateVersion](#opt-system.stateVersion) is declared properly. In that case + the oldest version available (one major behind the one from the previous NixOS + release) will be selected by default and the module will generate a warning that reminds + the user to upgrade to latest Nextcloud *after* that deploy. + - **`Error: Command "upgrade" is not defined.`** + This error usually occurs if the initial installation + ({command}`nextcloud-occ maintenance:install`) has failed. After that, the application + is not installed, but the upgrade is attempted to be executed. Further context can + be found in [NixOS/nixpkgs#111175](https://github.com/NixOS/nixpkgs/issues/111175). + + First of all, it makes sense to find out what went wrong by looking at the logs + of the installation via {command}`journalctl -u nextcloud-setup` and try to fix + the underlying issue. + + - If this occurs on an *existing* setup, this is most likely because + the maintenance mode is active. It can be deactivated by running + {command}`nextcloud-occ maintenance:mode --off`. It's advisable though to + check the logs first on why the maintenance mode was activated. + - ::: {.warning} + Only perform the following measures on + *freshly installed instances!* + ::: + + A re-run of the installer can be forced by *deleting* + {file}`/var/lib/nextcloud/config/config.php`. This is the only time + advisable because the fresh install doesn't have any state that can be lost. + In case that doesn't help, an entire re-creation can be forced via + {command}`rm -rf ~nextcloud/`. + + - **Server-side encryption.** + Nextcloud supports [server-side encryption (SSE)](https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/encryption_configuration.html). + This is not an end-to-end encryption, but can be used to encrypt files that will be persisted + to external storage such as S3. Please note that this won't work anymore when using OpenSSL 3 + for PHP's openssl extension and **Nextcloud 25 or older** because this is implemented using the + legacy cipher RC4. For Nextcloud26 this isn't relevant anymore, because Nextcloud has an RC4 implementation + written in native PHP and thus doesn't need `ext-openssl` for that anymore. + If [](#opt-system.stateVersion) is *above* `22.05`, + this is disabled by default. To turn it on again and for further information please refer to + [](#opt-services.nextcloud.enableBrokenCiphersForSSE). + +## Using an alternative webserver as reverse-proxy (e.g. `httpd`) {#module-services-nextcloud-httpd} + +By default, `nginx` is used as reverse-proxy for `nextcloud`. +However, it's possible to use e.g. `httpd` by explicitly disabling +`nginx` using [](#opt-services.nginx.enable) and fixing the +settings `listen.owner` & `listen.group` in the +[corresponding `phpfpm` pool](#opt-services.phpfpm.pools). + +An exemplary configuration may look like this: +``` +{ config, lib, pkgs, ... }: { + services.nginx.enable = false; + services.nextcloud = { + enable = true; + hostName = "localhost"; + + /* further, required options */ + }; + services.phpfpm.pools.nextcloud.settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + }; + services.httpd = { + enable = true; + adminAddr = "webmaster@localhost"; + extraModules = [ "proxy_fcgi" ]; + virtualHosts."localhost" = { + documentRoot = config.services.nextcloud.package; + extraConfig = '' + <Directory "${config.services.nextcloud.package}"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${config.services.phpfpm.pools.nextcloud.socket}|fcgi://localhost/" + </If> + </FilesMatch> + <IfModule mod_rewrite.c> + RewriteEngine On + RewriteBase / + RewriteRule ^index\.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.php [L] + </IfModule> + DirectoryIndex index.php + Require all granted + Options +FollowSymLinks + </Directory> + ''; + }; + }; +} +``` + +## Installing Apps and PHP extensions {#installing-apps-php-extensions-nextcloud} + +Nextcloud apps are installed statefully through the web interface. +Some apps may require extra PHP extensions to be installed. +This can be configured with the [](#opt-services.nextcloud.phpExtraExtensions) setting. + +Alternatively, extra apps can also be declared with the [](#opt-services.nextcloud.extraApps) setting. +When using this setting, apps can no longer be managed statefully because this can lead to Nextcloud updating apps +that are managed by Nix. If you want automatic updates it is recommended that you use web interface to install apps. + +## Maintainer information {#module-services-nextcloud-maintainer-info} + +As stated in the previous paragraph, we must provide a clean upgrade-path for Nextcloud +since it cannot move more than one major version forward on a single upgrade. This chapter +adds some notes how Nextcloud updates should be rolled out in the future. + +While minor and patch-level updates are no problem and can be done directly in the +package-expression (and should be backported to supported stable branches after that), +major-releases should be added in a new attribute (e.g. Nextcloud `v19.0.0` +should be available in `nixpkgs` as `pkgs.nextcloud19`). +To provide simple upgrade paths it's generally useful to backport those as well to stable +branches. As long as the package-default isn't altered, this won't break existing setups. +After that, the versioning-warning in the `nextcloud`-module should be +updated to make sure that the +[package](#opt-services.nextcloud.package)-option selects the latest version +on fresh setups. + +If major-releases will be abandoned by upstream, we should check first if those are needed +in NixOS for a safe upgrade-path before removing those. In that case we should keep those +packages, but mark them as insecure in an expression like this (in +`<nixpkgs/pkgs/servers/nextcloud/default.nix>`): +``` +/* ... */ +{ + nextcloud17 = generic { + version = "17.0.x"; + sha256 = "0000000000000000000000000000000000000000000000000000"; + eol = true; + }; +} +``` + +Ideally we should make sure that it's possible to jump two NixOS versions forward: +i.e. the warnings and the logic in the module should guard a user to upgrade from a +Nextcloud on e.g. 19.09 to a Nextcloud on 20.09. diff --git a/nixpkgs/nixos/modules/services/web-apps/nextcloud.nix b/nixpkgs/nixos/modules/services/web-apps/nextcloud.nix new file mode 100644 index 000000000000..a8142cf42d75 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/nextcloud.nix @@ -0,0 +1,1198 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.nextcloud; + fpm = config.services.phpfpm.pools.nextcloud; + + jsonFormat = pkgs.formats.json {}; + + inherit (cfg) datadir; + + phpPackage = cfg.phpPackage.buildEnv { + extensions = { enabled, all }: + (with all; + # disable default openssl extension + (lib.filter (e: e.pname != "php-openssl") enabled) + # use OpenSSL 1.1 for RC4 Nextcloud encryption if user + # has acknowledged the brokenness of the ciphers (RC4). + # TODO: remove when https://github.com/nextcloud/server/issues/32003 is fixed. + ++ (if cfg.enableBrokenCiphersForSSE then [ cfg.phpPackage.extensions.openssl-legacy ] else [ cfg.phpPackage.extensions.openssl ]) + ++ optional cfg.enableImagemagick imagick + # Optionally enabled depending on caching settings + ++ optional cfg.caching.apcu apcu + ++ optional cfg.caching.redis redis + ++ optional cfg.caching.memcached memcached + ) + ++ cfg.phpExtraExtensions all; # Enabled by user + extraConfig = toKeyValue phpOptions; + }; + + toKeyValue = generators.toKeyValue { + mkKeyValue = generators.mkKeyValueDefault {} " = "; + }; + + phpOptions = { + upload_max_filesize = cfg.maxUploadSize; + post_max_size = cfg.maxUploadSize; + memory_limit = cfg.maxUploadSize; + } // cfg.phpOptions + // optionalAttrs cfg.caching.apcu { + "apc.enable_cli" = "1"; + }; + + occ = pkgs.writeScriptBin "nextcloud-occ" '' + #! ${pkgs.runtimeShell} + cd ${cfg.package} + sudo=exec + if [[ "$USER" != nextcloud ]]; then + sudo='exec /run/wrappers/bin/sudo -u nextcloud --preserve-env=NEXTCLOUD_CONFIG_DIR --preserve-env=OC_PASS' + fi + export NEXTCLOUD_CONFIG_DIR="${datadir}/config" + $sudo \ + ${phpPackage}/bin/php \ + occ "$@" + ''; + + inherit (config.system) stateVersion; + + mysqlLocal = cfg.database.createLocally && cfg.config.dbtype == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.config.dbtype == "pgsql"; + +in { + + imports = [ + (mkRemovedOptionModule [ "services" "nextcloud" "config" "adminpass" ] '' + Please use `services.nextcloud.config.adminpassFile' instead! + '') + (mkRemovedOptionModule [ "services" "nextcloud" "config" "dbpass" ] '' + Please use `services.nextcloud.config.dbpassFile' instead! + '') + (mkRemovedOptionModule [ "services" "nextcloud" "nginx" "enable" ] '' + The nextcloud module supports `nginx` as reverse-proxy by default and doesn't + support other reverse-proxies officially. + + However it's possible to use an alternative reverse-proxy by + + * disabling nginx + * setting `listen.owner` & `listen.group` in the phpfpm-pool to a different value + + Further details about this can be found in the `Nextcloud`-section of the NixOS-manual + (which can be opened e.g. by running `nixos-help`). + '') + (mkRemovedOptionModule [ "services" "nextcloud" "disableImagemagick" ] '' + Use services.nextcloud.enableImagemagick instead. + '') + ]; + + options.services.nextcloud = { + enable = mkEnableOption (lib.mdDoc "nextcloud"); + + enableBrokenCiphersForSSE = mkOption { + type = types.bool; + default = versionOlder stateVersion "22.11"; + defaultText = literalExpression "versionOlder system.stateVersion \"22.11\""; + description = lib.mdDoc '' + This option enables using the OpenSSL PHP extension linked against OpenSSL 1.1 + rather than latest OpenSSL (≥ 3), this is not recommended unless you need + it for server-side encryption (SSE). SSE uses the legacy RC4 cipher which is + considered broken for several years now. See also [RFC7465](https://datatracker.ietf.org/doc/html/rfc7465). + + This cipher has been disabled in OpenSSL ≥ 3 and requires + a specific legacy profile to re-enable it. + + If you deploy Nextcloud using OpenSSL ≥ 3 for PHP and have + server-side encryption configured, you will not be able to access + your files anymore. Enabling this option can restore access to your files. + Upon testing we didn't encounter any data corruption when turning + this on and off again, but this cannot be guaranteed for + each Nextcloud installation. + + It is `true` by default for systems with a [](#opt-system.stateVersion) below + `22.11` to make sure that existing installations won't break on update. On newer + NixOS systems you have to explicitly enable it on your own. + + Please note that this only provides additional value when using + external storage such as S3 since it's not an end-to-end encryption. + If this is not the case, + it is advised to [disable server-side encryption](https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/encryption_configuration.html#disabling-encryption) and set this to `false`. + + In the future, Nextcloud may move to AES-256-GCM, by then, + this option will be removed. + ''; + }; + hostName = mkOption { + type = types.str; + description = lib.mdDoc "FQDN for the nextcloud instance."; + }; + home = mkOption { + type = types.str; + default = "/var/lib/nextcloud"; + description = lib.mdDoc "Storage path of nextcloud."; + }; + datadir = mkOption { + type = types.str; + default = config.services.nextcloud.home; + defaultText = literalExpression "config.services.nextcloud.home"; + description = lib.mdDoc '' + Data storage path of nextcloud. Will be [](#opt-services.nextcloud.home) by default. + This folder will be populated with a config.php and data folder which contains the state of the instance (excl the database)."; + ''; + example = "/mnt/nextcloud-file"; + }; + extraApps = mkOption { + type = types.attrsOf types.package; + default = { }; + description = lib.mdDoc '' + Extra apps to install. Should be an attrSet of appid to packages generated by fetchNextcloudApp. + The appid must be identical to the "id" value in the apps appinfo/info.xml. + Using this will disable the appstore to prevent Nextcloud from updating these apps (see [](#opt-services.nextcloud.appstoreEnable)). + ''; + example = literalExpression '' + { + maps = pkgs.fetchNextcloudApp { + name = "maps"; + sha256 = "007y80idqg6b6zk6kjxg4vgw0z8fsxs9lajnv49vv1zjy6jx2i1i"; + url = "https://github.com/nextcloud/maps/releases/download/v0.1.9/maps-0.1.9.tar.gz"; + version = "0.1.9"; + }; + phonetrack = pkgs.fetchNextcloudApp { + name = "phonetrack"; + sha256 = "0qf366vbahyl27p9mshfma1as4nvql6w75zy2zk5xwwbp343vsbc"; + url = "https://gitlab.com/eneiluj/phonetrack-oc/-/wikis/uploads/931aaaf8dca24bf31a7e169a83c17235/phonetrack-0.6.9.tar.gz"; + version = "0.6.9"; + }; + } + ''; + }; + extraAppsEnable = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Automatically enable the apps in [](#opt-services.nextcloud.extraApps) every time nextcloud starts. + If set to false, apps need to be enabled in the Nextcloud user interface or with nextcloud-occ app:enable. + ''; + }; + appstoreEnable = mkOption { + type = types.nullOr types.bool; + default = null; + example = true; + description = lib.mdDoc '' + Allow the installation of apps and app updates from the store. + Enabled by default unless there are packages in [](#opt-services.nextcloud.extraApps). + Set to true to force enable the store even if [](#opt-services.nextcloud.extraApps) is used. + Set to false to disable the installation of apps from the global appstore. App management is always enabled regardless of this setting. + ''; + }; + logLevel = mkOption { + type = types.ints.between 0 4; + default = 2; + description = lib.mdDoc "Log level value between 0 (DEBUG) and 4 (FATAL)."; + }; + logType = mkOption { + type = types.enum [ "errorlog" "file" "syslog" "systemd" ]; + default = "syslog"; + description = lib.mdDoc '' + Logging backend to use. + systemd requires the php-systemd package to be added to services.nextcloud.phpExtraExtensions. + See the [nextcloud documentation](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/logging_configuration.html) for details. + ''; + }; + https = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Use https for generated links."; + }; + package = mkOption { + type = types.package; + description = lib.mdDoc "Which package to use for the Nextcloud instance."; + relatedPackages = [ "nextcloud25" "nextcloud26" ]; + }; + phpPackage = mkOption { + type = types.package; + relatedPackages = [ "php80" "php81" ]; + defaultText = "pkgs.php"; + description = lib.mdDoc '' + PHP package to use for Nextcloud. + ''; + }; + + maxUploadSize = mkOption { + default = "512M"; + type = types.str; + description = lib.mdDoc '' + Defines the upload limit for files. This changes the relevant options + in php.ini and nginx if enabled. + ''; + }; + + skeletonDirectory = mkOption { + default = ""; + type = types.str; + description = lib.mdDoc '' + The directory where the skeleton files are located. These files will be + copied to the data directory of new users. Leave empty to not copy any + skeleton files. + ''; + }; + + webfinger = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable this option if you plan on using the webfinger plugin. + The appropriate nginx rewrite rules will be added to your configuration. + ''; + }; + + phpExtraExtensions = mkOption { + type = with types; functionTo (listOf package); + default = all: []; + defaultText = literalExpression "all: []"; + description = lib.mdDoc '' + Additional PHP extensions to use for nextcloud. + By default, only extensions necessary for a vanilla nextcloud installation are enabled, + but you may choose from the list of available extensions and add further ones. + This is sometimes necessary to be able to install a certain nextcloud app that has additional requirements. + ''; + example = literalExpression '' + all: [ all.pdlib all.bz2 ] + ''; + }; + + phpOptions = mkOption { + type = types.attrsOf types.str; + default = { + short_open_tag = "Off"; + expose_php = "Off"; + error_reporting = "E_ALL & ~E_DEPRECATED & ~E_STRICT"; + display_errors = "stderr"; + "opcache.enable_cli" = "1"; + "opcache.interned_strings_buffer" = "8"; + "opcache.max_accelerated_files" = "10000"; + "opcache.memory_consumption" = "128"; + "opcache.revalidate_freq" = "1"; + "opcache.fast_shutdown" = "1"; + "openssl.cafile" = "/etc/ssl/certs/ca-certificates.crt"; + catch_workers_output = "yes"; + }; + description = lib.mdDoc '' + Options for PHP's php.ini file for nextcloud. + ''; + }; + + 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 = lib.mdDoc '' + Options for nextcloud's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives. + ''; + }; + + poolConfig = mkOption { + type = types.nullOr types.lines; + default = null; + description = lib.mdDoc '' + Options for nextcloud's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives. + ''; + }; + + fastcgiTimeout = mkOption { + type = types.int; + default = 120; + description = lib.mdDoc '' + FastCGI timeout for database connection in seconds. + ''; + }; + + database = { + + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Create the database and database user locally. + ''; + }; + + }; + + + config = { + dbtype = mkOption { + type = types.enum [ "sqlite" "pgsql" "mysql" ]; + default = "sqlite"; + description = lib.mdDoc "Database type."; + }; + dbname = mkOption { + type = types.nullOr types.str; + default = "nextcloud"; + description = lib.mdDoc "Database name."; + }; + dbuser = mkOption { + type = types.nullOr types.str; + default = "nextcloud"; + description = lib.mdDoc "Database user."; + }; + dbpassFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The full path to a file that contains the database password. + ''; + }; + dbhost = mkOption { + type = types.nullOr types.str; + default = + if pgsqlLocal then "/run/postgresql" + else if mysqlLocal then "localhost:/run/mysqld/mysqld.sock" + else "localhost"; + defaultText = "localhost"; + description = lib.mdDoc '' + Database host or socket path. Defaults to the correct unix socket + instead if `services.nextcloud.database.createLocally` is true and + `services.nextcloud.config.dbtype` is either `pgsql` or `mysql`. + ''; + }; + dbport = mkOption { + type = with types; nullOr (either int str); + default = null; + description = lib.mdDoc "Database port."; + }; + dbtableprefix = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "Table prefix in Nextcloud database."; + }; + adminuser = mkOption { + type = types.str; + default = "root"; + description = lib.mdDoc "Admin username."; + }; + adminpassFile = mkOption { + type = types.str; + description = lib.mdDoc '' + The full path to a file that contains the admin's password. Must be + readable by user `nextcloud`. + ''; + }; + + extraTrustedDomains = mkOption { + type = types.listOf types.str; + default = []; + description = lib.mdDoc '' + Trusted domains, from which the nextcloud installation will be + accessible. You don't need to add + `services.nextcloud.hostname` here. + ''; + }; + + trustedProxies = mkOption { + type = types.listOf types.str; + default = []; + description = lib.mdDoc '' + Trusted proxies, to provide if the nextcloud installation is being + proxied to secure against e.g. spoofing. + ''; + }; + + overwriteProtocol = mkOption { + type = types.nullOr (types.enum [ "http" "https" ]); + default = null; + example = "https"; + + description = lib.mdDoc '' + Force Nextcloud to always use HTTPS i.e. for link generation. Nextcloud + uses the currently used protocol by default, but when behind a reverse-proxy, + it may use `http` for everything although Nextcloud + may be served via HTTPS. + ''; + }; + + defaultPhoneRegion = mkOption { + default = null; + type = types.nullOr types.str; + example = "DE"; + description = lib.mdDoc '' + ::: {.warning} + This option exists since Nextcloud 21! If older versions are used, + this will throw an eval-error! + ::: + + [ISO 3611-1](https://www.iso.org/iso-3166-country-codes.html) + country codes for automatic phone-number detection without a country code. + + With e.g. `DE` set, the `+49` can be omitted for + phone-numbers. + ''; + }; + + objectstore = { + s3 = { + enable = mkEnableOption (lib.mdDoc '' + S3 object storage as primary storage. + + This mounts a bucket on an Amazon S3 object storage or compatible + implementation into the virtual filesystem. + + Further details about this feature can be found in the + [upstream documentation](https://docs.nextcloud.com/server/22/admin_manual/configuration_files/primary_storage.html). + ''); + bucket = mkOption { + type = types.str; + example = "nextcloud"; + description = lib.mdDoc '' + The name of the S3 bucket. + ''; + }; + autocreate = mkOption { + type = types.bool; + description = lib.mdDoc '' + Create the objectstore if it does not exist. + ''; + }; + key = mkOption { + type = types.str; + example = "EJ39ITYZEUH5BGWDRUFY"; + description = lib.mdDoc '' + The access key for the S3 bucket. + ''; + }; + secretFile = mkOption { + type = types.str; + example = "/var/nextcloud-objectstore-s3-secret"; + description = lib.mdDoc '' + The full path to a file that contains the access secret. Must be + readable by user `nextcloud`. + ''; + }; + hostname = mkOption { + type = types.nullOr types.str; + default = null; + example = "example.com"; + description = lib.mdDoc '' + Required for some non-Amazon implementations. + ''; + }; + port = mkOption { + type = types.nullOr types.port; + default = null; + description = lib.mdDoc '' + Required for some non-Amazon implementations. + ''; + }; + useSsl = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Use SSL for objectstore access. + ''; + }; + region = mkOption { + type = types.nullOr types.str; + default = null; + example = "REGION"; + description = lib.mdDoc '' + Required for some non-Amazon implementations. + ''; + }; + usePathStyle = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Required for some non-Amazon S3 implementations. + + Ordinarily, requests will be made with + `http://bucket.hostname.domain/`, but with path style + enabled requests are made with + `http://hostname.domain/bucket` instead. + ''; + }; + sseCKeyFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/var/nextcloud-objectstore-s3-sse-c-key"; + description = lib.mdDoc '' + If provided this is the full path to a file that contains the key + to enable [server-side encryption with customer-provided keys][1] + (SSE-C). + + The file must contain a random 32-byte key encoded as a base64 + string, e.g. generated with the command + + ``` + openssl rand 32 | base64 + ``` + + Must be readable by user `nextcloud`. + + [1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html + ''; + }; + }; + }; + }; + + enableImagemagick = mkEnableOption (lib.mdDoc '' + the ImageMagick module for PHP. + This is used by the theming app and for generating previews of certain images (e.g. SVG and HEIF). + You may want to disable it for increased security. In that case, previews will still be available + for some images (e.g. JPEG and PNG). + See <https://github.com/nextcloud/server/issues/13099>. + '') // { + default = true; + }; + + configureRedis = lib.mkOption { + type = lib.types.bool; + default = config.services.nextcloud.notify_push.enable; + defaultText = literalExpression "config.services.nextcloud.notify_push.enable"; + description = lib.mdDoc '' + Whether to configure nextcloud to use the recommended redis settings for small instances. + + ::: {.note} + The `notify_push` app requires redis to be configured. If this option is turned off, this must be configured manually. + ::: + ''; + }; + + caching = { + apcu = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to load the APCu module into PHP. + ''; + }; + redis = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to load the Redis module into PHP. + You still need to enable Redis in your config.php. + See https://docs.nextcloud.com/server/14/admin_manual/configuration_server/caching_configuration.html + ''; + }; + memcached = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to load the Memcached module into PHP. + You still need to enable Memcached in your config.php. + See https://docs.nextcloud.com/server/14/admin_manual/configuration_server/caching_configuration.html + ''; + }; + }; + autoUpdateApps = { + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Run regular auto update of all apps installed from the nextcloud app store. + ''; + }; + startAt = mkOption { + type = with types; either str (listOf str); + default = "05:00:00"; + example = "Sun 14:00:00"; + description = lib.mdDoc '' + When to run the update. See `systemd.services.<name>.startAt`. + ''; + }; + }; + occ = mkOption { + type = types.package; + default = occ; + defaultText = literalMD "generated script"; + internal = true; + description = lib.mdDoc '' + The nextcloud-occ program preconfigured to target this Nextcloud instance. + ''; + }; + globalProfiles = mkEnableOption (lib.mdDoc "global profiles") // { + description = lib.mdDoc '' + Makes user-profiles globally available under `nextcloud.tld/u/user.name`. + Even though it's enabled by default in Nextcloud, it must be explicitly enabled + here because it has the side-effect that personal information is even accessible to + unauthenticated users by default. + + By default, the following properties are set to “Show to everyone” + if this flag is enabled: + - About + - Full name + - Headline + - Organisation + - Profile picture + - Role + - Twitter + - Website + + Only has an effect in Nextcloud 23 and later. + ''; + }; + + extraOptions = mkOption { + type = jsonFormat.type; + default = {}; + description = lib.mdDoc '' + Extra options which should be appended to nextcloud's config.php file. + ''; + example = literalExpression '' { + redis = { + host = "/run/redis/redis.sock"; + port = 0; + dbindex = 0; + password = "secret"; + timeout = 1.5; + }; + } ''; + }; + + secretFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Secret options which will be appended to nextcloud's config.php file (written as JSON, in the same + form as the [](#opt-services.nextcloud.extraOptions) option), for example + `{"redis":{"password":"secret"}}`. + ''; + }; + + nginx = { + recommendedHttpHeaders = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Enable additional recommended HTTP response headers"; + }; + hstsMaxAge = mkOption { + type = types.ints.positive; + default = 15552000; + description = lib.mdDoc '' + Value for the `max-age` directive of the HTTP + `Strict-Transport-Security` header. + + See section 6.1.1 of IETF RFC 6797 for detailed information on this + directive and header. + ''; + }; + }; + }; + + config = mkIf cfg.enable (mkMerge [ + { warnings = let + latest = 26; + upgradeWarning = major: nixos: + '' + A legacy Nextcloud install (from before NixOS ${nixos}) may be installed. + + After nextcloud${toString major} is installed successfully, you can safely upgrade + to ${toString (major + 1)}. The latest version available is nextcloud${toString latest}. + + Please note that Nextcloud doesn't support upgrades across multiple major versions + (i.e. an upgrade from 16 is possible to 17, but not 16 to 18). + + The package can be upgraded by explicitly declaring the service-option + `services.nextcloud.package`. + ''; + + in (optional (cfg.poolConfig != null) '' + Using config.services.nextcloud.poolConfig is deprecated and will become unsupported in a future release. + Please migrate your configuration to config.services.nextcloud.poolSettings. + '') + ++ (optional (versionOlder cfg.package.version "23") (upgradeWarning 22 "22.05")) + ++ (optional (versionOlder cfg.package.version "24") (upgradeWarning 23 "22.05")) + ++ (optional (versionOlder cfg.package.version "25") (upgradeWarning 24 "22.11")) + ++ (optional (versionOlder cfg.package.version "26") (upgradeWarning 25 "23.05")) + ++ (optional cfg.enableBrokenCiphersForSSE '' + You're using PHP's openssl extension built against OpenSSL 1.1 for Nextcloud. + This is only necessary if you're using Nextcloud's server-side encryption. + Please keep in mind that it's using the broken RC4 cipher. + + If you don't use that feature, you can switch to OpenSSL 3 and get + rid of this warning by declaring + + services.nextcloud.enableBrokenCiphersForSSE = false; + + If you need to use server-side encryption you can ignore this warning. + Otherwise you'd have to disable server-side encryption first in order + to be able to safely disable this option and get rid of this warning. + See <https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/encryption_configuration.html#disabling-encryption> on how to achieve this. + + For more context, here is the implementing pull request: https://github.com/NixOS/nixpkgs/pull/198470 + '') + ++ (optional (cfg.enableBrokenCiphersForSSE && versionAtLeast cfg.package.version "26") '' + Nextcloud26 supports RC4 without requiring legacy OpenSSL, so + `services.nextcloud.enableBrokenCiphersForSSE` can be set to `false`. + ''); + + services.nextcloud.package = with pkgs; + mkDefault ( + if pkgs ? nextcloud + then throw '' + The `pkgs.nextcloud`-attribute has been removed. If it's supposed to be the default + nextcloud defined in an overlay, please set `services.nextcloud.package` to + `pkgs.nextcloud`. + '' + else if versionOlder stateVersion "22.11" then nextcloud24 + else if versionOlder stateVersion "23.05" then nextcloud25 + else nextcloud26 + ); + + services.nextcloud.phpPackage = + if versionOlder cfg.package.version "26" then pkgs.php81 + else pkgs.php82; + } + + { assertions = [ + { assertion = cfg.database.createLocally -> cfg.config.dbpassFile == null; + message = '' + Using `services.nextcloud.database.createLocally` with database + password authentication is no longer supported. + + If you use an external database (or want to use password auth for any + other reason), set `services.nextcloud.database.createLocally` to + `false`. The database won't be managed for you (use `services.mysql` + if you want to set it up). + + If you want this module to manage your nextcloud database for you, + unset `services.nextcloud.config.dbpassFile` and + `services.nextcloud.config.dbhost` to use socket authentication + instead of password. + ''; + } + ]; } + + { systemd.timers.nextcloud-cron = { + wantedBy = [ "timers.target" ]; + after = [ "nextcloud-setup.service" ]; + timerConfig.OnBootSec = "5m"; + timerConfig.OnUnitActiveSec = "5m"; + timerConfig.Unit = "nextcloud-cron.service"; + }; + + systemd.tmpfiles.rules = ["d ${cfg.home} 0750 nextcloud nextcloud"]; + + systemd.services = { + # When upgrading the Nextcloud package, Nextcloud can report errors such as + # "The files of the app [all apps in /var/lib/nextcloud/apps] were not replaced correctly" + # Restarting phpfpm on Nextcloud package update fixes these issues (but this is a workaround). + phpfpm-nextcloud.restartTriggers = [ cfg.package ]; + + nextcloud-setup = let + c = cfg.config; + writePhpArray = a: "[${concatMapStringsSep "," (val: ''"${toString val}"'') a}]"; + requiresReadSecretFunction = c.dbpassFile != null || c.objectstore.s3.enable; + objectstoreConfig = let s3 = c.objectstore.s3; in optionalString s3.enable '' + 'objectstore' => [ + 'class' => '\\OC\\Files\\ObjectStore\\S3', + 'arguments' => [ + 'bucket' => '${s3.bucket}', + 'autocreate' => ${boolToString s3.autocreate}, + 'key' => '${s3.key}', + 'secret' => nix_read_secret('${s3.secretFile}'), + ${optionalString (s3.hostname != null) "'hostname' => '${s3.hostname}',"} + ${optionalString (s3.port != null) "'port' => ${toString s3.port},"} + 'use_ssl' => ${boolToString s3.useSsl}, + ${optionalString (s3.region != null) "'region' => '${s3.region}',"} + 'use_path_style' => ${boolToString s3.usePathStyle}, + ${optionalString (s3.sseCKeyFile != null) "'sse_c_key' => nix_read_secret('${s3.sseCKeyFile}'),"} + ], + ] + ''; + + showAppStoreSetting = cfg.appstoreEnable != null || cfg.extraApps != {}; + renderedAppStoreSetting = + let + x = cfg.appstoreEnable; + in + if x == null then "false" + else boolToString x; + + nextcloudGreaterOrEqualThan = req: versionAtLeast cfg.package.version req; + + overrideConfig = pkgs.writeText "nextcloud-config.php" '' + <?php + ${optionalString requiresReadSecretFunction '' + function nix_read_secret($file) { + if (!file_exists($file)) { + throw new \RuntimeException(sprintf( + "Cannot start Nextcloud, secret file %s set by NixOS doesn't seem to " + . "exist! Please make sure that the file exists and has appropriate " + . "permissions for user & group 'nextcloud'!", + $file + )); + } + return trim(file_get_contents($file)); + }''} + function nix_decode_json_file($file, $error) { + if (!file_exists($file)) { + throw new \RuntimeException(sprintf($error, $file)); + } + $decoded = json_decode(file_get_contents($file), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException(sprintf("Cannot decode %s, because: %s", $file, json_last_error_msg())); + } + + return $decoded; + } + $CONFIG = [ + 'apps_paths' => [ + ${optionalString (cfg.extraApps != { }) "[ 'path' => '${cfg.home}/nix-apps', 'url' => '/nix-apps', 'writable' => false ],"} + [ 'path' => '${cfg.home}/apps', 'url' => '/apps', 'writable' => false ], + [ 'path' => '${cfg.home}/store-apps', 'url' => '/store-apps', 'writable' => true ], + ], + ${optionalString (showAppStoreSetting) "'appstoreenabled' => ${renderedAppStoreSetting},"} + 'datadirectory' => '${datadir}/data', + 'skeletondirectory' => '${cfg.skeletonDirectory}', + ${optionalString cfg.caching.apcu "'memcache.local' => '\\OC\\Memcache\\APCu',"} + 'log_type' => '${cfg.logType}', + 'loglevel' => '${builtins.toString cfg.logLevel}', + ${optionalString (c.overwriteProtocol != null) "'overwriteprotocol' => '${c.overwriteProtocol}',"} + ${optionalString (c.dbname != null) "'dbname' => '${c.dbname}',"} + ${optionalString (c.dbhost != null) "'dbhost' => '${c.dbhost}',"} + ${optionalString (c.dbport != null) "'dbport' => '${toString c.dbport}',"} + ${optionalString (c.dbuser != null) "'dbuser' => '${c.dbuser}',"} + ${optionalString (c.dbtableprefix != null) "'dbtableprefix' => '${toString c.dbtableprefix}',"} + ${optionalString (c.dbpassFile != null) '' + 'dbpassword' => nix_read_secret( + "${c.dbpassFile}" + ), + '' + } + 'dbtype' => '${c.dbtype}', + 'trusted_domains' => ${writePhpArray ([ cfg.hostName ] ++ c.extraTrustedDomains)}, + 'trusted_proxies' => ${writePhpArray (c.trustedProxies)}, + ${optionalString (c.defaultPhoneRegion != null) "'default_phone_region' => '${c.defaultPhoneRegion}',"} + ${optionalString (nextcloudGreaterOrEqualThan "23") "'profile.enabled' => ${boolToString cfg.globalProfiles},"} + ${objectstoreConfig} + ]; + + $CONFIG = array_replace_recursive($CONFIG, nix_decode_json_file( + "${jsonFormat.generate "nextcloud-extraOptions.json" cfg.extraOptions}", + "impossible: this should never happen (decoding generated extraOptions file %s failed)" + )); + + ${optionalString (cfg.secretFile != null) '' + $CONFIG = array_replace_recursive($CONFIG, nix_decode_json_file( + "${cfg.secretFile}", + "Cannot start Nextcloud, secrets file %s set by NixOS doesn't exist!" + )); + ''} + ''; + occInstallCmd = let + mkExport = { arg, value }: "export ${arg}=${value}"; + dbpass = { + arg = "DBPASS"; + value = if c.dbpassFile != null + then ''"$(<"${toString c.dbpassFile}")"'' + else ''""''; + }; + adminpass = { + arg = "ADMINPASS"; + value = ''"$(<"${toString c.adminpassFile}")"''; + }; + installFlags = concatStringsSep " \\\n " + (mapAttrsToList (k: v: "${k} ${toString v}") { + "--database" = ''"${c.dbtype}"''; + # 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 c.dbname != null then "--database-name" else null} = ''"${c.dbname}"''; + ${if c.dbhost != null then "--database-host" else null} = ''"${c.dbhost}"''; + ${if c.dbport != null then "--database-port" else null} = ''"${toString c.dbport}"''; + ${if c.dbuser != null then "--database-user" else null} = ''"${c.dbuser}"''; + "--database-pass" = "\"\$${dbpass.arg}\""; + "--admin-user" = ''"${c.adminuser}"''; + "--admin-pass" = "\"\$${adminpass.arg}\""; + "--data-dir" = ''"${datadir}/data"''; + }); + in '' + ${mkExport dbpass} + ${mkExport adminpass} + ${occ}/bin/nextcloud-occ maintenance:install \ + ${installFlags} + ''; + occSetTrustedDomainsCmd = concatStringsSep "\n" (imap0 + (i: v: '' + ${occ}/bin/nextcloud-occ config:system:set trusted_domains \ + ${toString i} --value="${toString v}" + '') ([ cfg.hostName ] ++ cfg.config.extraTrustedDomains)); + + in { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-nextcloud.service" ]; + after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + requires = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + path = [ occ ]; + script = '' + ${optionalString (c.dbpassFile != null) '' + if [ ! -r "${c.dbpassFile}" ]; then + echo "dbpassFile ${c.dbpassFile} is not readable by nextcloud:nextcloud! Aborting..." + exit 1 + fi + if [ -z "$(<${c.dbpassFile})" ]; then + echo "dbpassFile ${c.dbpassFile} is empty!" + exit 1 + fi + ''} + if [ ! -r "${c.adminpassFile}" ]; then + echo "adminpassFile ${c.adminpassFile} is not readable by nextcloud:nextcloud! Aborting..." + exit 1 + fi + if [ -z "$(<${c.adminpassFile})" ]; then + echo "adminpassFile ${c.adminpassFile} is empty!" + exit 1 + fi + + ln -sf ${cfg.package}/apps ${cfg.home}/ + + # Install extra apps + ln -sfT \ + ${pkgs.linkFarm "nix-apps" + (mapAttrsToList (name: path: { inherit name path; }) cfg.extraApps)} \ + ${cfg.home}/nix-apps + + # create nextcloud directories. + # if the directories exist already with wrong permissions, we fix that + for dir in ${datadir}/config ${datadir}/data ${cfg.home}/store-apps ${cfg.home}/nix-apps; do + if [ ! -e $dir ]; then + install -o nextcloud -g nextcloud -d $dir + elif [ $(stat -c "%G" $dir) != "nextcloud" ]; then + chgrp -R nextcloud $dir + fi + done + + ln -sf ${overrideConfig} ${datadir}/config/override.config.php + + # Do not install if already installed + if [[ ! -e ${datadir}/config/config.php ]]; then + ${occInstallCmd} + fi + + ${occ}/bin/nextcloud-occ upgrade + + ${occ}/bin/nextcloud-occ config:system:delete trusted_domains + + ${optionalString (cfg.extraAppsEnable && cfg.extraApps != { }) '' + # Try to enable apps + ${occ}/bin/nextcloud-occ app:enable ${concatStringsSep " " (attrNames cfg.extraApps)} + ''} + + ${occSetTrustedDomainsCmd} + ''; + serviceConfig.Type = "oneshot"; + serviceConfig.User = "nextcloud"; + # On Nextcloud ≥ 26, it is not necessary to patch the database files to prevent + # an automatic creation of the database user. + environment.NC_setup_create_db_user = lib.mkIf (nextcloudGreaterOrEqualThan "26") "false"; + }; + nextcloud-cron = { + after = [ "nextcloud-setup.service" ]; + environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config"; + serviceConfig.Type = "oneshot"; + serviceConfig.User = "nextcloud"; + serviceConfig.ExecStart = "${phpPackage}/bin/php -f ${cfg.package}/cron.php"; + }; + nextcloud-update-plugins = mkIf cfg.autoUpdateApps.enable { + after = [ "nextcloud-setup.service" ]; + serviceConfig.Type = "oneshot"; + serviceConfig.ExecStart = "${occ}/bin/nextcloud-occ app:update --all"; + serviceConfig.User = "nextcloud"; + startAt = cfg.autoUpdateApps.startAt; + }; + }; + + services.phpfpm = { + pools.nextcloud = { + user = "nextcloud"; + group = "nextcloud"; + phpPackage = phpPackage; + phpEnv = { + NEXTCLOUD_CONFIG_DIR = "${datadir}/config"; + PATH = "/run/wrappers/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/usr/bin:/bin"; + }; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = config.services.nginx.user; + "listen.group" = config.services.nginx.group; + } // cfg.poolSettings; + extraConfig = cfg.poolConfig; + }; + }; + + users.users.nextcloud = { + home = "${cfg.home}"; + group = "nextcloud"; + isSystemUser = true; + }; + users.groups.nextcloud.members = [ "nextcloud" config.services.nginx.user ]; + + environment.systemPackages = [ occ ]; + + services.mysql = lib.mkIf mysqlLocal { + enable = true; + package = lib.mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.config.dbname ]; + ensureUsers = [{ + name = cfg.config.dbuser; + ensurePermissions = { "${cfg.config.dbname}.*" = "ALL PRIVILEGES"; }; + }]; + }; + + services.postgresql = mkIf pgsqlLocal { + enable = true; + ensureDatabases = [ cfg.config.dbname ]; + ensureUsers = [{ + name = cfg.config.dbuser; + ensurePermissions = { "DATABASE ${cfg.config.dbname}" = "ALL PRIVILEGES"; }; + }]; + }; + + services.redis.servers.nextcloud = lib.mkIf cfg.configureRedis { + enable = true; + user = "nextcloud"; + }; + + services.nextcloud = lib.mkIf cfg.configureRedis { + caching.redis = true; + extraOptions = { + memcache = { + distributed = ''\OC\Memcache\Redis''; + locking = ''\OC\Memcache\Redis''; + }; + redis = { + host = config.services.redis.servers.nextcloud.unixSocket; + port = 0; + }; + }; + }; + + services.nginx.enable = mkDefault true; + + services.nginx.virtualHosts.${cfg.hostName} = { + root = cfg.package; + locations = { + "= /robots.txt" = { + priority = 100; + extraConfig = '' + allow all; + access_log off; + ''; + }; + "= /" = { + priority = 100; + extraConfig = '' + if ( $http_user_agent ~ ^DavClnt ) { + return 302 /remote.php/webdav/$is_args$args; + } + ''; + }; + "/" = { + priority = 900; + extraConfig = "rewrite ^ /index.php;"; + }; + "~ ^/store-apps" = { + priority = 201; + extraConfig = "root ${cfg.home};"; + }; + "~ ^/nix-apps" = { + priority = 201; + extraConfig = "root ${cfg.home};"; + }; + "^~ /.well-known" = { + priority = 210; + extraConfig = '' + absolute_redirect off; + location = /.well-known/carddav { + return 301 /remote.php/dav; + } + location = /.well-known/caldav { + return 301 /remote.php/dav; + } + location ~ ^/\.well-known/(?!acme-challenge|pki-validation) { + return 301 /index.php$request_uri; + } + try_files $uri $uri/ =404; + ''; + }; + "~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/)".extraConfig = '' + return 404; + ''; + "~ ^/(?:\\.(?!well-known)|autotest|occ|issue|indie|db_|console)".extraConfig = '' + return 404; + ''; + "~ ^\\/(?:index|remote|public|cron|core\\/ajax\\/update|status|ocs\\/v[12]|updater\\/.+|oc[ms]-provider\\/.+|.+\\/richdocumentscode\\/proxy)\\.php(?:$|\\/)" = { + priority = 500; + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi.conf; + fastcgi_split_path_info ^(.+?\.php)(\\/.*)$; + set $path_info $fastcgi_path_info; + try_files $fastcgi_script_name =404; + fastcgi_param PATH_INFO $path_info; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTPS ${if cfg.https then "on" else "off"}; + fastcgi_param modHeadersAvailable true; + fastcgi_param front_controller_active true; + fastcgi_pass unix:${fpm.socket}; + fastcgi_intercept_errors on; + fastcgi_request_buffering off; + fastcgi_read_timeout ${builtins.toString cfg.fastcgiTimeout}s; + ''; + }; + "~ \\.(?:css|js|woff2?|svg|gif|map)$".extraConfig = '' + try_files $uri /index.php$request_uri; + expires 6M; + access_log off; + ''; + "~ ^\\/(?:updater|ocs-provider|ocm-provider)(?:$|\\/)".extraConfig = '' + try_files $uri/ =404; + index index.php; + ''; + "~ \\.(?:png|html|ttf|ico|jpg|jpeg|bcmap|mp4|webm)$".extraConfig = '' + try_files $uri /index.php$request_uri; + access_log off; + ''; + }; + extraConfig = '' + index index.php index.html /index.php$request_uri; + ${optionalString (cfg.nginx.recommendedHttpHeaders) '' + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Robots-Tag "noindex, nofollow"; + 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; + ''} + ${optionalString (cfg.https) '' + add_header Strict-Transport-Security "max-age=${toString cfg.nginx.hstsMaxAge}; includeSubDomains" always; + ''} + 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; + + ${optionalString cfg.webfinger '' + rewrite ^/.well-known/host-meta /public.php?service=host-meta last; + rewrite ^/.well-known/host-meta.json /public.php?service=host-meta-json last; + ''} + ''; + }; + } + ]); + + meta.doc = ./nextcloud.md; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/nexus.nix b/nixpkgs/nixos/modules/services/web-apps/nexus.nix new file mode 100644 index 000000000000..1f4a758b87ea --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/nexus.nix @@ -0,0 +1,156 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.nexus; + +in + +{ + options = { + services.nexus = { + enable = mkEnableOption (lib.mdDoc "Sonatype Nexus3 OSS service"); + + package = mkOption { + type = types.package; + default = pkgs.nexus; + defaultText = literalExpression "pkgs.nexus"; + description = lib.mdDoc "Package which runs Nexus3"; + }; + + user = mkOption { + type = types.str; + default = "nexus"; + description = lib.mdDoc "User which runs Nexus3."; + }; + + group = mkOption { + type = types.str; + default = "nexus"; + description = lib.mdDoc "Group which runs Nexus3."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/sonatype-work"; + description = lib.mdDoc "Home directory of the Nexus3 instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc "Address to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8081; + description = lib.mdDoc "Port to listen on."; + }; + + jvmOpts = mkOption { + type = types.lines; + default = '' + -Xms1200M + -Xmx1200M + -XX:MaxDirectMemorySize=2G + -XX:+UnlockDiagnosticVMOptions + -XX:+UnsyncloadClass + -XX:+LogVMOutput + -XX:LogFile=${cfg.home}/nexus3/log/jvm.log + -XX:-OmitStackTraceInFastThrow + -Djava.net.preferIPv4Stack=true + -Dkaraf.home=${cfg.package} + -Dkaraf.base=${cfg.package} + -Dkaraf.etc=${cfg.package}/etc/karaf + -Djava.util.logging.config.file=${cfg.package}/etc/karaf/java.util.logging.properties + -Dkaraf.data=${cfg.home}/nexus3 + -Djava.io.tmpdir=${cfg.home}/nexus3/tmp + -Dkaraf.startLocalConsole=false + -Djava.endorsed.dirs=${cfg.package}/lib/endorsed + ''; + defaultText = literalExpression '' + ''' + -Xms1200M + -Xmx1200M + -XX:MaxDirectMemorySize=2G + -XX:+UnlockDiagnosticVMOptions + -XX:+UnsyncloadClass + -XX:+LogVMOutput + -XX:LogFile=''${home}/nexus3/log/jvm.log + -XX:-OmitStackTraceInFastThrow + -Djava.net.preferIPv4Stack=true + -Dkaraf.home=''${package} + -Dkaraf.base=''${package} + -Dkaraf.etc=''${package}/etc/karaf + -Djava.util.logging.config.file=''${package}/etc/karaf/java.util.logging.properties + -Dkaraf.data=''${home}/nexus3 + -Djava.io.tmpdir=''${home}/nexus3/tmp + -Dkaraf.startLocalConsole=false + -Djava.endorsed.dirs=''${package}/lib/endorsed + ''' + ''; + + description = lib.mdDoc '' + Options for the JVM written to `nexus.jvmopts`. + Please refer to the docs (https://help.sonatype.com/repomanager3/installation/configuring-the-runtime-environment) + for further information. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.home; + createHome = true; + }; + + users.groups.${cfg.group} = {}; + + systemd.services.nexus = { + description = "Sonatype Nexus3"; + + wantedBy = [ "multi-user.target" ]; + + path = [ cfg.home ]; + + environment = { + NEXUS_USER = cfg.user; + NEXUS_HOME = cfg.home; + + VM_OPTS_FILE = pkgs.writeText "nexus.vmoptions" cfg.jvmOpts; + }; + + preStart = '' + mkdir -p ${cfg.home}/nexus3/etc + + if [ ! -f ${cfg.home}/nexus3/etc/nexus.properties ]; then + echo "# Jetty section" > ${cfg.home}/nexus3/etc/nexus.properties + echo "application-port=${toString cfg.listenPort}" >> ${cfg.home}/nexus3/etc/nexus.properties + echo "application-host=${toString cfg.listenAddress}" >> ${cfg.home}/nexus3/etc/nexus.properties + else + sed 's/^application-port=.*/application-port=${toString cfg.listenPort}/' -i ${cfg.home}/nexus3/etc/nexus.properties + sed 's/^# application-port=.*/application-port=${toString cfg.listenPort}/' -i ${cfg.home}/nexus3/etc/nexus.properties + sed 's/^application-host=.*/application-host=${toString cfg.listenAddress}/' -i ${cfg.home}/nexus3/etc/nexus.properties + sed 's/^# application-host=.*/application-host=${toString cfg.listenAddress}/' -i ${cfg.home}/nexus3/etc/nexus.properties + fi + ''; + + script = "${cfg.package}/bin/nexus run"; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + LimitNOFILE = 102642; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ ironpinguin ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/nifi.nix b/nixpkgs/nixos/modules/services/web-apps/nifi.nix new file mode 100644 index 000000000000..f643e24d81d9 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/nifi.nix @@ -0,0 +1,318 @@ +{ lib, pkgs, config, options, ... }: + +let + cfg = config.services.nifi; + opt = options.services.nifi; + + env = { + NIFI_OVERRIDE_NIFIENV = "true"; + NIFI_HOME = "/var/lib/nifi"; + NIFI_PID_DIR = "/run/nifi"; + NIFI_LOG_DIR = "/var/log/nifi"; + }; + + envFile = pkgs.writeText "nifi.env" (lib.concatMapStrings (s: s + "\n") ( + (lib.concatLists (lib.mapAttrsToList (name: value: + if value != null then [ + "${name}=\"${toString value}\"" + ] else [] + ) env)))); + + nifiEnv = pkgs.writeShellScriptBin "nifi-env" '' + set -a + source "${envFile}" + eval -- "\$@" + ''; + +in { + options = { + services.nifi = { + enable = lib.mkEnableOption (lib.mdDoc "Apache NiFi"); + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.nifi; + defaultText = lib.literalExpression "pkgs.nifi"; + description = lib.mdDoc "Apache NiFi package to use."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "nifi"; + description = lib.mdDoc "User account where Apache NiFi runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "nifi"; + description = lib.mdDoc "Group account where Apache NiFi runs."; + }; + + enableHTTPS = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc "Enable HTTPS protocol. Don`t use in production."; + }; + + listenHost = lib.mkOption { + type = lib.types.str; + default = if cfg.enableHTTPS then "0.0.0.0" else "127.0.0.1"; + defaultText = lib.literalExpression '' + if config.${opt.enableHTTPS} + then "0.0.0.0" + else "127.0.0.1" + ''; + description = lib.mdDoc "Bind to an ip for Apache NiFi web-ui."; + }; + + listenPort = lib.mkOption { + type = lib.types.int; + default = if cfg.enableHTTPS then 8443 else 8080; + defaultText = lib.literalExpression '' + if config.${opt.enableHTTPS} + then "8443" + else "8000" + ''; + description = lib.mdDoc "Bind to a port for Apache NiFi web-ui."; + }; + + proxyHost = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = if cfg.enableHTTPS then "0.0.0.0" else null; + defaultText = lib.literalExpression '' + if config.${opt.enableHTTPS} + then "0.0.0.0" + else null + ''; + description = lib.mdDoc "Allow requests from a specific host."; + }; + + proxyPort = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = if cfg.enableHTTPS then 8443 else null; + defaultText = lib.literalExpression '' + if config.${opt.enableHTTPS} + then "8443" + else null + ''; + description = lib.mdDoc "Allow requests from a specific port."; + }; + + initUser = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc "Initial user account for Apache NiFi. Username must be at least 4 characters."; + }; + + initPasswordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/nifi/password-nifi"; + description = lib.mdDoc "nitial password for Apache NiFi. Password must be at least 12 characters."; + }; + + initJavaHeapSize = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + example = 1024; + description = lib.mdDoc "Set the initial heap size for the JVM in MB."; + }; + + maxJavaHeapSize = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + example = 2048; + description = lib.mdDoc "Set the initial heap size for the JVM in MB."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { assertion = cfg.initUser!=null || cfg.initPasswordFile==null; + message = '' + <option>services.nifi.initUser</option> needs to be set if <option>services.nifi.initPasswordFile</option> enabled. + ''; + } + { assertion = cfg.initUser==null || cfg.initPasswordFile!=null; + message = '' + <option>services.nifi.initPasswordFile</option> needs to be set if <option>services.nifi.initUser</option> enabled. + ''; + } + { assertion = cfg.proxyHost==null || cfg.proxyPort!=null; + message = '' + <option>services.nifi.proxyPort</option> needs to be set if <option>services.nifi.proxyHost</option> value specified. + ''; + } + { assertion = cfg.proxyHost!=null || cfg.proxyPort==null; + message = '' + <option>services.nifi.proxyHost</option> needs to be set if <option>services.nifi.proxyPort</option> value specified. + ''; + } + { assertion = cfg.initJavaHeapSize==null || cfg.maxJavaHeapSize!=null; + message = '' + <option>services.nifi.maxJavaHeapSize</option> needs to be set if <option>services.nifi.initJavaHeapSize</option> value specified. + ''; + } + { assertion = cfg.initJavaHeapSize!=null || cfg.maxJavaHeapSize==null; + message = '' + <option>services.nifi.initJavaHeapSize</option> needs to be set if <option>services.nifi.maxJavaHeapSize</option> value specified. + ''; + } + ]; + + warnings = lib.optional (cfg.enableHTTPS==false) '' + Please do not disable HTTPS mode in production. In this mode, access to the nifi is opened without authentication. + ''; + + systemd.tmpfiles.rules = [ + "d '/var/lib/nifi/conf' 0750 ${cfg.user} ${cfg.group}" + "L+ '/var/lib/nifi/lib' - - - - ${cfg.package}/lib" + ]; + + + systemd.services.nifi = { + description = "Apache NiFi"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = env; + path = [ pkgs.gawk ]; + + serviceConfig = { + Type = "forking"; + PIDFile = "/run/nifi/nifi.pid"; + ExecStartPre = pkgs.writeScript "nifi-pre-start.sh" '' + #!/bin/sh + umask 077 + test -f '/var/lib/nifi/conf/authorizers.xml' || (cp '${cfg.package}/share/nifi/conf/authorizers.xml' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/authorizers.xml') + test -f '/var/lib/nifi/conf/bootstrap.conf' || (cp '${cfg.package}/share/nifi/conf/bootstrap.conf' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/bootstrap.conf') + test -f '/var/lib/nifi/conf/bootstrap-hashicorp-vault.conf' || (cp '${cfg.package}/share/nifi/conf/bootstrap-hashicorp-vault.conf' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/bootstrap-hashicorp-vault.conf') + test -f '/var/lib/nifi/conf/bootstrap-notification-services.xml' || (cp '${cfg.package}/share/nifi/conf/bootstrap-notification-services.xml' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/bootstrap-notification-services.xml') + test -f '/var/lib/nifi/conf/logback.xml' || (cp '${cfg.package}/share/nifi/conf/logback.xml' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/logback.xml') + test -f '/var/lib/nifi/conf/login-identity-providers.xml' || (cp '${cfg.package}/share/nifi/conf/login-identity-providers.xml' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/login-identity-providers.xml') + test -f '/var/lib/nifi/conf/nifi.properties' || (cp '${cfg.package}/share/nifi/conf/nifi.properties' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/nifi.properties') + test -f '/var/lib/nifi/conf/stateless-logback.xml' || (cp '${cfg.package}/share/nifi/conf/stateless-logback.xml' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/stateless-logback.xml') + test -f '/var/lib/nifi/conf/stateless.properties' || (cp '${cfg.package}/share/nifi/conf/stateless.properties' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/stateless.properties') + test -f '/var/lib/nifi/conf/state-management.xml' || (cp '${cfg.package}/share/nifi/conf/state-management.xml' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/state-management.xml') + test -f '/var/lib/nifi/conf/zookeeper.properties' || (cp '${cfg.package}/share/nifi/conf/zookeeper.properties' '/var/lib/nifi/conf/' && chmod 0640 '/var/lib/nifi/conf/zookeeper.properties') + test -d '/var/lib/nifi/docs/html' || (mkdir -p /var/lib/nifi/docs && cp -r '${cfg.package}/share/nifi/docs/html' '/var/lib/nifi/docs/html') + ${lib.optionalString ((cfg.initUser != null) && (cfg.initPasswordFile != null)) '' + awk -F'[<|>]' '/property name="Username"/ {if ($3!="") f=1} END{exit !f}' /var/lib/nifi/conf/login-identity-providers.xml || ${cfg.package}/bin/nifi.sh set-single-user-credentials ${cfg.initUser} $(cat ${cfg.initPasswordFile}) + ''} + ${lib.optionalString (cfg.enableHTTPS == false) '' + sed -i /var/lib/nifi/conf/nifi.properties \ + -e 's|nifi.remote.input.secure=.*|nifi.remote.input.secure=false|g' \ + -e 's|nifi.web.http.host=.*|nifi.web.http.host=${cfg.listenHost}|g' \ + -e 's|nifi.web.http.port=.*|nifi.web.http.port=${(toString cfg.listenPort)}|g' \ + -e 's|nifi.web.https.host=.*|nifi.web.https.host=|g' \ + -e 's|nifi.web.https.port=.*|nifi.web.https.port=|g' \ + -e 's|nifi.security.keystore=.*|nifi.security.keystore=|g' \ + -e 's|nifi.security.keystoreType=.*|nifi.security.keystoreType=|g' \ + -e 's|nifi.security.truststore=.*|nifi.security.truststore=|g' \ + -e 's|nifi.security.truststoreType=.*|nifi.security.truststoreType=|g' \ + -e '/nifi.security.keystorePasswd/s|^|#|' \ + -e '/nifi.security.keyPasswd/s|^|#|' \ + -e '/nifi.security.truststorePasswd/s|^|#|' + ''} + ${lib.optionalString (cfg.enableHTTPS == true) '' + sed -i /var/lib/nifi/conf/nifi.properties \ + -e 's|nifi.remote.input.secure=.*|nifi.remote.input.secure=true|g' \ + -e 's|nifi.web.http.host=.*|nifi.web.http.host=|g' \ + -e 's|nifi.web.http.port=.*|nifi.web.http.port=|g' \ + -e 's|nifi.web.https.host=.*|nifi.web.https.host=${cfg.listenHost}|g' \ + -e 's|nifi.web.https.port=.*|nifi.web.https.port=${(toString cfg.listenPort)}|g' \ + -e 's|nifi.security.keystore=.*|nifi.security.keystore=./conf/keystore.p12|g' \ + -e 's|nifi.security.keystoreType=.*|nifi.security.keystoreType=PKCS12|g' \ + -e 's|nifi.security.truststore=.*|nifi.security.truststore=./conf/truststore.p12|g' \ + -e 's|nifi.security.truststoreType=.*|nifi.security.truststoreType=PKCS12|g' \ + -e '/nifi.security.keystorePasswd/s|^#\+||' \ + -e '/nifi.security.keyPasswd/s|^#\+||' \ + -e '/nifi.security.truststorePasswd/s|^#\+||' + ''} + ${lib.optionalString ((cfg.enableHTTPS == true) && (cfg.proxyHost != null) && (cfg.proxyPort != null)) '' + sed -i /var/lib/nifi/conf/nifi.properties \ + -e 's|nifi.web.proxy.host=.*|nifi.web.proxy.host=${cfg.proxyHost}:${(toString cfg.proxyPort)}|g' + ''} + ${lib.optionalString ((cfg.enableHTTPS == false) || (cfg.proxyHost == null) && (cfg.proxyPort == null)) '' + sed -i /var/lib/nifi/conf/nifi.properties \ + -e 's|nifi.web.proxy.host=.*|nifi.web.proxy.host=|g' + ''} + ${lib.optionalString ((cfg.initJavaHeapSize != null) && (cfg.maxJavaHeapSize != null))'' + sed -i /var/lib/nifi/conf/bootstrap.conf \ + -e 's|java.arg.2=.*|java.arg.2=-Xms${(toString cfg.initJavaHeapSize)}m|g' \ + -e 's|java.arg.3=.*|java.arg.3=-Xmx${(toString cfg.maxJavaHeapSize)}m|g' + ''} + ${lib.optionalString ((cfg.initJavaHeapSize == null) && (cfg.maxJavaHeapSize == null))'' + sed -i /var/lib/nifi/conf/bootstrap.conf \ + -e 's|java.arg.2=.*|java.arg.2=-Xms512m|g' \ + -e 's|java.arg.3=.*|java.arg.3=-Xmx512m|g' + ''} + ''; + ExecStart = "${cfg.package}/bin/nifi.sh start"; + ExecStop = "${cfg.package}/bin/nifi.sh stop"; + # User and group + User = cfg.user; + Group = cfg.group; + # Runtime directory and mode + RuntimeDirectory = "nifi"; + RuntimeDirectoryMode = "0750"; + # State directory and mode + StateDirectory = "nifi"; + StateDirectoryMode = "0750"; + # Logs directory and mode + LogsDirectory = "nifi"; + LogsDirectoryMode = "0750"; + # Proc filesystem + ProcSubset = "pid"; + ProtectProc = "invisible"; + # Access write directories + ReadWritePaths = [ cfg.initPasswordFile ]; + UMask = "0027"; + # Capabilities + CapabilityBoundingSet = ""; + # Security + NoNewPrivileges = true; + # Sandboxing + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateIPC = true; + PrivateUsers = true; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET AF_INET6" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = false; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + PrivateMounts = true; + # System Call Filtering + SystemCallArchitectures = "native"; + SystemCallFilter = [ "~@cpu-emulation @debug @keyring @memlock @mount @obsolete @resources @privileged @setuid" "@chown" ]; + }; + }; + + users.users = lib.mkMerge [ + (lib.mkIf (cfg.user == "nifi") { + nifi = { + group = cfg.group; + isSystemUser = true; + home = cfg.package; + }; + }) + (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package nifiEnv ]) + ]; + + users.groups = lib.optionalAttrs (cfg.group == "nifi") { + nifi = { }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/node-red.nix b/nixpkgs/nixos/modules/services/web-apps/node-red.nix new file mode 100644 index 000000000000..f4d4ad9681a6 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/node-red.nix @@ -0,0 +1,148 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.node-red; + defaultUser = "node-red"; + finalPackage = if cfg.withNpmAndGcc then node-red_withNpmAndGcc else cfg.package; + node-red_withNpmAndGcc = pkgs.runCommand "node-red" { + nativeBuildInputs = [ pkgs.makeWrapper ]; + } + '' + mkdir -p $out/bin + makeWrapper ${pkgs.nodePackages.node-red}/bin/node-red $out/bin/node-red \ + --set PATH '${lib.makeBinPath [ pkgs.nodePackages.npm pkgs.gcc ]}:$PATH' \ + ''; +in +{ + options.services.node-red = { + enable = mkEnableOption (lib.mdDoc "the Node-RED service"); + + package = mkOption { + default = pkgs.nodePackages.node-red; + defaultText = literalExpression "pkgs.nodePackages.node-red"; + type = types.package; + description = lib.mdDoc "Node-RED package to use."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Open ports in the firewall for the server. + ''; + }; + + withNpmAndGcc = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Give Node-RED access to NPM and GCC at runtime, so 'Nodes' can be + downloaded and managed imperatively via the 'Palette Manager'. + ''; + }; + + configFile = mkOption { + type = types.path; + default = "${cfg.package}/lib/node_modules/node-red/settings.js"; + defaultText = literalExpression ''"''${package}/lib/node_modules/node-red/settings.js"''; + description = lib.mdDoc '' + Path to the JavaScript configuration file. + See <https://github.com/node-red/node-red/blob/master/packages/node_modules/node-red/settings.js> + for a configuration example. + ''; + }; + + port = mkOption { + type = types.port; + default = 1880; + description = lib.mdDoc "Listening port."; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = lib.mdDoc '' + User under which Node-RED runs.If left as the default value this user + will automatically be created on system activation, otherwise the + sysadmin is responsible for ensuring the user exists. + ''; + }; + + group = mkOption { + type = types.str; + default = defaultUser; + description = lib.mdDoc '' + Group under which Node-RED runs.If left as the default value this group + will automatically be created on system activation, otherwise the + sysadmin is responsible for ensuring the group exists. + ''; + }; + + userDir = mkOption { + type = types.path; + default = "/var/lib/node-red"; + description = lib.mdDoc '' + The directory to store all user data, such as flow and credential files and all library data. If left + as the default value this directory will automatically be created before the node-red service starts, + otherwise the sysadmin is responsible for ensuring the directory exists with appropriate ownership + and permissions. + ''; + }; + + safe = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Whether to launch Node-RED in --safe mode."; + }; + + define = mkOption { + type = types.attrs; + default = {}; + description = lib.mdDoc "List of settings.js overrides to pass via -D to Node-RED."; + example = literalExpression '' + { + "logging.console.level" = "trace"; + } + ''; + }; + }; + + config = mkIf cfg.enable { + users.users = optionalAttrs (cfg.user == defaultUser) { + ${defaultUser} = { + isSystemUser = true; + group = defaultUser; + }; + }; + + users.groups = optionalAttrs (cfg.group == defaultUser) { + ${defaultUser} = { }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + + systemd.services.node-red = { + description = "Node-RED Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + environment = { + HOME = cfg.userDir; + }; + serviceConfig = mkMerge [ + { + User = cfg.user; + Group = cfg.group; + ExecStart = "${finalPackage}/bin/node-red ${pkgs.lib.optionalString cfg.safe "--safe"} --settings ${cfg.configFile} --port ${toString cfg.port} --userDir ${cfg.userDir} ${concatStringsSep " " (mapAttrsToList (name: value: "-D ${name}=${value}") cfg.define)}"; + PrivateTmp = true; + Restart = "always"; + WorkingDirectory = cfg.userDir; + } + (mkIf (cfg.userDir == "/var/lib/node-red") { StateDirectory = "node-red"; }) + ]; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/onlyoffice.nix b/nixpkgs/nixos/modules/services/web-apps/onlyoffice.nix new file mode 100644 index 000000000000..3494f2fa21f0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/onlyoffice.nix @@ -0,0 +1,296 @@ +{ lib, config, pkgs, ... }: + +with lib; + +let + cfg = config.services.onlyoffice; +in +{ + options.services.onlyoffice = { + enable = mkEnableOption (lib.mdDoc "OnlyOffice DocumentServer"); + + enableExampleServer = mkEnableOption (lib.mdDoc "OnlyOffice example server"); + + hostname = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "FQDN for the onlyoffice instance."; + }; + + jwtSecretFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Path to a file that contains the secret to sign web requests using JSON Web Tokens. + If left at the default value null signing is disabled. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.onlyoffice-documentserver; + defaultText = lib.literalExpression "pkgs.onlyoffice-documentserver"; + description = lib.mdDoc "Which package to use for the OnlyOffice instance."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = lib.mdDoc "Port the OnlyOffice DocumentServer should listens on."; + }; + + examplePort = mkOption { + type = types.port; + default = null; + description = lib.mdDoc "Port the OnlyOffice Example server should listens on."; + }; + + postgresHost = mkOption { + type = types.str; + default = "/run/postgresql"; + description = lib.mdDoc "The Postgresql hostname or socket path OnlyOffice should connect to."; + }; + + postgresName = mkOption { + type = types.str; + default = "onlyoffice"; + description = lib.mdDoc "The name of database OnlyOffice should user."; + }; + + postgresPasswordFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Path to a file that contains the password OnlyOffice should use to connect to Postgresql. + Unused when using socket authentication. + ''; + }; + + postgresUser = mkOption { + type = types.str; + default = "onlyoffice"; + description = lib.mdDoc '' + The username OnlyOffice should use to connect to Postgresql. + Unused when using socket authentication. + ''; + }; + + rabbitmqUrl = mkOption { + type = types.str; + default = "amqp://guest:guest@localhost:5672"; + description = lib.mdDoc "The Rabbitmq in amqp URI style OnlyOffice should connect to."; + }; + }; + + config = lib.mkIf cfg.enable { + services = { + nginx = { + enable = mkDefault true; + # misses text/csv, font/ttf, application/x-font-ttf, application/rtf, application/wasm + recommendedGzipSettings = mkDefault true; + recommendedProxySettings = mkDefault true; + + upstreams = { + # /etc/nginx/includes/http-common.conf + onlyoffice-docservice = { + servers = { "localhost:${toString cfg.port}" = { }; }; + }; + onlyoffice-example = lib.mkIf cfg.enableExampleServer { + servers = { "localhost:${toString cfg.examplePort}" = { }; }; + }; + }; + + virtualHosts.${cfg.hostname} = { + locations = { + # /etc/nginx/includes/ds-docservice.conf + "~ ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(web-apps\/apps\/api\/documents\/api\.js)$".extraConfig = '' + expires -1; + alias ${cfg.package}/var/www/onlyoffice/documentserver/$2; + ''; + "~ ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(web-apps)(\/.*\.json)$".extraConfig = '' + expires 365d; + error_log /dev/null crit; + alias ${cfg.package}/var/www/onlyoffice/documentserver/$2$3; + ''; + "~ ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(sdkjs-plugins)(\/.*\.json)$".extraConfig = '' + expires 365d; + error_log /dev/null crit; + alias ${cfg.package}/var/www/onlyoffice/documentserver/$2$3; + ''; + "~ ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(web-apps|sdkjs|sdkjs-plugins|fonts)(\/.*)$".extraConfig = '' + expires 365d; + alias ${cfg.package}/var/www/onlyoffice/documentserver/$2$3; + ''; + "~* ^(\/cache\/files.*)(\/.*)".extraConfig = '' + alias /var/lib/onlyoffice/documentserver/App_Data$1; + add_header Content-Disposition "attachment; filename*=UTF-8''$arg_filename"; + + set $secret_string verysecretstring; + secure_link $arg_md5,$arg_expires; + secure_link_md5 "$secure_link_expires$uri$secret_string"; + + if ($secure_link = "") { + return 403; + } + + if ($secure_link = "0") { + return 410; + } + ''; + "~* ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(internal)(\/.*)$".extraConfig = '' + allow 127.0.0.1; + deny all; + proxy_pass http://onlyoffice-docservice/$2$3; + ''; + "~* ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(info)(\/.*)$".extraConfig = '' + allow 127.0.0.1; + deny all; + proxy_pass http://onlyoffice-docservice/$2$3; + ''; + "/".extraConfig = '' + proxy_pass http://onlyoffice-docservice; + ''; + "~ ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?(\/doc\/.*)".extraConfig = '' + proxy_pass http://onlyoffice-docservice$2; + proxy_http_version 1.1; + ''; + "/${cfg.package.version}/".extraConfig = '' + proxy_pass http://onlyoffice-docservice/; + ''; + "~ ^(\/[\d]+\.[\d]+\.[\d]+[\.|-][\d]+)?\/(dictionaries)(\/.*)$".extraConfig = '' + expires 365d; + alias ${cfg.package}/var/www/onlyoffice/documentserver/$2$3; + ''; + # /etc/nginx/includes/ds-example.conf + "~ ^(\/welcome\/.*)$".extraConfig = '' + expires 365d; + alias ${cfg.package}/var/www/onlyoffice/documentserver-example$1; + index docker.html; + ''; + "/example/".extraConfig = lib.mkIf cfg.enableExampleServer '' + proxy_pass http://onlyoffice-example/; + proxy_set_header X-Forwarded-Path /example; + ''; + }; + extraConfig = '' + rewrite ^/$ /welcome/ redirect; + rewrite ^\/OfficeWeb(\/apps\/.*)$ /${cfg.package.version}/web-apps$1 redirect; + rewrite ^(\/web-apps\/apps\/(?!api\/).*)$ /${cfg.package.version}$1 redirect; + + # based on https://github.com/ONLYOFFICE/document-server-package/blob/master/common/documentserver/nginx/includes/http-common.conf.m4#L29-L34 + # without variable indirection and correct variable names + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + # required for CSP to take effect + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # required for websocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + ''; + }; + }; + + rabbitmq.enable = lib.mkDefault true; + + postgresql = { + enable = lib.mkDefault true; + ensureDatabases = [ "onlyoffice" ]; + ensureUsers = [{ + name = "onlyoffice"; + ensurePermissions = { "DATABASE \"onlyoffice\"" = "ALL PRIVILEGES"; }; + }]; + }; + }; + + systemd.services = { + onlyoffice-converter = { + description = "onlyoffice converter"; + after = [ "network.target" "onlyoffice-docservice.service" "postgresql.service" ]; + requires = [ "network.target" "onlyoffice-docservice.service" "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${cfg.package.fhs}/bin/onlyoffice-wrapper FileConverter/converter /run/onlyoffice/config"; + Group = "onlyoffice"; + Restart = "always"; + RuntimeDirectory = "onlyoffice"; + StateDirectory = "onlyoffice"; + Type = "simple"; + User = "onlyoffice"; + }; + }; + + onlyoffice-docservice = + let + onlyoffice-prestart = pkgs.writeShellScript "onlyoffice-prestart" '' + PATH=$PATH:${lib.makeBinPath (with pkgs; [ jq moreutils config.services.postgresql.package ])} + umask 077 + mkdir -p /run/onlyoffice/config/ /var/lib/onlyoffice/documentserver/sdkjs/{slide/themes,common}/ /var/lib/onlyoffice/documentserver/{fonts,server/FileConverter/bin}/ + cp -r ${cfg.package}/etc/onlyoffice/documentserver/* /run/onlyoffice/config/ + chmod u+w /run/onlyoffice/config/default.json + + # Allow members of the onlyoffice group to serve files under /var/lib/onlyoffice/documentserver/App_Data + chmod g+x /var/lib/onlyoffice/documentserver + + cp /run/onlyoffice/config/default.json{,.orig} + + # for a mapping of environment variables from the docker container to json options see + # https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/run-document-server.sh + jq ' + .services.CoAuthoring.server.port = ${toString cfg.port} | + .services.CoAuthoring.sql.dbHost = "${cfg.postgresHost}" | + .services.CoAuthoring.sql.dbName = "${cfg.postgresName}" | + ${lib.optionalString (cfg.postgresPasswordFile != null) '' + .services.CoAuthoring.sql.dbPass = "'"$(cat ${cfg.postgresPasswordFile})"'" | + ''} + .services.CoAuthoring.sql.dbUser = "${cfg.postgresUser}" | + ${lib.optionalString (cfg.jwtSecretFile != null) '' + .services.CoAuthoring.token.enable.browser = true | + .services.CoAuthoring.token.enable.request.inbox = true | + .services.CoAuthoring.token.enable.request.outbox = true | + .services.CoAuthoring.secret.inbox.string = "'"$(cat ${cfg.jwtSecretFile})"'" | + .services.CoAuthoring.secret.outbox.string = "'"$(cat ${cfg.jwtSecretFile})"'" | + .services.CoAuthoring.secret.session.string = "'"$(cat ${cfg.jwtSecretFile})"'" | + ''} + .rabbitmq.url = "${cfg.rabbitmqUrl}" + ' /run/onlyoffice/config/default.json | sponge /run/onlyoffice/config/default.json + + if psql -d onlyoffice -c "SELECT 'task_result'::regclass;" >/dev/null; then + psql -f ${cfg.package}/var/www/onlyoffice/documentserver/server/schema/postgresql/removetbl.sql + psql -f ${cfg.package}/var/www/onlyoffice/documentserver/server/schema/postgresql/createdb.sql + else + psql -f ${cfg.package}/var/www/onlyoffice/documentserver/server/schema/postgresql/createdb.sql + fi + ''; + in + { + description = "onlyoffice documentserver"; + after = [ "network.target" "postgresql.service" ]; + requires = [ "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${cfg.package.fhs}/bin/onlyoffice-wrapper DocService/docservice /run/onlyoffice/config"; + ExecStartPre = [ onlyoffice-prestart ]; + Group = "onlyoffice"; + Restart = "always"; + RuntimeDirectory = "onlyoffice"; + StateDirectory = "onlyoffice"; + Type = "simple"; + User = "onlyoffice"; + }; + }; + }; + + users.users = { + onlyoffice = { + description = "OnlyOffice Service"; + group = "onlyoffice"; + isSystemUser = true; + }; + + nginx.extraGroups = [ "onlyoffice" ]; + }; + + users.groups.onlyoffice = { }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/openvscode-server.nix b/nixpkgs/nixos/modules/services/web-apps/openvscode-server.nix new file mode 100644 index 000000000000..d0db614d8d72 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/openvscode-server.nix @@ -0,0 +1,211 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.openvscode-server; + defaultUser = "openvscode-server"; + defaultGroup = defaultUser; +in { + options = { + services.openvscode-server = { + enable = lib.mkEnableOption (lib.mdDoc "openvscode-server"); + + package = lib.mkPackageOptionMD pkgs "openvscode-server" { }; + + extraPackages = lib.mkOption { + default = [ ]; + description = lib.mdDoc '' + Additional packages to add to the openvscode-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 = lib.mdDoc '' + Additional environment variables to pass to openvscode-server. + ''; + default = { }; + example = { PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig"; }; + }; + + extraArguments = lib.mkOption { + default = [ ]; + description = lib.mdDoc '' + Additional arguments to pass to openvscode-server. + ''; + example = lib.literalExpression ''[ "--log=info" ]''; + type = lib.types.listOf lib.types.str; + }; + + host = lib.mkOption { + default = "localhost"; + description = lib.mdDoc '' + The host name or IP address the server should listen to. + ''; + type = lib.types.str; + }; + + port = lib.mkOption { + default = 3000; + description = lib.mdDoc '' + The port the server should listen to. If 0 is passed a random free port is picked. If a range in the format num-num is passed, a free port from the range (end inclusive) is selected. + ''; + type = lib.types.port; + }; + + user = lib.mkOption { + default = defaultUser; + example = "yourUser"; + description = lib.mdDoc '' + The user to run openvscode-server as. + By default, a user named `${defaultUser}` will be created. + ''; + type = lib.types.str; + }; + + group = lib.mkOption { + default = defaultGroup; + example = "yourGroup"; + description = lib.mdDoc '' + The group to run openvscode-server under. + By default, a group named `${defaultGroup}` will be created. + ''; + type = lib.types.str; + }; + + extraGroups = lib.mkOption { + default = [ ]; + description = lib.mdDoc '' + An array of additional groups for the `${defaultUser}` user. + ''; + example = [ "docker" ]; + type = lib.types.listOf lib.types.str; + }; + + withoutConnectionToken = lib.mkOption { + default = false; + description = lib.mdDoc '' + Run without a connection token. Only use this if the connection is secured by other means. + ''; + example = true; + type = lib.types.bool; + }; + + socketPath = lib.mkOption { + default = null; + example = "/run/openvscode/socket"; + description = lib.mdDoc '' + The path to a socket file for the server to listen to. + ''; + type = lib.types.nullOr lib.types.str; + }; + + userDataDir = lib.mkOption { + default = null; + description = lib.mdDoc '' + Specifies the directory that user data is kept in. Can be used to open multiple distinct instances of Code. + ''; + type = lib.types.nullOr lib.types.str; + }; + + serverDataDir = lib.mkOption { + default = null; + description = lib.mdDoc '' + Specifies the directory that server data is kept in. + ''; + type = lib.types.nullOr lib.types.str; + }; + + extensionsDir = lib.mkOption { + default = null; + description = lib.mdDoc '' + Set the root path for extensions. + ''; + type = lib.types.nullOr lib.types.str; + }; + + telemetryLevel = lib.mkOption { + default = "off"; + example = "crash"; + description = lib.mdDoc '' + Sets the initial telemetry level. Valid levels are: 'off', 'crash', 'error' and 'all'. + ''; + type = lib.types.str; + }; + + connectionToken = lib.mkOption { + default = null; + example = "secret-token"; + description = lib.mdDoc '' + A secret that must be included with all requests. + ''; + type = lib.types.nullOr lib.types.str; + }; + + connectionTokenFile = lib.mkOption { + default = null; + description = lib.mdDoc '' + Path to a file that contains the connection token. + ''; + type = lib.types.nullOr lib.types.str; + }; + + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.openvscode-server = { + description = "OpenVSCode server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + path = cfg.extraPackages; + environment = cfg.extraEnvironment; + serviceConfig = { + ExecStart = '' + ${lib.getExe cfg.package} \ + --accept-server-license-terms \ + --host=${cfg.host} \ + --port=${toString cfg.port} \ + '' + lib.optionalString (cfg.telemetryLevel == true) '' + --telemetry-level=${cfg.telemetryLevel} \ + '' + lib.optionalString (cfg.withoutConnectionToken == true) '' + --without-connection-token \ + '' + lib.optionalString (cfg.socketPath != null) '' + --socket-path=${cfg.socketPath} \ + '' + lib.optionalString (cfg.userDataDir != null) '' + --user-data-dir=${cfg.userDataDir} \ + '' + lib.optionalString (cfg.serverDataDir != null) '' + --server-data-dir=${cfg.serverDataDir} \ + '' + lib.optionalString (cfg.extensionsDir != null) '' + --extensions-dir=${cfg.extensionsDir} \ + '' + lib.optionalString (cfg.connectionToken != null) '' + --connection-token=${cfg.connectionToken} \ + '' + lib.optionalString (cfg.connectionTokenFile != null) '' + --connection-token-file=${cfg.connectionTokenFile} \ + '' + 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 = "openvscode-server user"; + inherit (cfg) group; + }) + { + packages = cfg.extraPackages; + inherit (cfg) extraGroups; + } + ]; + + users.groups."${defaultGroup}" = lib.mkIf (cfg.group == defaultGroup) { }; + }; + + meta.maintainers = [ lib.maintainers.drupol ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/openwebrx.nix b/nixpkgs/nixos/modules/services/web-apps/openwebrx.nix new file mode 100644 index 000000000000..72c5d6c7818c --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/openwebrx.nix @@ -0,0 +1,38 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.services.openwebrx; +in +{ + options.services.openwebrx = with lib; { + enable = mkEnableOption (lib.mdDoc "OpenWebRX Web interface for Software-Defined Radios on http://localhost:8073"); + + package = mkOption { + type = types.package; + default = pkgs.openwebrx; + defaultText = literalExpression "pkgs.openwebrx"; + description = lib.mdDoc "OpenWebRX package to use for the service"; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.openwebrx = { + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ + csdr + digiham + codec2 + js8call + m17-cxx-demod + alsaUtils + netcat + ]; + serviceConfig = { + ExecStart = "${cfg.package}/bin/openwebrx"; + Restart = "always"; + DynamicUser = true; + # openwebrx uses /var/lib/openwebrx by default + StateDirectory = [ "openwebrx" ]; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/outline.nix b/nixpkgs/nixos/modules/services/web-apps/outline.nix new file mode 100644 index 000000000000..6f63198a68a5 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/outline.nix @@ -0,0 +1,781 @@ +{ config, lib, pkgs, ...}: + +let + defaultUser = "outline"; + cfg = config.services.outline; +in +{ + # See here for a reference of all the options: + # https://github.com/outline/outline/blob/v0.67.0/.env.sample + # https://github.com/outline/outline/blob/v0.67.0/app.json + # https://github.com/outline/outline/blob/v0.67.0/server/env.ts + # https://github.com/outline/outline/blob/v0.67.0/shared/types.ts + # The order is kept the same here to make updating easier. + options.services.outline = { + enable = lib.mkEnableOption (lib.mdDoc "outline"); + + package = lib.mkOption { + default = pkgs.outline; + defaultText = lib.literalExpression "pkgs.outline"; + type = lib.types.package; + example = lib.literalExpression '' + pkgs.outline.overrideAttrs (super: { + # Ignore the domain part in emails that come from OIDC. This is might + # be helpful if you want multiple users with different email providers + # to still land in the same team. Note that this effectively makes + # Outline a single-team instance. + patchPhase = ${"''"} + sed -i 's/const domain = parts\.length && parts\[1\];/const domain = "example.com";/g' server/routes/auth/providers/oidc.ts + ${"''"}; + }) + ''; + description = lib.mdDoc "Outline package to use."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = defaultUser; + description = lib.mdDoc '' + User under which the service should run. If this is the default value, + the user will be created, with the specified group as the primary + group. + ''; + }; + + group = lib.mkOption { + type = lib.types.str; + default = defaultUser; + description = lib.mdDoc '' + Group under which the service should run. If this is the default value, + the group will be created. + ''; + }; + + sequelizeArguments = lib.mkOption { + type = lib.types.str; + default = ""; + example = "--env=production-ssl-disabled"; + description = lib.mdDoc '' + Optional arguments to pass to `sequelize` calls. + ''; + }; + + # + # Required options + # + + secretKeyFile = lib.mkOption { + type = lib.types.str; + default = "/var/lib/outline/secret_key"; + description = lib.mdDoc '' + File path that contains the application secret key. It must be 32 + bytes long and hex-encoded. If the file does not exist, a new key will + be generated and saved here. + ''; + }; + + utilsSecretFile = lib.mkOption { + type = lib.types.str; + default = "/var/lib/outline/utils_secret"; + description = lib.mdDoc '' + File path that contains the utility secret key. If the file does not + exist, a new key will be generated and saved here. + ''; + }; + + databaseUrl = lib.mkOption { + type = lib.types.str; + default = "local"; + description = lib.mdDoc '' + 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. + ''; + }; + + redisUrl = lib.mkOption { + type = lib.types.str; + default = "local"; + description = lib.mdDoc '' + Connection to a redis server. 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 + `REDIS_URL` entry. Pass the string + `local` to setup a local Redis database. + ''; + }; + + publicUrl = lib.mkOption { + type = lib.types.str; + default = "http://localhost:3000"; + description = lib.mdDoc "The fully qualified, publicly accessible URL"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3000; + description = lib.mdDoc "Listening port."; + }; + + storage = lib.mkOption { + description = lib.mdDoc '' + To support uploading of images for avatars and document attachments an + s3-compatible storage must be provided. AWS S3 is recommended for + redundancy however if you want to keep all file storage local an + alternative such as [minio](https://github.com/minio/minio) + can be used. + + A more detailed guide on setting up S3 is available + [here](https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f). + ''; + example = lib.literalExpression '' + { + accessKey = "..."; + secretKeyFile = "/somewhere"; + uploadBucketUrl = "https://minio.example.com"; + uploadBucketName = "outline"; + region = "us-east-1"; + } + ''; + type = lib.types.submodule { + options = { + accessKey = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "S3 access key."; + }; + secretKeyFile = lib.mkOption { + type = lib.types.path; + description = lib.mdDoc "File path that contains the S3 secret key."; + }; + region = lib.mkOption { + type = lib.types.str; + default = "xx-xxxx-x"; + description = lib.mdDoc "AWS S3 region name."; + }; + uploadBucketUrl = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc '' + URL endpoint of an S3-compatible API where uploads should be + stored. + ''; + }; + uploadBucketName = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Name of the bucket where uploads should be stored."; + }; + uploadMaxSize = lib.mkOption { + type = lib.types.int; + default = 26214400; + description = lib.mdDoc "Maxmium file size for uploads."; + }; + forcePathStyle = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc "Force S3 path style."; + }; + acl = lib.mkOption { + type = lib.types.str; + default = "private"; + description = lib.mdDoc "ACL setting."; + }; + }; + }; + }; + + # + # Authentication + # + + slackAuthentication = lib.mkOption { + description = lib.mdDoc '' + To configure Slack auth, you'll need to create an Application at + https://api.slack.com/apps + + When configuring the Client ID, add a redirect URL under "OAuth & Permissions" + to `https://[publicUrl]/auth/slack.callback`. + ''; + default = null; + type = lib.types.nullOr (lib.types.submodule { + options = { + clientId = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Authentication key."; + }; + secretFile = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "File path containing the authentication secret."; + }; + }; + }); + }; + + googleAuthentication = lib.mkOption { + description = lib.mdDoc '' + To configure Google auth, you'll need to create an OAuth Client ID at + https://console.cloud.google.com/apis/credentials + + When configuring the Client ID, add an Authorized redirect URI to + `https://[publicUrl]/auth/google.callback`. + ''; + default = null; + type = lib.types.nullOr (lib.types.submodule { + options = { + clientId = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Authentication client identifier."; + }; + clientSecretFile = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "File path containing the authentication secret."; + }; + }; + }); + }; + + azureAuthentication = lib.mkOption { + description = lib.mdDoc '' + To configure Microsoft/Azure auth, you'll need to create an OAuth + Client. See + [the guide](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4) + for details on setting up your Azure App. + ''; + default = null; + type = lib.types.nullOr (lib.types.submodule { + options = { + clientId = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Authentication client identifier."; + }; + clientSecretFile = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "File path containing the authentication secret."; + }; + resourceAppId = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Authentication application resource ID."; + }; + }; + }); + }; + + oidcAuthentication = lib.mkOption { + description = lib.mdDoc '' + To configure generic OIDC auth, you'll need some kind of identity + provider. See the documentation for whichever IdP you use to fill out + all the fields. The redirect URL is + `https://[publicUrl]/auth/oidc.callback`. + ''; + default = null; + type = lib.types.nullOr (lib.types.submodule { + options = { + clientId = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Authentication client identifier."; + }; + clientSecretFile = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "File path containing the authentication secret."; + }; + authUrl = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "OIDC authentication URL endpoint."; + }; + tokenUrl = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "OIDC token URL endpoint."; + }; + userinfoUrl = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "OIDC userinfo URL endpoint."; + }; + usernameClaim = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc '' + Specify which claims to derive user information from. Supports any + valid JSON path with the JWT payload + ''; + default = "preferred_username"; + }; + displayName = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Display name for OIDC authentication."; + default = "OpenID"; + }; + scopes = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = lib.mdDoc "OpenID authentication scopes."; + default = [ "openid" "profile" "email" ]; + }; + }; + }); + }; + + # + # Optional configuration + # + + sslKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc '' + File path that contains the Base64-encoded private key for HTTPS + termination. This is only required if you do not use an external reverse + proxy. See + [the documentation](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4). + ''; + }; + sslCertFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc '' + File path that contains the Base64-encoded certificate for HTTPS + termination. This is only required if you do not use an external reverse + proxy. See + [the documentation](https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4). + ''; + }; + + cdnUrl = lib.mkOption { + type = lib.types.str; + default = ""; + description = lib.mdDoc '' + If using a Cloudfront/Cloudflare distribution or similar it can be set + using this option. This will cause paths to JavaScript files, + stylesheets and images to be updated to the hostname defined here. In + your CDN configuration the origin server should be set to public URL. + ''; + }; + + forceHttps = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc '' + Auto-redirect to HTTPS in production. The default is + `true` but you may set this to `false` + if you can be sure that SSL is terminated at an external loadbalancer. + ''; + }; + + enableUpdateCheck = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Have the installation check for updates by sending anonymized statistics + to the maintainers. + ''; + }; + + concurrency = lib.mkOption { + type = lib.types.int; + default = 1; + description = lib.mdDoc '' + How many processes should be spawned. For a rough estimate, divide your + server's available memory by 512. + ''; + }; + + maximumImportSize = lib.mkOption { + type = lib.types.int; + default = 5120000; + description = lib.mdDoc '' + The maximum size of document imports. Overriding this could be required + if you have especially large Word documents with embedded imagery. + ''; + }; + + debugOutput = lib.mkOption { + type = lib.types.nullOr (lib.types.enum [ "http" ]); + default = null; + description = lib.mdDoc "Set this to `http` log HTTP requests."; + }; + + slackIntegration = lib.mkOption { + description = lib.mdDoc '' + For a complete Slack integration with search and posting to channels + this configuration is also needed. See here for details: + https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a + ''; + default = null; + type = lib.types.nullOr (lib.types.submodule { + options = { + verificationTokenFile = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "File path containing the verification token."; + }; + appId = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Application ID."; + }; + messageActions = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc "Whether to enable message actions."; + }; + }; + }); + }; + + googleAnalyticsId = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc '' + Optionally enable Google Analytics to track page views in the knowledge + base. + ''; + }; + + sentryDsn = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc '' + Optionally enable [Sentry](https://sentry.io/) to + track errors and performance. + ''; + }; + + sentryTunnel = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc '' + Optionally add a + [Sentry proxy tunnel](https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option) + for bypassing ad blockers in the UI. + ''; + }; + + logo = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = lib.mdDoc '' + Custom logo displayed on the authentication screen. This will be scaled + to a height of 60px. + ''; + }; + + smtp = lib.mkOption { + description = lib.mdDoc '' + To support sending outgoing transactional emails such as + "document updated" or "you've been invited" you'll need to provide + authentication for an SMTP server. + ''; + default = null; + type = lib.types.nullOr (lib.types.submodule { + options = { + host = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Host name or IP address of the SMTP server."; + }; + port = lib.mkOption { + type = lib.types.port; + description = lib.mdDoc "TCP port of the SMTP server."; + }; + username = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Username to authenticate with."; + }; + passwordFile = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc '' + File path containing the password to authenticate with. + ''; + }; + fromEmail = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Sender email in outgoing mail."; + }; + replyEmail = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Reply address in outgoing mail."; + }; + tlsCiphers = lib.mkOption { + type = lib.types.str; + default = ""; + description = lib.mdDoc "Override SMTP cipher configuration."; + }; + secure = lib.mkOption { + type = lib.types.bool; + default = true; + description = lib.mdDoc "Use a secure SMTP connection."; + }; + }; + }); + }; + + defaultLanguage = lib.mkOption { + type = lib.types.enum [ + "da_DK" + "de_DE" + "en_US" + "es_ES" + "fa_IR" + "fr_FR" + "it_IT" + "ja_JP" + "ko_KR" + "nl_NL" + "pl_PL" + "pt_BR" + "pt_PT" + "ru_RU" + "sv_SE" + "th_TH" + "vi_VN" + "zh_CN" + "zh_TW" + ]; + default = "en_US"; + description = lib.mdDoc '' + The default interface language. See + [translate.getoutline.com](https://translate.getoutline.com/) + for a list of available language codes and their rough percentage + translated. + ''; + }; + + rateLimiter.enable = lib.mkEnableOption (lib.mdDoc "rate limiter for the application web server"); + rateLimiter.requests = lib.mkOption { + type = lib.types.int; + default = 5000; + description = lib.mdDoc "Maximum number of requests in a throttling window."; + }; + rateLimiter.durationWindow = lib.mkOption { + type = lib.types.int; + default = 60; + description = lib.mdDoc "Length of a throttling window."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users = lib.optionalAttrs (cfg.user == defaultUser) { + ${defaultUser} = { + isSystemUser = true; + group = cfg.group; + }; + }; + + users.groups = lib.optionalAttrs (cfg.group == defaultUser) { + ${defaultUser} = { }; + }; + + systemd.tmpfiles.rules = [ + "f ${cfg.secretKeyFile} 0600 ${cfg.user} ${cfg.group} -" + "f ${cfg.utilsSecretFile} 0600 ${cfg.user} ${cfg.group} -" + "f ${cfg.storage.secretKeyFile} 0600 ${cfg.user} ${cfg.group} -" + ]; + + services.postgresql = lib.mkIf (cfg.databaseUrl == "local") { + enable = true; + ensureUsers = [{ + name = "outline"; + ensurePermissions."DATABASE outline" = "ALL PRIVILEGES"; + }]; + ensureDatabases = [ "outline" ]; + }; + + services.redis.servers.outline = lib.mkIf (cfg.redisUrl == "local") { + enable = true; + user = config.services.outline.user; + port = 0; # Disable the TCP listener + }; + + systemd.services.outline = let + localRedisUrl = "redis+unix:///run/redis-outline/redis.sock"; + localPostgresqlUrl = "postgres://localhost/outline?host=/run/postgresql"; + + # Create an outline-sequalize wrapper (a wrapper around the wrapper) that + # has the config file's path baked in. This is necessary because there is + # at least two occurrences of outline calling this from its own code. + sequelize = pkgs.writeShellScriptBin "outline-sequelize" '' + exec ${cfg.package}/bin/outline-sequelize \ + --config $RUNTIME_DIRECTORY/database.json \ + ${cfg.sequelizeArguments} \ + "$@" + ''; + in { + description = "Outline wiki and knowledge base"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ] + ++ lib.optional (cfg.databaseUrl == "local") "postgresql.service" + ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service"; + requires = lib.optional (cfg.databaseUrl == "local") "postgresql.service" + ++ lib.optional (cfg.redisUrl == "local") "redis-outline.service"; + path = [ + pkgs.openssl # Required by the preStart script + sequelize + ]; + + + environment = lib.mkMerge [ + { + NODE_ENV = "production"; + + REDIS_URL = if cfg.redisUrl == "local" then localRedisUrl else cfg.redisUrl; + URL = cfg.publicUrl; + PORT = builtins.toString cfg.port; + + AWS_ACCESS_KEY_ID = cfg.storage.accessKey; + AWS_REGION = cfg.storage.region; + AWS_S3_UPLOAD_BUCKET_URL = cfg.storage.uploadBucketUrl; + AWS_S3_UPLOAD_BUCKET_NAME = cfg.storage.uploadBucketName; + AWS_S3_UPLOAD_MAX_SIZE = builtins.toString cfg.storage.uploadMaxSize; + AWS_S3_FORCE_PATH_STYLE = builtins.toString cfg.storage.forcePathStyle; + AWS_S3_ACL = cfg.storage.acl; + + CDN_URL = cfg.cdnUrl; + FORCE_HTTPS = builtins.toString cfg.forceHttps; + ENABLE_UPDATES = builtins.toString cfg.enableUpdateCheck; + WEB_CONCURRENCY = builtins.toString cfg.concurrency; + MAXIMUM_IMPORT_SIZE = builtins.toString cfg.maximumImportSize; + DEBUG = cfg.debugOutput; + GOOGLE_ANALYTICS_ID = lib.optionalString (cfg.googleAnalyticsId != null) cfg.googleAnalyticsId; + SENTRY_DSN = lib.optionalString (cfg.sentryDsn != null) cfg.sentryDsn; + SENTRY_TUNNEL = lib.optionalString (cfg.sentryTunnel != null) cfg.sentryTunnel; + TEAM_LOGO = lib.optionalString (cfg.logo != null) cfg.logo; + DEFAULT_LANGUAGE = cfg.defaultLanguage; + + RATE_LIMITER_ENABLED = builtins.toString cfg.rateLimiter.enable; + RATE_LIMITER_REQUESTS = builtins.toString cfg.rateLimiter.requests; + RATE_LIMITER_DURATION_WINDOW = builtins.toString cfg.rateLimiter.durationWindow; + } + + (lib.mkIf (cfg.slackAuthentication != null) { + SLACK_CLIENT_ID = cfg.slackAuthentication.clientId; + }) + + (lib.mkIf (cfg.googleAuthentication != null) { + GOOGLE_CLIENT_ID = cfg.googleAuthentication.clientId; + }) + + (lib.mkIf (cfg.azureAuthentication != null) { + AZURE_CLIENT_ID = cfg.azureAuthentication.clientId; + AZURE_RESOURCE_APP_ID = cfg.azureAuthentication.resourceAppId; + }) + + (lib.mkIf (cfg.oidcAuthentication != null) { + OIDC_CLIENT_ID = cfg.oidcAuthentication.clientId; + OIDC_AUTH_URI = cfg.oidcAuthentication.authUrl; + OIDC_TOKEN_URI = cfg.oidcAuthentication.tokenUrl; + OIDC_USERINFO_URI = cfg.oidcAuthentication.userinfoUrl; + OIDC_USERNAME_CLAIM = cfg.oidcAuthentication.usernameClaim; + OIDC_DISPLAY_NAME = cfg.oidcAuthentication.displayName; + OIDC_SCOPES = lib.concatStringsSep " " cfg.oidcAuthentication.scopes; + }) + + (lib.mkIf (cfg.slackIntegration != null) { + SLACK_APP_ID = cfg.slackIntegration.appId; + SLACK_MESSAGE_ACTIONS = builtins.toString cfg.slackIntegration.messageActions; + }) + + (lib.mkIf (cfg.smtp != null) { + SMTP_HOST = cfg.smtp.host; + SMTP_PORT = builtins.toString cfg.smtp.port; + SMTP_USERNAME = cfg.smtp.username; + SMTP_FROM_EMAIL = cfg.smtp.fromEmail; + SMTP_REPLY_EMAIL = cfg.smtp.replyEmail; + SMTP_TLS_CIPHERS = cfg.smtp.tlsCiphers; + SMTP_SECURE = builtins.toString cfg.smtp.secure; + }) + ]; + + preStart = '' + if [ ! -s ${lib.escapeShellArg cfg.secretKeyFile} ]; then + openssl rand -hex 32 > ${lib.escapeShellArg cfg.secretKeyFile} + fi + if [ ! -s ${lib.escapeShellArg cfg.utilsSecretFile} ]; then + openssl rand -hex 32 > ${lib.escapeShellArg cfg.utilsSecretFile} + fi + + # The config file is required for the sequelize CLI. + ${if (cfg.databaseUrl == "local") then '' + cat <<EOF > $RUNTIME_DIRECTORY/database.json + { + "production-ssl-disabled": { + "host": "/run/postgresql", + "username": null, + "password": null, + "dialect": "postgres" + } + } + EOF + '' else '' + cat <<EOF > $RUNTIME_DIRECTORY/database.json + { + "production": { + "use_env_variable": "DATABASE_URL", + "dialect": "postgres", + "dialectOptions": { + "ssl": { + "rejectUnauthorized": false + } + } + }, + "production-ssl-disabled": { + "use_env_variable": "DATABASE_URL", + "dialect": "postgres" + } + } + EOF + ''} + ''; + + script = '' + export SECRET_KEY="$(head -n1 ${lib.escapeShellArg cfg.secretKeyFile})" + export UTILS_SECRET="$(head -n1 ${lib.escapeShellArg cfg.utilsSecretFile})" + export AWS_SECRET_ACCESS_KEY="$(head -n1 ${lib.escapeShellArg cfg.storage.secretKeyFile})" + ${lib.optionalString (cfg.slackAuthentication != null) '' + export SLACK_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.slackAuthentication.secretFile})" + ''} + ${lib.optionalString (cfg.googleAuthentication != null) '' + export GOOGLE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.googleAuthentication.clientSecretFile})" + ''} + ${lib.optionalString (cfg.azureAuthentication != null) '' + export AZURE_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.azureAuthentication.clientSecretFile})" + ''} + ${lib.optionalString (cfg.oidcAuthentication != null) '' + export OIDC_CLIENT_SECRET="$(head -n1 ${lib.escapeShellArg cfg.oidcAuthentication.clientSecretFile})" + ''} + ${lib.optionalString (cfg.sslKeyFile != null) '' + export SSL_KEY="$(head -n1 ${lib.escapeShellArg cfg.sslKeyFile})" + ''} + ${lib.optionalString (cfg.sslCertFile != null) '' + export SSL_CERT="$(head -n1 ${lib.escapeShellArg cfg.sslCertFile})" + ''} + ${lib.optionalString (cfg.slackIntegration != null) '' + export SLACK_VERIFICATION_TOKEN="$(head -n1 ${lib.escapeShellArg cfg.slackIntegration.verificationTokenFile})" + ''} + ${lib.optionalString (cfg.smtp != null) '' + export SMTP_PASSWORD="$(head -n1 ${lib.escapeShellArg cfg.smtp.passwordFile})" + ''} + + ${if (cfg.databaseUrl == "local") then '' + export DATABASE_URL=${lib.escapeShellArg localPostgresqlUrl} + export PGSSLMODE=disable + '' else '' + export DATABASE_URL=${lib.escapeShellArg cfg.databaseUrl} + ''} + + ${cfg.package}/bin/outline-server + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + Restart = "always"; + ProtectSystem = "strict"; + PrivateHome = true; + PrivateTmp = true; + UMask = "0007"; + + StateDirectory = "outline"; + StateDirectoryMode = "0750"; + RuntimeDirectory = "outline"; + RuntimeDirectoryMode = "0750"; + # This working directory is required to find stuff like the set of + # onboarding files: + WorkingDirectory = "${cfg.package}/share/outline"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/peering-manager.nix b/nixpkgs/nixos/modules/services/web-apps/peering-manager.nix new file mode 100644 index 000000000000..666b82621268 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/peering-manager.nix @@ -0,0 +1,265 @@ +{ config, lib, pkgs, buildEnv, ... }: + +with lib; + +let + cfg = config.services.peering-manager; + configFile = pkgs.writeTextFile { + name = "configuration.py"; + text = '' + ALLOWED_HOSTS = ['*'] + DATABASE = { + 'NAME': 'peering-manager', + 'USER': 'peering-manager', + 'HOST': '/run/postgresql', + } + + # Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate + # configuration exists for each. Full connection details are required in both sections, and it is strongly recommended + # to use two separate database IDs. + REDIS = { + 'tasks': { + 'UNIX_SOCKET_PATH': '${config.services.redis.servers.peering-manager.unixSocket}', + 'DATABASE': 0, + }, + 'caching': { + 'UNIX_SOCKET_PATH': '${config.services.redis.servers.peering-manager.unixSocket}', + 'DATABASE': 1, + } + } + + with open("${cfg.secretKeyFile}", "r") as file: + SECRET_KEY = file.readline() + '' + lib.optionalString (cfg.peeringdbApiKeyFile != null) '' + with open("${cfg.peeringdbApiKeyFile}", "r") as file: + PEERINGDB_API_KEY = file.readline() + '' + '' + + ${cfg.extraConfig} + ''; + }; + pkg = (pkgs.peering-manager.overrideAttrs (old: { + postInstall = '' + ln -s ${configFile} $out/opt/peering-manager/peering_manager/configuration.py + '' + optionalString cfg.enableLdap '' + ln -s ${cfg.ldapConfigPath} $out/opt/peering-manager/peering_manager/ldap_config.py + ''; + })).override { + inherit (cfg) plugins; + }; + peeringManagerManageScript = with pkgs; (writeScriptBin "peering-manager-manage" '' + #!${stdenv.shell} + export PYTHONPATH=${pkg.pythonPath} + sudo -u peering-manager ${pkg}/bin/peering-manager "$@" + ''); + +in { + options.services.peering-manager = { + enable = mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Enable Peering Manager. + + This module requires a reverse proxy that serves `/static` separately. + See this [example](https://github.com/peering-manager-community/peering-manager/blob/develop/contrib/nginx.conf/) on how to configure this. + ''; + }; + + listenAddress = mkOption { + type = types.str; + default = "[::1]"; + description = lib.mdDoc '' + Address the server will listen on. + ''; + }; + + port = mkOption { + type = types.port; + default = 8001; + description = lib.mdDoc '' + Port the server will listen on. + ''; + }; + + plugins = mkOption { + type = types.functionTo (types.listOf types.package); + default = _: []; + defaultText = literalExpression '' + python3Packages: with python3Packages; []; + ''; + description = lib.mdDoc '' + List of plugin packages to install. + ''; + }; + + secretKeyFile = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to a file containing the secret key. + ''; + }; + + peeringdbApiKeyFile = mkOption { + type = with types; nullOr path; + default = null; + description = lib.mdDoc '' + Path to a file containing the PeeringDB API key. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Additional lines of configuration appended to the `configuration.py`. + See the [documentation](https://peering-manager.readthedocs.io/en/stable/configuration/optional-settings/) for more possible options. + ''; + }; + + enableLdap = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable LDAP-Authentication for Peering Manager. + + This requires a configuration file being pass through `ldapConfigPath`. + ''; + }; + + ldapConfigPath = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to the Configuration-File for LDAP-Authentication, will be loaded as `ldap_config.py`. + See the [documentation](https://peering-manager.readthedocs.io/en/stable/setup/6-ldap/#configuration) for possible options. + ''; + }; + }; + + config = mkIf cfg.enable { + services.peering-manager.plugins = mkIf cfg.enableLdap (ps: [ ps.django-auth-ldap ]); + + system.build.peeringManagerPkg = pkg; + + services.redis.servers.peering-manager.enable = true; + + services.postgresql = { + enable = true; + ensureDatabases = [ "peering-manager" ]; + ensureUsers = [ + { + name = "peering-manager"; + ensurePermissions = { + "DATABASE \"peering-manager\"" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + environment.systemPackages = [ peeringManagerManageScript ]; + + systemd.targets.peering-manager = { + description = "Target for all Peering Manager services"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "redis-peering-manager.service" ]; + }; + + systemd.services = let + defaultServiceConfig = { + WorkingDirectory = "/var/lib/peering-manager"; + User = "peering-manager"; + Group = "peering-manager"; + StateDirectory = "peering-manager"; + StateDirectoryMode = "0750"; + Restart = "on-failure"; + }; + in { + peering-manager-migration = { + description = "Peering Manager migrations"; + wantedBy = [ "peering-manager.target" ]; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + Type = "oneshot"; + ExecStart = '' + ${pkg}/bin/peering-manager migrate + ''; + }; + }; + + peering-manager = { + description = "Peering Manager WSGI Service"; + wantedBy = [ "peering-manager.target" ]; + after = [ "peering-manager-migration.service" ]; + + preStart = '' + ${pkg}/bin/peering-manager remove_stale_contenttypes --no-input + ''; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${pkg.python.pkgs.gunicorn}/bin/gunicorn peering_manager.wsgi \ + --bind ${cfg.listenAddress}:${toString cfg.port} \ + --pythonpath ${pkg}/opt/peering-manager + ''; + }; + }; + + peering-manager-rq = { + description = "Peering Manager Request Queue Worker"; + wantedBy = [ "peering-manager.target" ]; + after = [ "peering-manager.service" ]; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${pkg}/bin/peering-manager rqworker high default low + ''; + }; + }; + + peering-manager-housekeeping = { + description = "Peering Manager housekeeping job"; + after = [ "peering-manager.service" ]; + + environment = { + PYTHONPATH = pkg.pythonPath; + }; + + serviceConfig = defaultServiceConfig // { + Type = "oneshot"; + ExecStart = '' + ${pkg}/bin/peering-manager housekeeping + ''; + }; + }; + }; + + systemd.timers.peering-manager-housekeeping = { + description = "Run Peering Manager housekeeping job"; + wantedBy = [ "timers.target" ]; + + timerConfig = { + OnCalendar = "daily"; + }; + }; + + users.users.peering-manager = { + home = "/var/lib/peering-manager"; + isSystemUser = true; + group = "peering-manager"; + }; + users.groups.peering-manager = {}; + users.groups."${config.services.redis.servers.peering-manager.user}".members = [ "peering-manager" ]; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/peertube.nix b/nixpkgs/nixos/modules/services/web-apps/peertube.nix new file mode 100644 index 000000000000..4ef2d7dce532 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/peertube.nix @@ -0,0 +1,872 @@ +{ lib, pkgs, config, options, ... }: + +let + cfg = config.services.peertube; + opt = options.services.peertube; + + settingsFormat = pkgs.formats.json {}; + configFile = settingsFormat.generate "production.json" cfg.settings; + + env = { + NODE_CONFIG_DIR = "/var/lib/peertube/config"; + NODE_ENV = "production"; + NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/ca-certificates.crt"; + NPM_CONFIG_CACHE = "/var/cache/peertube/.npm"; + NPM_CONFIG_PREFIX = cfg.package; + HOME = cfg.package; + }; + + systemCallsList = [ "@cpu-emulation" "@debug" "@keyring" "@ipc" "@memlock" "@mount" "@obsolete" "@privileged" "@setuid" ]; + + cfgService = { + # Proc filesystem + ProcSubset = "pid"; + ProtectProc = "invisible"; + # Access write directories + UMask = "0027"; + # Capabilities + CapabilityBoundingSet = ""; + # Security + NoNewPrivileges = true; + # Sandboxing + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + PrivateMounts = true; + # System Call Filtering + SystemCallArchitectures = "native"; + }; + + envFile = pkgs.writeText "peertube.env" (lib.concatMapStrings (s: s + "\n") ( + (lib.concatLists (lib.mapAttrsToList (name: value: + if value != null then [ + "${name}=\"${toString value}\"" + ] else [] + ) env)))); + + peertubeEnv = pkgs.writeShellScriptBin "peertube-env" '' + set -a + source "${envFile}" + eval -- "\$@" + ''; + + peertubeCli = pkgs.writeShellScriptBin "peertube" '' + node ~/dist/server/tools/peertube.js $@ + ''; + + nginxCommonHeaders = lib.optionalString cfg.enableWebHttps '' + add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains'; + '' + lib.optionalString config.services.nginx.virtualHosts.${cfg.localDomain}.http3 '' + add_header Alt-Svc 'h3=":443"; ma=86400'; + '' + '' + add_header Access-Control-Allow-Origin '*'; + add_header Access-Control-Allow-Methods 'GET, OPTIONS'; + add_header Access-Control-Allow-Headers 'Range,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + ''; + +in { + options.services.peertube = { + enable = lib.mkEnableOption (lib.mdDoc "Peertube"); + + user = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = lib.mdDoc "User account under which Peertube runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = lib.mdDoc "Group under which Peertube runs."; + }; + + localDomain = lib.mkOption { + type = lib.types.str; + example = "peertube.example.com"; + description = lib.mdDoc "The domain serving your PeerTube instance."; + }; + + listenHttp = lib.mkOption { + type = lib.types.port; + default = 9000; + description = lib.mdDoc "listen port for HTTP server."; + }; + + listenWeb = lib.mkOption { + type = lib.types.port; + default = 9000; + description = lib.mdDoc "listen port for WEB server."; + }; + + enableWebHttps = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc "Enable or disable HTTPS protocol."; + }; + + dataDirs = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + example = [ "/opt/peertube/storage" "/var/cache/peertube" ]; + description = lib.mdDoc "Allow access to custom data locations."; + }; + + serviceEnvironmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-init-root"; + description = lib.mdDoc '' + Set environment variables for the service. Mainly useful for setting the initial root password. + For example write to file: + PT_INITIAL_ROOT_PASSWORD=changeme + ''; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + example = lib.literalExpression '' + { + listen = { + hostname = "0.0.0.0"; + }; + log = { + level = "debug"; + }; + storage = { + tmp = "/opt/data/peertube/storage/tmp/"; + logs = "/opt/data/peertube/storage/logs/"; + cache = "/opt/data/peertube/storage/cache/"; + }; + } + ''; + description = lib.mdDoc "Configuration for peertube."; + }; + + configureNginx = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc "Configure nginx as a reverse proxy for peertube."; + }; + + secrets = { + secretsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/peertube"; + description = lib.mdDoc '' + Secrets to run PeerTube. + Generate one using `openssl rand -hex 32` + ''; + }; + }; + + database = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc "Configure local PostgreSQL database server for PeerTube."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = if cfg.database.createLocally then "/run/postgresql" else null; + defaultText = lib.literalExpression '' + if config.${opt.database.createLocally} + then "/run/postgresql" + else null + ''; + example = "192.168.15.47"; + description = lib.mdDoc "Database host address or unix socket."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 5432; + description = lib.mdDoc "Database host port."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = lib.mdDoc "Database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-postgresql"; + description = lib.mdDoc "Password for PostgreSQL database."; + }; + }; + + redis = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc "Configure local Redis server for PeerTube."; + }; + + host = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then "127.0.0.1" else null; + defaultText = lib.literalExpression '' + if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket} + then "127.0.0.1" + else null + ''; + description = lib.mdDoc "Redis host."; + }; + + port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = if cfg.redis.createLocally && cfg.redis.enableUnixSocket then null else 31638; + defaultText = lib.literalExpression '' + if config.${opt.redis.createLocally} && config.${opt.redis.enableUnixSocket} + then null + else 6379 + ''; + description = lib.mdDoc "Redis port."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-redis-db"; + description = lib.mdDoc "Password for redis database."; + }; + + enableUnixSocket = lib.mkOption { + type = lib.types.bool; + default = cfg.redis.createLocally; + defaultText = lib.literalExpression "config.${opt.redis.createLocally}"; + description = lib.mdDoc "Use Unix socket."; + }; + }; + + smtp = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc "Configure local Postfix SMTP server for PeerTube."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-smtp"; + description = lib.mdDoc "Password for smtp server."; + }; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.peertube; + defaultText = lib.literalExpression "pkgs.peertube"; + description = lib.mdDoc "Peertube package to use."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { assertion = cfg.serviceEnvironmentFile == null || !lib.hasPrefix builtins.storeDir cfg.serviceEnvironmentFile; + message = '' + <option>services.peertube.serviceEnvironmentFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { assertion = cfg.secrets.secretsFile != null; + message = '' + <option>services.peertube.secrets.secretsFile</option> needs to be set. + ''; + } + { assertion = !(cfg.redis.enableUnixSocket && (cfg.redis.host != null || cfg.redis.port != null)); + message = '' + <option>services.peertube.redis.createLocally</option> and redis network connection (<option>services.peertube.redis.host</option> or <option>services.peertube.redis.port</option>) enabled. Disable either of them. + ''; + } + { assertion = cfg.redis.enableUnixSocket || (cfg.redis.host != null && cfg.redis.port != null); + message = '' + <option>services.peertube.redis.host</option> and <option>services.peertube.redis.port</option> needs to be set if <option>services.peertube.redis.enableUnixSocket</option> is not enabled. + ''; + } + { assertion = cfg.redis.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.redis.passwordFile; + message = '' + <option>services.peertube.redis.passwordFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { assertion = cfg.database.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.database.passwordFile; + message = '' + <option>services.peertube.database.passwordFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { assertion = cfg.smtp.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.smtp.passwordFile; + message = '' + <option>services.peertube.smtp.passwordFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + ]; + + services.peertube.settings = lib.mkMerge [ + { + listen = { + port = cfg.listenHttp; + }; + webserver = { + https = (if cfg.enableWebHttps then true else false); + hostname = "${cfg.localDomain}"; + port = cfg.listenWeb; + }; + database = { + hostname = "${cfg.database.host}"; + port = cfg.database.port; + name = "${cfg.database.name}"; + username = "${cfg.database.user}"; + }; + redis = { + hostname = "${toString cfg.redis.host}"; + port = (if cfg.redis.port == null then "" else cfg.redis.port); + }; + storage = { + tmp = lib.mkDefault "/var/lib/peertube/storage/tmp/"; + bin = lib.mkDefault "/var/lib/peertube/storage/bin/"; + avatars = lib.mkDefault "/var/lib/peertube/storage/avatars/"; + videos = lib.mkDefault "/var/lib/peertube/storage/videos/"; + streaming_playlists = lib.mkDefault "/var/lib/peertube/storage/streaming-playlists/"; + redundancy = lib.mkDefault "/var/lib/peertube/storage/redundancy/"; + logs = lib.mkDefault "/var/lib/peertube/storage/logs/"; + previews = lib.mkDefault "/var/lib/peertube/storage/previews/"; + thumbnails = lib.mkDefault "/var/lib/peertube/storage/thumbnails/"; + torrents = lib.mkDefault "/var/lib/peertube/storage/torrents/"; + captions = lib.mkDefault "/var/lib/peertube/storage/captions/"; + cache = lib.mkDefault "/var/lib/peertube/storage/cache/"; + plugins = lib.mkDefault "/var/lib/peertube/storage/plugins/"; + well_known = lib.mkDefault "/var/lib/peertube/storage/well_known/"; + client_overrides = lib.mkDefault "/var/lib/peertube/storage/client-overrides/"; + }; + import = { + videos = { + http = { + youtube_dl_release = { + python_path = "${pkgs.python3}/bin/python"; + }; + }; + }; + }; + } + (lib.mkIf cfg.redis.enableUnixSocket { redis = { socket = "/run/redis-peertube/redis.sock"; }; }) + ]; + + systemd.tmpfiles.rules = [ + "d '/var/lib/peertube/config' 0700 ${cfg.user} ${cfg.group} - -" + "z '/var/lib/peertube/config' 0700 ${cfg.user} ${cfg.group} - -" + "d '/var/lib/peertube/www' 0750 ${cfg.user} ${cfg.group} - -" + "z '/var/lib/peertube/www' 0750 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.peertube-init-db = lib.mkIf cfg.database.createLocally { + description = "Initialization database for PeerTube daemon"; + after = [ "network.target" "postgresql.service" ]; + requires = [ "postgresql.service" ]; + + script = let + psqlSetupCommands = pkgs.writeText "peertube-init.sql" '' + SELECT 'CREATE USER "${cfg.database.user}"' WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${cfg.database.user}')\gexec + SELECT 'CREATE DATABASE "${cfg.database.name}" OWNER "${cfg.database.user}" TEMPLATE template0 ENCODING UTF8' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${cfg.database.name}')\gexec + \c '${cfg.database.name}' + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE EXTENSION IF NOT EXISTS unaccent; + ''; + in "${config.services.postgresql.package}/bin/psql -f ${psqlSetupCommands}"; + + serviceConfig = { + Type = "oneshot"; + WorkingDirectory = cfg.package; + # User and group + User = "postgres"; + Group = "postgres"; + # Sandboxing + RestrictAddressFamilies = [ "AF_UNIX" ]; + MemoryDenyWriteExecute = true; + # System Call Filtering + SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]); + } // cfgService; + }; + + systemd.services.peertube = { + description = "PeerTube daemon"; + after = [ "network.target" ] + ++ lib.optional cfg.redis.createLocally "redis-peertube.service" + ++ lib.optionals cfg.database.createLocally [ "postgresql.service" "peertube-init-db.service" ]; + requires = lib.optional cfg.redis.createLocally "redis-peertube.service" + ++ lib.optionals cfg.database.createLocally [ "postgresql.service" "peertube-init-db.service" ]; + wantedBy = [ "multi-user.target" ]; + + environment = env; + + path = with pkgs; [ bashInteractive ffmpeg nodejs_18 openssl yarn python3 ]; + + script = '' + #!/bin/sh + umask 077 + cat > /var/lib/peertube/config/local.yaml <<EOF + ${lib.optionalString (cfg.secrets.secretsFile != null) '' + secrets: + peertube: '$(cat ${cfg.secrets.secretsFile})' + ''} + ${lib.optionalString ((!cfg.database.createLocally) && (cfg.database.passwordFile != null)) '' + database: + password: '$(cat ${cfg.database.passwordFile})' + ''} + ${lib.optionalString (cfg.redis.passwordFile != null) '' + redis: + auth: '$(cat ${cfg.redis.passwordFile})' + ''} + ${lib.optionalString (cfg.smtp.passwordFile != null) '' + smtp: + password: '$(cat ${cfg.smtp.passwordFile})' + ''} + EOF + umask 027 + ln -sf ${configFile} /var/lib/peertube/config/production.json + ln -sf ${cfg.package}/config/default.yaml /var/lib/peertube/config/default.yaml + ln -sf ${cfg.package}/client/dist -T /var/lib/peertube/www/client + ln -sf ${cfg.settings.storage.client_overrides} -T /var/lib/peertube/www/client-overrides + npm start + ''; + serviceConfig = { + Type = "simple"; + Restart = "always"; + RestartSec = 20; + TimeoutSec = 60; + WorkingDirectory = cfg.package; + SyslogIdentifier = "peertube"; + # User and group + User = cfg.user; + Group = cfg.group; + # State directory and mode + StateDirectory = "peertube"; + StateDirectoryMode = "0750"; + # Cache directory and mode + CacheDirectory = "peertube"; + CacheDirectoryMode = "0750"; + # Access write directories + ReadWritePaths = cfg.dataDirs; + # Environment + EnvironmentFile = cfg.serviceEnvironmentFile; + # Sandboxing + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ]; + MemoryDenyWriteExecute = false; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "pipe" "pipe2" ]; + } // cfgService; + }; + + services.nginx = lib.mkIf cfg.configureNginx { + enable = true; + virtualHosts."${cfg.localDomain}" = { + root = "/var/lib/peertube/www"; + + # Application + locations."/" = { + tryFiles = "/dev/null @api"; + priority = 1110; + }; + + locations."= /api/v1/videos/upload-resumable" = { + tryFiles = "/dev/null @api"; + priority = 1120; + + extraConfig = '' + client_max_body_size 0; + proxy_request_buffering off; + ''; + }; + + locations."~ ^/api/v1/videos/(upload|([^/]+/studio/edit))$" = { + tryFiles = "/dev/null @api"; + root = cfg.settings.storage.tmp; + priority = 1130; + + extraConfig = '' + client_max_body_size 12G; + add_header X-File-Maximum-Size 8G always; + '' + lib.optionalString cfg.enableWebHttps '' + add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains'; + '' + lib.optionalString config.services.nginx.virtualHosts.${cfg.localDomain}.http3 '' + add_header Alt-Svc 'h3=":443"; ma=86400'; + ''; + }; + + locations."~ ^/api/v1/(videos|video-playlists|video-channels|users/me)" = { + tryFiles = "/dev/null @api"; + priority = 1140; + + extraConfig = '' + client_max_body_size 6M; + add_header X-File-Maximum-Size 4M always; + '' + lib.optionalString cfg.enableWebHttps '' + add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains'; + '' + lib.optionalString config.services.nginx.virtualHosts.${cfg.localDomain}.http3 '' + add_header Alt-Svc 'h3=":443"; ma=86400'; + ''; + }; + + locations."@api" = { + proxyPass = "http://127.0.0.1:${toString cfg.listenHttp}"; + priority = 1150; + + extraConfig = '' + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + proxy_connect_timeout 10m; + + proxy_send_timeout 10m; + proxy_read_timeout 10m; + + client_max_body_size 100k; + send_timeout 10m; + ''; + }; + + # Websocket + locations."/socket.io" = { + tryFiles = "/dev/null @api_websocket"; + priority = 1210; + }; + + locations."/tracker/socket" = { + tryFiles = "/dev/null @api_websocket"; + priority = 1220; + + extraConfig = '' + proxy_read_timeout 15m; + ''; + }; + + locations."~ ^/plugins/[^/]+(/[^/]+)?/ws/" = { + tryFiles = "/dev/null @api_websocket"; + priority = 1230; + }; + + locations."@api_websocket" = { + proxyPass = "http://127.0.0.1:${toString cfg.listenHttp}"; + priority = 1240; + + extraConfig = '' + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + + proxy_http_version 1.1; + ''; + }; + + # Bypass PeerTube for performance reasons. + locations."~ ^/client/(assets/images/(icons/icon-36x36\.png|icons/icon-48x48\.png|icons/icon-72x72\.png|icons/icon-96x96\.png|icons/icon-144x144\.png|icons/icon-192x192\.png|icons/icon-512x512\.png|logo\.svg|favicon\.png|default-playlist\.jpg|default-avatar-account\.png|default-avatar-account-48x48\.png|default-avatar-video-channel\.png|default-avatar-video-channel-48x48\.png))$" = { + tryFiles = "/client-overrides/$1 /client/$1 $1"; + priority = 1310; + }; + + locations."~ ^/client/(.*\.(js|css|png|svg|woff2|otf|ttf|woff|eot))$" = { + alias = "${cfg.package}/client/dist/$1"; + priority = 1320; + extraConfig = '' + add_header Cache-Control 'public, max-age=604800, immutable'; + '' + lib.optionalString cfg.enableWebHttps '' + add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains'; + '' + lib.optionalString config.services.nginx.virtualHosts.${cfg.localDomain}.http3 '' + add_header Alt-Svc 'h3=":443"; ma=86400'; + ''; + }; + + locations."^~ /lazy-static/avatars/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.avatars; + priority = 1330; + extraConfig = '' + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Cache-Control 'no-cache'; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + + ${nginxCommonHeaders} + add_header Cache-Control 'public, max-age=7200'; + + rewrite ^/lazy-static/avatars/(.*)$ /$1 break; + ''; + }; + + locations."^~ /lazy-static/banners/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.avatars; + priority = 1340; + extraConfig = '' + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Cache-Control 'no-cache'; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + + ${nginxCommonHeaders} + add_header Cache-Control 'public, max-age=7200'; + + rewrite ^/lazy-static/banners/(.*)$ /$1 break; + ''; + }; + + locations."^~ /lazy-static/previews/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.previews; + priority = 1350; + extraConfig = '' + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Cache-Control 'no-cache'; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + + ${nginxCommonHeaders} + add_header Cache-Control 'public, max-age=7200'; + + rewrite ^/lazy-static/previews/(.*)$ /$1 break; + ''; + }; + + locations."^~ /static/streaming-playlists/private/" = { + proxyPass = "http://127.0.0.1:${toString cfg.listenHttp}"; + priority = 1410; + extraConfig = '' + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + proxy_limit_rate 5M; + ''; + }; + + locations."^~ /static/webseed/private/" = { + proxyPass = "http://127.0.0.1:${toString cfg.listenHttp}"; + priority = 1420; + extraConfig = '' + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + proxy_limit_rate 5M; + ''; + }; + + locations."^~ /static/thumbnails/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.thumbnails; + priority = 1430; + extraConfig = '' + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Cache-Control 'no-cache'; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + + ${nginxCommonHeaders} + add_header Cache-Control 'public, max-age=7200'; + + rewrite ^/static/thumbnails/(.*)$ /$1 break; + ''; + }; + + locations."^~ /static/redundancy/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.redundancy; + priority = 1440; + extraConfig = '' + set $peertube_limit_rate 800k; + + if ($request_uri ~ -fragmented.mp4$) { + set $peertube_limit_rate 5M; + } + + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + if ($request_method = 'GET') { + ${nginxCommonHeaders} + + access_log off; + } + + aio threads; + sendfile on; + sendfile_max_chunk 1M; + + limit_rate $peertube_limit_rate; + limit_rate_after 5M; + + rewrite ^/static/redundancy/(.*)$ /$1 break; + ''; + }; + + locations."^~ /static/streaming-playlists/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.streaming_playlists; + priority = 1450; + extraConfig = '' + set $peertube_limit_rate 800k; + + if ($request_uri ~ -fragmented.mp4$) { + set $peertube_limit_rate 5M; + } + + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + if ($request_method = 'GET') { + ${nginxCommonHeaders} + + access_log off; + } + + aio threads; + sendfile on; + sendfile_max_chunk 1M; + + limit_rate $peertube_limit_rate; + limit_rate_after 5M; + + rewrite ^/static/streaming-playlists/(.*)$ /$1 break; + ''; + }; + + locations."^~ /static/webseed/" = { + tryFiles = "$uri @api"; + root = cfg.settings.storage.videos; + priority = 1460; + extraConfig = '' + set $peertube_limit_rate 800k; + + if ($request_uri ~ -fragmented.mp4$) { + set $peertube_limit_rate 5M; + } + + if ($request_method = 'OPTIONS') { + ${nginxCommonHeaders} + add_header Access-Control-Max-Age 1728000; + add_header Content-Type 'text/plain charset=UTF-8'; + add_header Content-Length 0; + return 204; + } + if ($request_method = 'GET') { + ${nginxCommonHeaders} + + access_log off; + } + + aio threads; + sendfile on; + sendfile_max_chunk 1M; + + limit_rate $peertube_limit_rate; + limit_rate_after 5M; + + rewrite ^/static/webseed/(.*)$ /$1 break; + ''; + }; + + extraConfig = lib.optionalString cfg.enableWebHttps '' + add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains'; + ''; + }; + }; + + services.postgresql = lib.mkIf cfg.database.createLocally { + enable = true; + }; + + services.redis.servers.peertube = lib.mkMerge [ + (lib.mkIf cfg.redis.createLocally { + enable = true; + }) + (lib.mkIf (cfg.redis.createLocally && !cfg.redis.enableUnixSocket) { + bind = "127.0.0.1"; + port = cfg.redis.port; + }) + (lib.mkIf (cfg.redis.createLocally && cfg.redis.enableUnixSocket) { + unixSocket = "/run/redis-peertube/redis.sock"; + unixSocketPerm = 660; + }) + ]; + + services.postfix = lib.mkIf cfg.smtp.createLocally { + enable = true; + hostname = lib.mkDefault "${cfg.localDomain}"; + }; + + users.users = lib.mkMerge [ + (lib.mkIf (cfg.user == "peertube") { + peertube = { + isSystemUser = true; + group = cfg.group; + home = cfg.package; + }; + }) + (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package peertubeEnv peertubeCli pkgs.ffmpeg pkgs.nodejs_18 pkgs.yarn ]) + (lib.mkIf cfg.redis.enableUnixSocket {${config.services.peertube.user}.extraGroups = [ "redis-peertube" ];}) + ]; + + users.groups = { + ${cfg.group} = { + members = lib.optional cfg.configureNginx config.services.nginx.user; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/pgpkeyserver-lite.nix b/nixpkgs/nixos/modules/services/web-apps/pgpkeyserver-lite.nix new file mode 100644 index 000000000000..dd51bacd75ea --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/pgpkeyserver-lite.nix @@ -0,0 +1,78 @@ +{ config, lib, options, pkgs, ... }: + +with lib; + +let + + cfg = config.services.pgpkeyserver-lite; + sksCfg = config.services.sks; + sksOpt = options.services.sks; + + webPkg = cfg.package; + +in + +{ + + options = { + + services.pgpkeyserver-lite = { + + enable = mkEnableOption (lib.mdDoc "pgpkeyserver-lite on a nginx vHost proxying to a gpg keyserver"); + + package = mkOption { + default = pkgs.pgpkeyserver-lite; + defaultText = literalExpression "pkgs.pgpkeyserver-lite"; + type = types.package; + description = lib.mdDoc '' + Which webgui derivation to use. + ''; + }; + + hostname = mkOption { + type = types.str; + description = lib.mdDoc '' + Which hostname to set the vHost to that is proxying to sks. + ''; + }; + + hkpAddress = mkOption { + default = builtins.head sksCfg.hkpAddress; + defaultText = literalExpression "head config.${sksOpt.hkpAddress}"; + type = types.str; + description = lib.mdDoc '' + Which IP address the sks-keyserver is listening on. + ''; + }; + + hkpPort = mkOption { + default = sksCfg.hkpPort; + defaultText = literalExpression "config.${sksOpt.hkpPort}"; + type = types.int; + description = lib.mdDoc '' + Which port the sks-keyserver is listening on. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + + services.nginx.enable = true; + + services.nginx.virtualHosts = let + hkpPort = builtins.toString cfg.hkpPort; + in { + ${cfg.hostname} = { + root = webPkg; + locations = { + "/pks".extraConfig = '' + proxy_pass http://${cfg.hkpAddress}:${hkpPort}; + proxy_pass_header Server; + add_header Via "1.1 ${cfg.hostname}"; + ''; + }; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/photoprism.nix b/nixpkgs/nixos/modules/services/web-apps/photoprism.nix new file mode 100644 index 000000000000..d5ca6014780a --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/photoprism.nix @@ -0,0 +1,155 @@ +{ config, pkgs, lib, ... }: +let + cfg = config.services.photoprism; + + env = { + PHOTOPRISM_ORIGINALS_PATH = cfg.originalsPath; + PHOTOPRISM_STORAGE_PATH = cfg.storagePath; + PHOTOPRISM_IMPORT_PATH = cfg.importPath; + PHOTOPRISM_HTTP_HOST = cfg.address; + PHOTOPRISM_HTTP_PORT = toString cfg.port; + } // ( + lib.mapAttrs (_: toString) cfg.settings + ); + + manage = + let + setupEnv = lib.concatStringsSep "\n" (lib.mapAttrsToList (name: val: "export ${name}=${lib.escapeShellArg val}") env); + in + pkgs.writeShellScript "manage" '' + ${setupEnv} + exec ${cfg.package}/bin/photoprism "$@" + ''; +in +{ + meta.maintainers = with lib.maintainers; [ stunkymonkey ]; + + options.services.photoprism = { + + enable = lib.mkEnableOption (lib.mdDoc "Photoprism web server"); + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = lib.mdDoc '' + Admin password file. + ''; + }; + + address = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = lib.mdDoc '' + Web interface address. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 2342; + description = lib.mdDoc '' + Web interface port. + ''; + }; + + originalsPath = lib.mkOption { + type = lib.types.path; + default = null; + example = "/data/photos"; + description = lib.mdDoc '' + Storage path of your original media files (photos and videos). + ''; + }; + + importPath = lib.mkOption { + type = lib.types.str; + default = "import"; + description = lib.mdDoc '' + Relative or absolute to the `originalsPath` from where the files should be imported. + ''; + }; + + storagePath = lib.mkOption { + type = lib.types.path; + default = "/var/lib/photoprism"; + description = lib.mdDoc '' + Location for sidecar, cache, and database files. + ''; + }; + + package = lib.mkPackageOptionMD pkgs "photoprism" { }; + + settings = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = lib.mdDoc '' + See [the getting-started guide](https://docs.photoprism.app/getting-started/config-options/) for available options. + ''; + example = { + PHOTOPRISM_DEFAULT_LOCALE = "de"; + PHOTOPRISM_ADMIN_USER = "root"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.photoprism = { + description = "Photoprism server"; + + serviceConfig = { + Restart = "on-failure"; + User = "photoprism"; + Group = "photoprism"; + DynamicUser = true; + StateDirectory = "photoprism"; + WorkingDirectory = "/var/lib/photoprism"; + RuntimeDirectory = "photoprism"; + + LoadCredential = lib.optionalString (cfg.passwordFile != null) + "PHOTOPRISM_ADMIN_PASSWORD:${cfg.passwordFile}"; + + CapabilityBoundingSet = ""; + LockPersonality = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged @setuid @keyring" ]; + UMask = "0066"; + } // lib.optionalAttrs (cfg.port < 1024) { + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + }; + + wantedBy = [ "multi-user.target" ]; + environment = env; + + # reminder: easier password configuration will come in https://github.com/photoprism/photoprism/pull/2302 + preStart = '' + ln -sf ${manage} photoprism-manage + + ${lib.optionalString (cfg.passwordFile != null) '' + export PHOTOPRISM_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/PHOTOPRISM_ADMIN_PASSWORD") + ''} + exec ${cfg.package}/bin/photoprism migrations run -f + ''; + + script = '' + ${lib.optionalString (cfg.passwordFile != null) '' + export PHOTOPRISM_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/PHOTOPRISM_ADMIN_PASSWORD") + ''} + exec ${cfg.package}/bin/photoprism start + ''; + }; + }; +} + diff --git a/nixpkgs/nixos/modules/services/web-apps/phylactery.nix b/nixpkgs/nixos/modules/services/web-apps/phylactery.nix new file mode 100644 index 000000000000..4801bd203b48 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/phylactery.nix @@ -0,0 +1,51 @@ +{ config, lib, pkgs, ... }: + +with lib; +let cfg = config.services.phylactery; +in { + options.services.phylactery = { + enable = mkEnableOption (lib.mdDoc "Whether to enable Phylactery server"); + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Listen host for Phylactery"; + }; + + port = mkOption { + type = types.port; + description = lib.mdDoc "Listen port for Phylactery"; + }; + + library = mkOption { + type = types.path; + description = lib.mdDoc "Path to CBZ library"; + }; + + package = mkOption { + type = types.package; + default = pkgs.phylactery; + defaultText = literalExpression "pkgs.phylactery"; + description = lib.mdDoc "The Phylactery package to use"; + }; + }; + + config = mkIf cfg.enable { + systemd.services.phylactery = { + environment = { + PHYLACTERY_ADDRESS = "${cfg.host}:${toString cfg.port}"; + PHYLACTERY_LIBRARY = "${cfg.library}"; + }; + + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + ConditionPathExists = cfg.library; + DynamicUser = true; + ExecStart = "${cfg.package}/bin/phylactery"; + }; + }; + }; + + meta.maintainers = with maintainers; [ McSinyx ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/pict-rs.md b/nixpkgs/nixos/modules/services/web-apps/pict-rs.md new file mode 100644 index 000000000000..2fa6bb3aebce --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/pict-rs.md @@ -0,0 +1,89 @@ +# Pict-rs {#module-services-pict-rs} + +pict-rs is a a simple image hosting service. + +## Quickstart {#module-services-pict-rs-quickstart} + +the minimum to start pict-rs is + +```nix +services.pict-rs.enable = true; +``` + +this will start the http server on port 8080 by default. + +## Usage {#module-services-pict-rs-usage} + +pict-rs offers the following endpoints: + +- `POST /image` for uploading an image. Uploaded content must be valid multipart/form-data with an + image array located within the `images[]` key + + This endpoint returns the following JSON structure on success with a 201 Created status + ```json + { + "files": [ + { + "delete_token": "JFvFhqJA98", + "file": "lkWZDRvugm.jpg" + }, + { + "delete_token": "kAYy9nk2WK", + "file": "8qFS0QooAn.jpg" + }, + { + "delete_token": "OxRpM3sf0Y", + "file": "1hJaYfGE01.jpg" + } + ], + "msg": "ok" + } + ``` +- `GET /image/download?url=...` Download an image from a remote server, returning the same JSON + payload as the `POST` endpoint +- `GET /image/original/{file}` for getting a full-resolution image. `file` here is the `file` key from the + `/image` endpoint's JSON +- `GET /image/details/original/{file}` for getting the details of a full-resolution image. + The returned JSON is structured like so: + ```json + { + "width": 800, + "height": 537, + "content_type": "image/webp", + "created_at": [ + 2020, + 345, + 67376, + 394363487 + ] + } + ``` +- `GET /image/process.{ext}?src={file}&...` get a file with transformations applied. + existing transformations include + - `identity=true`: apply no changes + - `blur={float}`: apply a gaussian blur to the file + - `thumbnail={int}`: produce a thumbnail of the image fitting inside an `{int}` by `{int}` + square using raw pixel sampling + - `resize={int}`: produce a thumbnail of the image fitting inside an `{int}` by `{int}` square + using a Lanczos2 filter. This is slower than sampling but looks a bit better in some cases + - `crop={int-w}x{int-h}`: produce a cropped version of the image with an `{int-w}` by `{int-h}` + aspect ratio. The resulting crop will be centered on the image. Either the width or height + of the image will remain full-size, depending on the image's aspect ratio and the requested + aspect ratio. For example, a 1600x900 image cropped with a 1x1 aspect ratio will become 900x900. A + 1600x1100 image cropped with a 16x9 aspect ratio will become 1600x900. + + Supported `ext` file extensions include `png`, `jpg`, and `webp` + + An example of usage could be + ``` + GET /image/process.jpg?src=asdf.png&thumbnail=256&blur=3.0 + ``` + which would create a 256x256px JPEG thumbnail and blur it +- `GET /image/details/process.{ext}?src={file}&...` for getting the details of a processed image. + The returned JSON is the same format as listed for the full-resolution details endpoint. +- `DELETE /image/delete/{delete_token}/{file}` or `GET /image/delete/{delete_token}/{file}` to + delete a file, where `delete_token` and `file` are from the `/image` endpoint's JSON + +## Missing {#module-services-pict-rs-missing} + +- Configuring the secure-api-key is not included yet. The envisioned basic use case is consumption on localhost by other services without exposing the service to the internet. diff --git a/nixpkgs/nixos/modules/services/web-apps/pict-rs.nix b/nixpkgs/nixos/modules/services/web-apps/pict-rs.nix new file mode 100644 index 000000000000..3270715a051b --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/pict-rs.nix @@ -0,0 +1,48 @@ +{ lib, pkgs, config, ... }: +with lib; +let + cfg = config.services.pict-rs; +in +{ + meta.maintainers = with maintainers; [ happysalada ]; + meta.doc = ./pict-rs.md; + + options.services.pict-rs = { + enable = mkEnableOption (lib.mdDoc "pict-rs server"); + dataDir = mkOption { + type = types.path; + default = "/var/lib/pict-rs"; + description = lib.mdDoc '' + The directory where to store the uploaded images. + ''; + }; + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc '' + The IPv4 address to deploy the service to. + ''; + }; + port = mkOption { + type = types.port; + default = 8080; + description = lib.mdDoc '' + The port which to bind the service to. + ''; + }; + }; + config = lib.mkIf cfg.enable { + systemd.services.pict-rs = { + environment = { + PICTRS__PATH = cfg.dataDir; + PICTRS__ADDR = "${cfg.address}:${toString cfg.port}"; + }; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + DynamicUser = true; + StateDirectory = "pict-rs"; + ExecStart = "${pkgs.pict-rs}/bin/pict-rs"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/pixelfed.nix b/nixpkgs/nixos/modules/services/web-apps/pixelfed.nix new file mode 100644 index 000000000000..430a368650ec --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/pixelfed.nix @@ -0,0 +1,478 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.pixelfed; + user = cfg.user; + group = cfg.group; + pixelfed = cfg.package.override { inherit (cfg) dataDir runtimeDir; }; + # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L185-L190 + extraPrograms = with pkgs; [ jpegoptim optipng pngquant gifsicle ffmpeg ]; + # Ensure PHP extensions: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L135-L147 + phpPackage = cfg.phpPackage.buildEnv { + extensions = { enabled, all }: + enabled + ++ (with all; [ bcmath ctype curl mbstring gd intl zip redis imagick ]); + }; + configFile = + pkgs.writeText "pixelfed-env" (lib.generators.toKeyValue { } cfg.settings); + # Management script + pixelfed-manage = pkgs.writeShellScriptBin "pixelfed-manage" '' + cd ${pixelfed} + sudo=exec + if [[ "$USER" != ${user} ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${user}' + fi + $sudo ${cfg.phpPackage}/bin/php artisan "$@" + ''; + dbSocket = { + "pgsql" = "/run/postgresql"; + "mysql" = "/run/mysqld/mysqld.sock"; + }.${cfg.database.type}; + dbService = { + "pgsql" = "postgresql.service"; + "mysql" = "mysql.service"; + }.${cfg.database.type}; + redisService = "redis-pixelfed.service"; +in { + options.services = { + pixelfed = { + enable = mkEnableOption (lib.mdDoc "a Pixelfed instance"); + package = mkPackageOptionMD pkgs "pixelfed" { }; + phpPackage = mkPackageOptionMD pkgs "php81" { }; + + user = mkOption { + type = types.str; + default = "pixelfed"; + description = lib.mdDoc '' + User account under which pixelfed 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 pixelfed application starts. + ::: + ''; + }; + + group = mkOption { + type = types.str; + default = "pixelfed"; + description = lib.mdDoc '' + Group account under which pixelfed 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 pixelfed application starts. + ::: + ''; + }; + + domain = mkOption { + type = types.str; + description = lib.mdDoc '' + FQDN for the Pixelfed instance. + ''; + }; + + secretFile = mkOption { + type = types.path; + description = lib.mdDoc '' + A secret file to be sourced for the .env settings. + Place `APP_KEY` and other settings that should not end up in the Nix store here. + ''; + }; + + settings = mkOption { + type = with types; (attrsOf (oneOf [ bool int str ])); + description = lib.mdDoc '' + .env settings for Pixelfed. + Secrets should use `secretFile` option instead. + ''; + }; + + nginx = mkOption { + type = types.nullOr (types.submodule + (import ../web-servers/nginx/vhost-options.nix { + inherit config lib; + })); + default = null; + example = lib.literalExpression '' + { + serverAliases = [ + "pics.''${config.networking.domain}" + ]; + enableACME = true; + forceHttps = true; + } + ''; + description = lib.mdDoc '' + 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}`, + If this is set to null (the default), no nginx virtualHost will be configured. + ''; + }; + + redis.createLocally = mkEnableOption + (lib.mdDoc "a local Redis database using UNIX socket authentication") + // { + default = true; + }; + + database = { + createLocally = mkEnableOption + (lib.mdDoc "a local database using UNIX socket authentication") // { + default = true; + }; + automaticMigrations = mkEnableOption + (lib.mdDoc "automatic migrations for database schema and data") // { + default = true; + }; + + type = mkOption { + type = types.enum [ "mysql" "pgsql" ]; + example = "pgsql"; + default = "mysql"; + description = lib.mdDoc '' + Database engine to use. + Note that PGSQL is not well supported: https://github.com/pixelfed/pixelfed/issues/2727 + ''; + }; + + name = mkOption { + type = types.str; + default = "pixelfed"; + description = lib.mdDoc "Database name."; + }; + }; + + maxUploadSize = mkOption { + type = types.str; + default = "8M"; + description = lib.mdDoc '' + Max upload size with units. + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ int str bool ]); + default = { }; + + description = lib.mdDoc '' + Options for Pixelfed's PHP-FPM pool. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/pixelfed"; + description = lib.mdDoc '' + State directory of the `pixelfed` user which holds + the application's state and data. + ''; + }; + + runtimeDir = mkOption { + type = types.str; + default = "/run/pixelfed"; + description = lib.mdDoc '' + Ruutime directory of the `pixelfed` user which holds + the application's caches and temporary files. + ''; + }; + + schedulerInterval = mkOption { + type = types.str; + default = "1d"; + description = lib.mdDoc "How often the Pixelfed cron task should run"; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.pixelfed = mkIf (cfg.user == "pixelfed") { + isSystemUser = true; + group = cfg.group; + extraGroups = lib.optional cfg.redis.createLocally "redis-pixelfed"; + }; + users.groups.pixelfed = mkIf (cfg.group == "pixelfed") { }; + + services.redis.servers.pixelfed.enable = lib.mkIf cfg.redis.createLocally true; + services.pixelfed.settings = mkMerge [ + ({ + APP_ENV = mkDefault "production"; + APP_DEBUG = mkDefault false; + # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L312-L316 + APP_URL = mkDefault "https://${cfg.domain}"; + ADMIN_DOMAIN = mkDefault cfg.domain; + APP_DOMAIN = mkDefault cfg.domain; + SESSION_DOMAIN = mkDefault cfg.domain; + SESSION_SECURE_COOKIE = mkDefault true; + OPEN_REGISTRATION = mkDefault false; + # ActivityPub: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L360-L364 + ACTIVITY_PUB = mkDefault true; + AP_REMOTE_FOLLOW = mkDefault true; + AP_INBOX = mkDefault true; + AP_OUTBOX = mkDefault true; + AP_SHAREDINBOX = mkDefault true; + # Image optimization: https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L367-L404 + PF_OPTIMIZE_IMAGES = mkDefault true; + IMAGE_DRIVER = mkDefault "imagick"; + # Mobile APIs + OAUTH_ENABLED = mkDefault true; + # https://github.com/pixelfed/pixelfed/blob/dev/app/Console/Commands/Installer.php#L351 + EXP_EMC = mkDefault true; + # Defer to systemd + LOG_CHANNEL = mkDefault "stderr"; + # TODO: find out the correct syntax? + # TRUST_PROXIES = mkDefault "127.0.0.1/8, ::1/128"; + }) + (mkIf (cfg.redis.createLocally) { + BROADCAST_DRIVER = mkDefault "redis"; + CACHE_DRIVER = mkDefault "redis"; + QUEUE_DRIVER = mkDefault "redis"; + SESSION_DRIVER = mkDefault "redis"; + WEBSOCKET_REPLICATION_MODE = mkDefault "redis"; + # Support phpredis and predis configuration-style. + REDIS_SCHEME = "unix"; + REDIS_HOST = config.services.redis.servers.pixelfed.unixSocket; + REDIS_PATH = config.services.redis.servers.pixelfed.unixSocket; + }) + (mkIf (cfg.database.createLocally) { + DB_CONNECTION = cfg.database.type; + DB_SOCKET = dbSocket; + DB_DATABASE = cfg.database.name; + DB_USERNAME = user; + # No TCP/IP connection. + DB_PORT = 0; + }) + ]; + + environment.systemPackages = [ pixelfed-manage ]; + + services.mysql = + mkIf (cfg.database.createLocally && cfg.database.type == "mysql") { + enable = mkDefault true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + }]; + }; + + services.postgresql = + mkIf (cfg.database.createLocally && cfg.database.type == "pgsql") { + enable = mkDefault true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = user; + ensurePermissions = { }; + }]; + }; + + # Make each individual option overridable with lib.mkDefault. + services.pixelfed.poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) { + "pm" = "dynamic"; + "php_admin_value[error_log]" = "stderr"; + "php_admin_flag[log_errors]" = true; + "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"; + }; + + services.phpfpm.pools.pixelfed = { + inherit user group; + inherit phpPackage; + + phpOptions = '' + post_max_size = ${toString cfg.maxUploadSize} + upload_max_filesize = ${toString cfg.maxUploadSize} + max_execution_time = 600; + ''; + + settings = { + "listen.owner" = user; + "listen.group" = group; + "listen.mode" = "0660"; + "catch_workers_output" = "yes"; + } // cfg.poolConfig; + }; + + systemd.services.phpfpm-pixelfed.after = [ "pixelfed-data-setup.service" ]; + systemd.services.phpfpm-pixelfed.requires = + [ "pixelfed-horizon.service" "pixelfed-data-setup.service" ] + ++ lib.optional cfg.database.createLocally dbService + ++ lib.optional cfg.redis.createLocally redisService; + # Ensure image optimizations programs are available. + systemd.services.phpfpm-pixelfed.path = extraPrograms; + + systemd.services.pixelfed-horizon = { + description = "Pixelfed task queueing via Laravel Horizon framework"; + after = [ "network.target" "pixelfed-data-setup.service" ]; + requires = [ "pixelfed-data-setup.service" ] + ++ (lib.optional cfg.database.createLocally dbService) + ++ (lib.optional cfg.redis.createLocally redisService); + wantedBy = [ "multi-user.target" ]; + # Ensure image optimizations programs are available. + path = extraPrograms; + + serviceConfig = { + Type = "simple"; + ExecStart = "${pixelfed-manage}/bin/pixelfed-manage horizon"; + StateDirectory = + lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed"; + User = user; + Group = group; + Restart = "on-failure"; + }; + }; + + systemd.timers.pixelfed-cron = { + description = "Pixelfed periodic tasks timer"; + after = [ "pixelfed-data-setup.service" ]; + requires = [ "phpfpm-pixelfed.service" ]; + wantedBy = [ "timers.target" ]; + + timerConfig = { + OnBootSec = cfg.schedulerInterval; + OnUnitActiveSec = cfg.schedulerInterval; + }; + }; + + systemd.services.pixelfed-cron = { + description = "Pixelfed periodic tasks"; + # Ensure image optimizations programs are available. + path = extraPrograms; + + serviceConfig = { + ExecStart = "${pixelfed-manage}/bin/pixelfed-manage schedule:run"; + User = user; + Group = group; + StateDirectory = cfg.dataDir; + }; + }; + + systemd.services.pixelfed-data-setup = { + description = + "Pixelfed setup: migrations, environment file update, cache reload, data changes"; + wantedBy = [ "multi-user.target" ]; + after = lib.optional cfg.database.createLocally dbService; + requires = lib.optional cfg.database.createLocally dbService; + path = with pkgs; [ bash pixelfed-manage rsync ] ++ extraPrograms; + + serviceConfig = { + Type = "oneshot"; + User = user; + Group = group; + StateDirectory = + lib.mkIf (cfg.dataDir == "/var/lib/pixelfed") "pixelfed"; + LoadCredential = "env-secrets:${cfg.secretFile}"; + UMask = "077"; + }; + + script = '' + # Concatenate non-secret .env and secret .env + rm -f ${cfg.dataDir}/.env + cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env + echo -e '\n' >> ${cfg.dataDir}/.env + cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env + + # Link the static storage (package provided) to the runtime storage + # Necessary for cities.json and static images. + mkdir -p ${cfg.dataDir}/storage + rsync -av --no-perms ${pixelfed}/storage-static/ ${cfg.dataDir}/storage + chmod -R +w ${cfg.dataDir}/storage + + # Link the app.php in the runtime folder. + # We cannot link the cache folder only because bootstrap folder needs to be writeable. + ln -sf ${pixelfed}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php + + # https://laravel.com/docs/10.x/filesystem#the-public-disk + # Creating the public/storage → storage/app/public link + # is unnecessary as it's part of the installPhase of pixelfed. + + # Install Horizon + # FIXME: require write access to public/ — should be done as part of install — pixelfed-manage horizon:publish + + # Before running any PHP program, cleanup the bootstrap. + # It's necessary if you upgrade the application otherwise you might + # try to import non-existent modules. + rm -rf ${cfg.runtimeDir}/bootstrap/* + + # Perform the first migration. + [[ ! -f ${cfg.dataDir}/.initial-migration ]] && pixelfed-manage migrate --force && touch ${cfg.dataDir}/.initial-migration + + ${lib.optionalString cfg.database.automaticMigrations '' + # Force migrate the database. + pixelfed-manage migrate --force + ''} + + # Import location data + pixelfed-manage import:cities + + ${lib.optionalString cfg.settings.ACTIVITY_PUB '' + # ActivityPub federation bookkeeping + [[ ! -f ${cfg.dataDir}/.instance-actor-created ]] && pixelfed-manage instance:actor && touch ${cfg.dataDir}/.instance-actor-created + ''} + + ${lib.optionalString cfg.settings.OAUTH_ENABLED '' + # Generate Passport encryption keys + [[ ! -f ${cfg.dataDir}/.passport-keys-generated ]] && pixelfed-manage passport:keys && touch ${cfg.dataDir}/.passport-keys-generated + ''} + + pixelfed-manage route:cache + pixelfed-manage view:cache + pixelfed-manage config:cache + ''; + }; + + systemd.tmpfiles.rules = [ + # Cache must live across multiple systemd units runtimes. + "d ${cfg.runtimeDir}/ 0700 ${user} ${group} - -" + "d ${cfg.runtimeDir}/cache 0700 ${user} ${group} - -" + ]; + + # Enable NGINX to access our phpfpm-socket. + users.users."${config.services.nginx.group}".extraGroups = [ cfg.group ]; + services.nginx = mkIf (cfg.nginx != null) { + enable = true; + virtualHosts."${cfg.domain}" = mkMerge [ + cfg.nginx + { + root = lib.mkForce "${pixelfed}/public/"; + locations."/".tryFiles = "$uri $uri/ /index.php?$query_string"; + locations."/favicon.ico".extraConfig = '' + access_log off; log_not_found off; + ''; + locations."/robots.txt".extraConfig = '' + access_log off; log_not_found off; + ''; + locations."~ \\.php$".extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools.pixelfed.socket}; + fastcgi_index index.php; + ''; + locations."~ /\\.(?!well-known).*".extraConfig = '' + deny all; + ''; + extraConfig = '' + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Content-Type-Options "nosniff"; + index index.html index.htm index.php; + error_page 404 /index.php; + client_max_body_size ${toString cfg.maxUploadSize}; + ''; + } + ]; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/plantuml-server.nix b/nixpkgs/nixos/modules/services/web-apps/plantuml-server.nix new file mode 100644 index 000000000000..5ebee48c3e0b --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/plantuml-server.nix @@ -0,0 +1,140 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.plantuml-server; + +in + +{ + options = { + services.plantuml-server = { + enable = mkEnableOption (lib.mdDoc "PlantUML server"); + + package = mkOption { + type = types.package; + default = pkgs.plantuml-server; + defaultText = literalExpression "pkgs.plantuml-server"; + description = lib.mdDoc "PlantUML server package to use"; + }; + + packages = { + jdk = mkOption { + type = types.package; + default = pkgs.jdk; + defaultText = literalExpression "pkgs.jdk"; + description = lib.mdDoc "JDK package to use for the server"; + }; + jetty = mkOption { + type = types.package; + default = pkgs.jetty; + defaultText = literalExpression "pkgs.jetty"; + description = lib.mdDoc "Jetty package to use for the server"; + }; + }; + + user = mkOption { + type = types.str; + default = "plantuml"; + description = lib.mdDoc "User which runs PlantUML server."; + }; + + group = mkOption { + type = types.str; + default = "plantuml"; + description = lib.mdDoc "Group which runs PlantUML server."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/plantuml"; + description = lib.mdDoc "Home directory of the PlantUML server instance."; + }; + + listenHost = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc "Host to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8080; + description = lib.mdDoc "Port to listen on."; + }; + + plantumlLimitSize = mkOption { + type = types.int; + default = 4096; + description = lib.mdDoc "Limits image width and height."; + }; + + graphvizPackage = mkOption { + type = types.package; + default = pkgs.graphviz; + defaultText = literalExpression "pkgs.graphviz"; + description = lib.mdDoc "Package containing the dot executable."; + }; + + plantumlStats = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Set it to on to enable statistics report (https://plantuml.com/statistics-report)."; + }; + + httpAuthorization = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc "When calling the proxy endpoint, the value of HTTP_AUTHORIZATION will be used to set the HTTP Authorization header."; + }; + + allowPlantumlInclude = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Enables !include processing which can read files from the server into diagrams. Files are read relative to the current working directory."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.home; + createHome = true; + }; + + users.groups.${cfg.group} = {}; + + systemd.services.plantuml-server = { + description = "PlantUML server"; + wantedBy = [ "multi-user.target" ]; + path = [ cfg.home ]; + environment = { + PLANTUML_LIMIT_SIZE = builtins.toString cfg.plantumlLimitSize; + GRAPHVIZ_DOT = "${cfg.graphvizPackage}/bin/dot"; + PLANTUML_STATS = if cfg.plantumlStats then "on" else "off"; + HTTP_AUTHORIZATION = cfg.httpAuthorization; + ALLOW_PLANTUML_INCLUDE = if cfg.allowPlantumlInclude then "true" else "false"; + }; + script = '' + ${cfg.packages.jdk}/bin/java \ + -jar ${cfg.packages.jetty}/start.jar \ + --module=deploy,http,jsp \ + jetty.home=${cfg.packages.jetty} \ + jetty.base=${cfg.package} \ + jetty.http.host=${cfg.listenHost} \ + jetty.http.port=${builtins.toString cfg.listenPort} + ''; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ truh ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/plausible.md b/nixpkgs/nixos/modules/services/web-apps/plausible.md new file mode 100644 index 000000000000..1328ce69441a --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/plausible.md @@ -0,0 +1,35 @@ +# Plausible {#module-services-plausible} + +[Plausible](https://plausible.io/) is a privacy-friendly alternative to +Google analytics. + +## Basic Usage {#module-services-plausible-basic-usage} + +At first, a secret key is needed to be generated. This can be done with e.g. +```ShellSession +$ openssl rand -base64 64 +``` + +After that, `plausible` can be deployed like this: +``` +{ + services.plausible = { + enable = true; + adminUser = { + # activate is used to skip the email verification of the admin-user that's + # automatically created by plausible. This is only supported if + # postgresql is configured by the module. This is done by default, but + # can be turned off with services.plausible.database.postgres.setup. + activate = true; + email = "admin@localhost"; + passwordFile = "/run/secrets/plausible-admin-pwd"; + }; + server = { + baseUrl = "http://analytics.example.org"; + # secretKeybaseFile is a path to the file which contains the secret generated + # with openssl as described above. + secretKeybaseFile = "/run/secrets/plausible-secret-key-base"; + }; + }; +} +``` diff --git a/nixpkgs/nixos/modules/services/web-apps/plausible.nix b/nixpkgs/nixos/modules/services/web-apps/plausible.nix new file mode 100644 index 000000000000..893dfa10acbc --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/plausible.nix @@ -0,0 +1,298 @@ +{ lib, pkgs, config, ... }: + +with lib; + +let + cfg = config.services.plausible; + +in { + options.services.plausible = { + enable = mkEnableOption (lib.mdDoc "plausible"); + + package = mkPackageOptionMD pkgs "plausible" { }; + + releaseCookiePath = mkOption { + type = with types; either str path; + description = lib.mdDoc '' + The path to the file with release cookie. (used for remote connection to the running node). + ''; + }; + + adminUser = { + name = mkOption { + default = "admin"; + type = types.str; + description = lib.mdDoc '' + Name of the admin user that plausible will created on initial startup. + ''; + }; + + email = mkOption { + type = types.str; + example = "admin@localhost"; + description = lib.mdDoc '' + Email-address of the admin-user. + ''; + }; + + passwordFile = mkOption { + type = types.either types.str types.path; + description = lib.mdDoc '' + Path to the file which contains the password of the admin user. + ''; + }; + + activate = mkEnableOption (lib.mdDoc "activating the freshly created admin-user"); + }; + + database = { + clickhouse = { + setup = mkEnableOption (lib.mdDoc "creating a clickhouse instance") // { default = true; }; + url = mkOption { + default = "http://localhost:8123/default"; + type = types.str; + description = lib.mdDoc '' + The URL to be used to connect to `clickhouse`. + ''; + }; + }; + postgres = { + setup = mkEnableOption (lib.mdDoc "creating a postgresql instance") // { default = true; }; + dbname = mkOption { + default = "plausible"; + type = types.str; + description = lib.mdDoc '' + Name of the database to use. + ''; + }; + socket = mkOption { + default = "/run/postgresql"; + type = types.str; + description = lib.mdDoc '' + Path to the UNIX domain-socket to communicate with `postgres`. + ''; + }; + }; + }; + + server = { + disableRegistration = mkOption { + default = true; + type = types.bool; + description = lib.mdDoc '' + Whether to prohibit creating an account in plausible's UI. + ''; + }; + secretKeybaseFile = mkOption { + type = types.either types.path types.str; + description = lib.mdDoc '' + Path to the secret used by the `phoenix`-framework. Instructions + how to generate one are documented in the + [ + framework docs](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Secret.html#content). + ''; + }; + port = mkOption { + default = 8000; + type = types.port; + description = lib.mdDoc '' + Port where the service should be available. + ''; + }; + baseUrl = mkOption { + type = types.str; + description = lib.mdDoc '' + Public URL where plausible is available. + + Note that `/path` components are currently ignored: + [ + https://github.com/plausible/analytics/issues/1182 + ](https://github.com/plausible/analytics/issues/1182). + ''; + }; + }; + + mail = { + email = mkOption { + default = "hello@plausible.local"; + type = types.str; + description = lib.mdDoc '' + The email id to use for as *from* address of all communications + from Plausible. + ''; + }; + smtp = { + hostAddr = mkOption { + default = "localhost"; + type = types.str; + description = lib.mdDoc '' + The host address of your smtp server. + ''; + }; + hostPort = mkOption { + default = 25; + type = types.port; + description = lib.mdDoc '' + The port of your smtp server. + ''; + }; + user = mkOption { + default = null; + type = types.nullOr types.str; + description = lib.mdDoc '' + The username/email in case SMTP auth is enabled. + ''; + }; + passwordFile = mkOption { + default = null; + type = with types; nullOr (either str path); + description = lib.mdDoc '' + The path to the file with the password in case SMTP auth is enabled. + ''; + }; + enableSSL = mkEnableOption (lib.mdDoc "SSL when connecting to the SMTP server"); + retries = mkOption { + type = types.ints.unsigned; + default = 2; + description = lib.mdDoc '' + Number of retries to make until mailer gives up. + ''; + }; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { assertion = cfg.adminUser.activate -> cfg.database.postgres.setup; + message = '' + Unable to automatically activate the admin-user if no locally managed DB for + postgres (`services.plausible.database.postgres.setup') is enabled! + ''; + } + ]; + + services.postgresql = mkIf cfg.database.postgres.setup { + enable = true; + }; + + services.clickhouse = mkIf cfg.database.clickhouse.setup { + enable = true; + }; + + services.epmd.enable = true; + + environment.systemPackages = [ cfg.package ]; + + systemd.services = mkMerge [ + { + plausible = { + inherit (cfg.package.meta) description; + documentation = [ "https://plausible.io/docs/self-hosting" ]; + wantedBy = [ "multi-user.target" ]; + after = optional cfg.database.clickhouse.setup "clickhouse.service" + ++ optionals cfg.database.postgres.setup [ + "postgresql.service" + "plausible-postgres.service" + ]; + requires = optional cfg.database.clickhouse.setup "clickhouse.service" + ++ optionals cfg.database.postgres.setup [ + "postgresql.service" + "plausible-postgres.service" + ]; + + environment = { + # NixOS specific option to avoid that it's trying to write into its store-path. + # See also https://github.com/lau/tzdata#data-directory-and-releases + STORAGE_DIR = "/var/lib/plausible/elixir_tzdata"; + + # Configuration options from + # https://plausible.io/docs/self-hosting-configuration + PORT = toString cfg.server.port; + DISABLE_REGISTRATION = boolToString cfg.server.disableRegistration; + + RELEASE_TMP = "/var/lib/plausible/tmp"; + # Home is needed to connect to the node with iex + HOME = "/var/lib/plausible"; + + ADMIN_USER_NAME = cfg.adminUser.name; + ADMIN_USER_EMAIL = cfg.adminUser.email; + + DATABASE_SOCKET_DIR = cfg.database.postgres.socket; + DATABASE_NAME = cfg.database.postgres.dbname; + CLICKHOUSE_DATABASE_URL = cfg.database.clickhouse.url; + + BASE_URL = cfg.server.baseUrl; + + MAILER_EMAIL = cfg.mail.email; + SMTP_HOST_ADDR = cfg.mail.smtp.hostAddr; + SMTP_HOST_PORT = toString cfg.mail.smtp.hostPort; + SMTP_RETRIES = toString cfg.mail.smtp.retries; + SMTP_HOST_SSL_ENABLED = boolToString cfg.mail.smtp.enableSSL; + + SELFHOST = "true"; + } // (optionalAttrs (cfg.mail.smtp.user != null) { + SMTP_USER_NAME = cfg.mail.smtp.user; + }); + + path = [ cfg.package ] + ++ optional cfg.database.postgres.setup config.services.postgresql.package; + script = '' + export CONFIG_DIR=$CREDENTIALS_DIRECTORY + + export RELEASE_COOKIE="$(< $CREDENTIALS_DIRECTORY/RELEASE_COOKIE )" + + # setup + ${cfg.package}/createdb.sh + ${cfg.package}/migrate.sh + ${optionalString cfg.adminUser.activate '' + if ! ${cfg.package}/init-admin.sh | grep 'already exists'; then + psql -d plausible <<< "UPDATE users SET email_verified=true;" + fi + ''} + + exec plausible start + ''; + + serviceConfig = { + DynamicUser = true; + PrivateTmp = true; + WorkingDirectory = "/var/lib/plausible"; + StateDirectory = "plausible"; + LoadCredential = [ + "ADMIN_USER_PWD:${cfg.adminUser.passwordFile}" + "SECRET_KEY_BASE:${cfg.server.secretKeybaseFile}" + "RELEASE_COOKIE:${cfg.releaseCookiePath}" + ] ++ lib.optionals (cfg.mail.smtp.passwordFile != null) [ "SMTP_USER_PWD:${cfg.mail.smtp.passwordFile}"]; + }; + }; + } + (mkIf cfg.database.postgres.setup { + # `plausible' requires the `citext'-extension. + plausible-postgres = { + after = [ "postgresql.service" ]; + partOf = [ "plausible.service" ]; + serviceConfig = { + Type = "oneshot"; + User = config.services.postgresql.superUser; + RemainAfterExit = true; + }; + script = with cfg.database.postgres; '' + PSQL() { + ${config.services.postgresql.package}/bin/psql --port=5432 "$@" + } + # check if the database already exists + if ! PSQL -lqt | ${pkgs.coreutils}/bin/cut -d \| -f 1 | ${pkgs.gnugrep}/bin/grep -qw ${dbname} ; then + PSQL -tAc "CREATE ROLE plausible WITH LOGIN;" + PSQL -tAc "CREATE DATABASE ${dbname} WITH OWNER plausible;" + PSQL -d ${dbname} -tAc "CREATE EXTENSION IF NOT EXISTS citext;" + fi + ''; + }; + }) + ]; + }; + + meta.maintainers = with maintainers; [ ma27 ]; + meta.doc = ./plausible.md; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/powerdns-admin.nix b/nixpkgs/nixos/modules/services/web-apps/powerdns-admin.nix new file mode 100644 index 000000000000..7b6fb06e3565 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/powerdns-admin.nix @@ -0,0 +1,153 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.powerdns-admin; + + configText = '' + ${cfg.config} + '' + + optionalString (cfg.secretKeyFile != null) '' + with open('${cfg.secretKeyFile}') as file: + SECRET_KEY = file.read() + '' + + optionalString (cfg.saltFile != null) '' + with open('${cfg.saltFile}') as file: + SALT = file.read() + ''; +in +{ + options.services.powerdns-admin = { + enable = mkEnableOption (lib.mdDoc "the PowerDNS web interface"); + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = literalExpression '' + [ "-b" "127.0.0.1:8000" ] + ''; + description = lib.mdDoc '' + Extra arguments passed to powerdns-admin. + ''; + }; + + config = mkOption { + type = types.str; + default = ""; + example = '' + BIND_ADDRESS = '127.0.0.1' + PORT = 8000 + SQLALCHEMY_DATABASE_URI = 'postgresql://powerdnsadmin@/powerdnsadmin?host=/run/postgresql' + ''; + description = lib.mdDoc '' + Configuration python file. + See [the example configuration](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/v${pkgs.powerdns-admin.version}/configs/development.py) + for options. + ''; + }; + + secretKeyFile = mkOption { + type = types.nullOr types.path; + example = "/etc/powerdns-admin/secret"; + description = lib.mdDoc '' + The secret used to create cookies. + This needs to be set, otherwise the default is used and everyone can forge valid login cookies. + Set this to null to ignore this setting and configure it through another way. + ''; + }; + + saltFile = mkOption { + type = types.nullOr types.path; + example = "/etc/powerdns-admin/salt"; + description = lib.mdDoc '' + The salt used for serialization. + This should be set, otherwise the default is used. + Set this to null to ignore this setting and configure it through another way. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.powerdns-admin = { + description = "PowerDNS web interface"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + + environment.FLASK_CONF = builtins.toFile "powerdns-admin-config.py" configText; + environment.PYTHONPATH = pkgs.powerdns-admin.pythonPath; + serviceConfig = { + ExecStart = "${pkgs.powerdns-admin}/bin/powerdns-admin --pid /run/powerdns-admin/pid ${escapeShellArgs cfg.extraArgs}"; + # Set environment variables only for starting flask database upgrade + ExecStartPre = "${pkgs.coreutils}/bin/env FLASK_APP=${pkgs.powerdns-admin}/share/powerdnsadmin/__init__.py SESSION_TYPE= ${pkgs.python3Packages.flask}/bin/flask db upgrade -d ${pkgs.powerdns-admin}/share/migrations"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + ExecStop = "${pkgs.coreutils}/bin/kill -TERM $MAINPID"; + PIDFile = "/run/powerdns-admin/pid"; + RuntimeDirectory = "powerdns-admin"; + User = "powerdnsadmin"; + Group = "powerdnsadmin"; + + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + BindReadOnlyPaths = [ + "/nix/store" + "-/etc/resolv.conf" + "-/etc/nsswitch.conf" + "-/etc/hosts" + "-/etc/localtime" + ] + ++ (optional (cfg.secretKeyFile != null) cfg.secretKeyFile) + ++ (optional (cfg.saltFile != null) cfg.saltFile); + CapabilityBoundingSet = "CAP_NET_BIND_SERVICE"; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + # Implies ProtectSystem=strict, which re-mounts all paths + #DynamicUser = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + # Needs to start a server + #PrivateNetwork = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectHome = true; + ProtectHostname = true; + # Would re-mount paths ignored by temporary root + #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"; + # gunicorn needs setuid + SystemCallFilter = [ + "@system-service" + "~@privileged @resources @keyring" + # These got removed by the line above but are needed + "@setuid @chown" + ]; + TemporaryFileSystem = "/:ro"; + # Does not work well with the temporary root + #UMask = "0066"; + }; + }; + + users.groups.powerdnsadmin = { }; + users.users.powerdnsadmin = { + description = "PowerDNS web interface user"; + isSystemUser = true; + group = "powerdnsadmin"; + }; + }; + + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/prosody-filer.nix b/nixpkgs/nixos/modules/services/web-apps/prosody-filer.nix new file mode 100644 index 000000000000..84953546d8e0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/prosody-filer.nix @@ -0,0 +1,86 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + + cfg = config.services.prosody-filer; + + settingsFormat = pkgs.formats.toml { }; + configFile = settingsFormat.generate "prosody-filer.toml" cfg.settings; +in { + + options = { + services.prosody-filer = { + enable = mkEnableOption (lib.mdDoc "Prosody Filer XMPP upload file server"); + + settings = mkOption { + description = lib.mdDoc '' + Configuration for Prosody Filer. + Refer to <https://github.com/ThomasLeister/prosody-filer#configure-prosody-filer> for details on supported values. + ''; + + type = settingsFormat.type; + + example = { + secret = "mysecret"; + storeDir = "/srv/http/nginx/prosody-upload"; + }; + + defaultText = literalExpression '' + { + listenport = mkDefault "127.0.0.1:5050"; + uploadSubDir = mkDefault "upload/"; + } + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.prosody-filer.settings = { + listenport = mkDefault "127.0.0.1:5050"; + uploadSubDir = mkDefault "upload/"; + }; + + users.users.prosody-filer = { + group = "prosody-filer"; + isSystemUser = true; + }; + + users.groups.prosody-filer = { }; + + systemd.services.prosody-filer = { + description = "Prosody file upload server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + User = "prosody-filer"; + Group = "prosody-filer"; + ExecStart = "${pkgs.prosody-filer}/bin/prosody-filer -config ${configFile}"; + Restart = "on-failure"; + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectHome = true; + ProtectClock = true; + ProtectProc = "noaccess"; + ProcSubset = "pid"; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + ProtectHostname = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + RestrictNamespaces = true; + LockPersonality = true; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + SystemCallFilter = [ "@system-service" "~@privileged" ]; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/restya-board.nix b/nixpkgs/nixos/modules/services/web-apps/restya-board.nix new file mode 100644 index 000000000000..4b32f06826e2 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/restya-board.nix @@ -0,0 +1,380 @@ +{ config, lib, pkgs, ... }: + +with lib; + +# TODO: are these php-packages needed? +#imagick +#php-geoip -> php.ini: extension = geoip.so +#expat + +let + cfg = config.services.restya-board; + fpm = config.services.phpfpm.pools.${poolName}; + + runDir = "/run/restya-board"; + + poolName = "restya-board"; + +in + +{ + + ###### interface + + options = { + + services.restya-board = { + + enable = mkEnableOption (lib.mdDoc "restya-board"); + + dataDir = mkOption { + type = types.path; + default = "/var/lib/restya-board"; + description = lib.mdDoc '' + Data of the application. + ''; + }; + + user = mkOption { + type = types.str; + default = "restya-board"; + description = lib.mdDoc '' + User account under which the web-application runs. + ''; + }; + + group = mkOption { + type = types.str; + default = "nginx"; + description = lib.mdDoc '' + Group account under which the web-application runs. + ''; + }; + + virtualHost = { + serverName = mkOption { + type = types.str; + default = "restya.board"; + description = lib.mdDoc '' + Name of the nginx virtualhost to use. + ''; + }; + + listenHost = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc '' + Listen address for the virtualhost to use. + ''; + }; + + listenPort = mkOption { + type = types.port; + default = 3000; + description = lib.mdDoc '' + Listen port for the virtualhost to use. + ''; + }; + }; + + database = { + host = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Host of the database. Leave 'null' to use a local PostgreSQL database. + A local PostgreSQL database is initialized automatically. + ''; + }; + + port = mkOption { + type = types.nullOr types.int; + default = 5432; + description = lib.mdDoc '' + The database's port. + ''; + }; + + name = mkOption { + type = types.str; + default = "restya_board"; + description = lib.mdDoc '' + Name of the database. The database must exist. + ''; + }; + + user = mkOption { + type = types.str; + default = "restya_board"; + description = lib.mdDoc '' + The database user. The user must exist and have access to + the specified database. + ''; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc '' + The database user's password. 'null' if no password is set. + ''; + }; + }; + + email = { + server = mkOption { + type = types.nullOr types.str; + default = null; + example = "localhost"; + description = lib.mdDoc '' + Hostname to send outgoing mail. Null to use the system MTA. + ''; + }; + + port = mkOption { + type = types.port; + default = 25; + description = lib.mdDoc '' + Port used to connect to SMTP server. + ''; + }; + + login = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + SMTP authentication login used when sending outgoing mail. + ''; + }; + + password = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + SMTP authentication password used when sending outgoing mail. + + ATTENTION: The password is stored world-readable in the nix-store! + ''; + }; + }; + + timezone = mkOption { + type = types.lines; + default = "GMT"; + description = lib.mdDoc '' + Timezone the web-app runs in. + ''; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + services.phpfpm.pools = { + ${poolName} = { + inherit (cfg) user group; + + phpOptions = '' + date.timezone = "CET" + + ${optionalString (cfg.email.server != null) '' + SMTP = ${cfg.email.server} + smtp_port = ${toString cfg.email.port} + auth_username = ${cfg.email.login} + auth_password = ${cfg.email.password} + ''} + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "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.enable = true; + services.nginx.virtualHosts.${cfg.virtualHost.serverName} = { + listen = [ { addr = cfg.virtualHost.listenHost; port = cfg.virtualHost.listenPort; } ]; + serverName = cfg.virtualHost.serverName; + root = runDir; + extraConfig = '' + index index.html index.php; + + gzip on; + + gzip_comp_level 6; + gzip_min_length 1100; + gzip_buffers 16 8k; + gzip_proxied any; + gzip_types text/plain application/xml text/css text/js text/xml application/x-javascript text/javascript application/json application/xml+rss; + + client_max_body_size 300M; + + rewrite ^/oauth/authorize$ /server/php/authorize.php last; + rewrite ^/oauth_callback/([a-zA-Z0-9_\.]*)/([a-zA-Z0-9_\.]*)$ /server/php/oauth_callback.php?plugin=$1&code=$2 last; + rewrite ^/download/([0-9]*)/([a-zA-Z0-9_\.]*)$ /server/php/download.php?id=$1&hash=$2 last; + rewrite ^/ical/([0-9]*)/([0-9]*)/([a-z0-9]*).ics$ /server/php/ical.php?board_id=$1&user_id=$2&hash=$3 last; + rewrite ^/api/(.*)$ /server/php/R/r.php?_url=$1&$args last; + rewrite ^/api_explorer/api-docs/$ /client/api_explorer/api-docs/index.php last; + ''; + + locations."/".root = "${runDir}/client"; + + locations."~ \\.php$" = { + tryFiles = "$uri =404"; + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_pass unix:${fpm.socket}; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PHP_VALUE "upload_max_filesize=9G \n post_max_size=9G \n max_execution_time=200 \n max_input_time=200 \n memory_limit=256M"; + ''; + }; + + locations."~* \\.(css|js|less|html|ttf|woff|jpg|jpeg|gif|png|bmp|ico)" = { + root = "${runDir}/client"; + extraConfig = '' + if (-f $request_filename) { + break; + } + rewrite ^/img/([a-zA-Z_]*)/([a-zA-Z_]*)/([a-zA-Z0-9_\.]*)$ /server/php/image.php?size=$1&model=$2&filename=$3 last; + add_header Cache-Control public; + add_header Cache-Control must-revalidate; + expires 7d; + ''; + }; + }; + + systemd.services.restya-board-init = { + description = "Restya board initialization"; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + + wantedBy = [ "multi-user.target" ]; + requires = if cfg.database.host == null then [] else [ "postgresql.service" ]; + after = [ "network.target" ] ++ (if cfg.database.host == null then [] else [ "postgresql.service" ]); + + script = '' + rm -rf "${runDir}" + mkdir -m 750 -p "${runDir}" + cp -r "${pkgs.restya-board}/"* "${runDir}" + sed -i "s/@restya.com/@${cfg.virtualHost.serverName}/g" "${runDir}/sql/restyaboard_with_empty_data.sql" + rm -rf "${runDir}/media" + rm -rf "${runDir}/client/img" + chmod -R 0750 "${runDir}" + + sed -i "s@^php@${config.services.phpfpm.phpPackage}/bin/php@" "${runDir}/server/php/shell/"*.sh + + ${if (cfg.database.host == null) then '' + sed -i "s/^.*'R_DB_HOST'.*$/define('R_DB_HOST', 'localhost');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_PASSWORD'.*$/define('R_DB_PASSWORD', 'restya');/g" "${runDir}/server/php/config.inc.php" + '' else '' + sed -i "s/^.*'R_DB_HOST'.*$/define('R_DB_HOST', '${cfg.database.host}');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_PASSWORD'.*$/define('R_DB_PASSWORD', ${if cfg.database.passwordFile == null then "''" else "'$(cat ${cfg.database.passwordFile})');/g"}" "${runDir}/server/php/config.inc.php" + ''} + sed -i "s/^.*'R_DB_PORT'.*$/define('R_DB_PORT', '${toString cfg.database.port}');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_NAME'.*$/define('R_DB_NAME', '${cfg.database.name}');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_USER'.*$/define('R_DB_USER', '${cfg.database.user}');/g" "${runDir}/server/php/config.inc.php" + + chmod 0400 "${runDir}/server/php/config.inc.php" + + ln -sf "${cfg.dataDir}/media" "${runDir}/media" + ln -sf "${cfg.dataDir}/client/img" "${runDir}/client/img" + + chmod g+w "${runDir}/tmp/cache" + chown -R "${cfg.user}":"${cfg.group}" "${runDir}" + + + mkdir -m 0750 -p "${cfg.dataDir}" + mkdir -m 0750 -p "${cfg.dataDir}/media" + mkdir -m 0750 -p "${cfg.dataDir}/client/img" + cp -r "${pkgs.restya-board}/media/"* "${cfg.dataDir}/media" + cp -r "${pkgs.restya-board}/client/img/"* "${cfg.dataDir}/client/img" + chown "${cfg.user}":"${cfg.group}" "${cfg.dataDir}" + chown -R "${cfg.user}":"${cfg.group}" "${cfg.dataDir}/media" + chown -R "${cfg.user}":"${cfg.group}" "${cfg.dataDir}/client/img" + + ${optionalString (cfg.database.host == null) '' + if ! [ -e "${cfg.dataDir}/.db-initialized" ]; then + ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} \ + ${config.services.postgresql.package}/bin/psql -U ${config.services.postgresql.superUser} \ + -c "CREATE USER ${cfg.database.user} WITH ENCRYPTED PASSWORD 'restya'" + + ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} \ + ${config.services.postgresql.package}/bin/psql -U ${config.services.postgresql.superUser} \ + -c "CREATE DATABASE ${cfg.database.name} OWNER ${cfg.database.user} ENCODING 'UTF8' TEMPLATE template0" + + ${pkgs.sudo}/bin/sudo -u ${cfg.user} \ + ${config.services.postgresql.package}/bin/psql -U ${cfg.database.user} \ + -d ${cfg.database.name} -f "${runDir}/sql/restyaboard_with_empty_data.sql" + + touch "${cfg.dataDir}/.db-initialized" + fi + ''} + ''; + }; + + systemd.timers.restya-board = { + description = "restya-board scripts for e.g. email notification"; + wantedBy = [ "timers.target" ]; + after = [ "restya-board-init.service" ]; + requires = [ "restya-board-init.service" ]; + timerConfig = { + OnUnitInactiveSec = "60s"; + Unit = "restya-board-timers.service"; + }; + }; + + systemd.services.restya-board-timers = { + description = "restya-board scripts for e.g. email notification"; + serviceConfig.Type = "oneshot"; + serviceConfig.User = cfg.user; + + after = [ "restya-board-init.service" ]; + requires = [ "restya-board-init.service" ]; + + script = '' + /bin/sh ${runDir}/server/php/shell/instant_email_notification.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/periodic_email_notification.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/imap.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/webhook.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/card_due_notification.sh 2> /dev/null || true + ''; + }; + + users.users.restya-board = { + isSystemUser = true; + createHome = false; + home = runDir; + group = "restya-board"; + }; + users.groups.restya-board = {}; + + services.postgresql.enable = mkIf (cfg.database.host == null) true; + + services.postgresql.identMap = optionalString (cfg.database.host == null) + '' + restya-board-users restya-board restya_board + ''; + + services.postgresql.authentication = optionalString (cfg.database.host == null) + '' + local restya_board all ident map=restya-board-users + ''; + + }; + +} + diff --git a/nixpkgs/nixos/modules/services/web-apps/rss-bridge.nix b/nixpkgs/nixos/modules/services/web-apps/rss-bridge.nix new file mode 100644 index 000000000000..1a710f4a6a67 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/rss-bridge.nix @@ -0,0 +1,125 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.rss-bridge; + + poolName = "rss-bridge"; + + whitelist = pkgs.writeText "rss-bridge_whitelist.txt" + (concatStringsSep "\n" cfg.whitelist); +in +{ + options = { + services.rss-bridge = { + enable = mkEnableOption (lib.mdDoc "rss-bridge"); + + user = mkOption { + type = types.str; + default = "nginx"; + description = lib.mdDoc '' + User account under which both the service and the web-application run. + ''; + }; + + group = mkOption { + type = types.str; + default = "nginx"; + description = lib.mdDoc '' + Group under which the web-application run. + ''; + }; + + pool = mkOption { + type = types.str; + default = poolName; + description = lib.mdDoc '' + Name of existing phpfpm pool that is used to run web-application. + If not specified a pool will be created automatically with + default values. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/rss-bridge"; + description = lib.mdDoc '' + Location in which cache directory will be created. + You can put `config.ini.php` in here. + ''; + }; + + virtualHost = mkOption { + type = types.nullOr types.str; + default = "rss-bridge"; + description = lib.mdDoc '' + Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost. + ''; + }; + + whitelist = mkOption { + type = types.listOf types.str; + default = []; + example = options.literalExpression '' + [ + "Facebook" + "Instagram" + "Twitter" + ] + ''; + description = lib.mdDoc '' + List of bridges to be whitelisted. + If the list is empty, rss-bridge will use whitelist.default.txt. + Use `[ "*" ]` to whitelist all. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.phpfpm.pools = mkIf (cfg.pool == poolName) { + ${poolName} = { + user = cfg.user; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = cfg.user; + "listen.group" = cfg.user; + "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; + }; + }; + }; + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}/cache' 0750 ${cfg.user} ${cfg.group} - -" + (mkIf (cfg.whitelist != []) "L+ ${cfg.dataDir}/whitelist.txt - - - - ${whitelist}") + "z '${cfg.dataDir}/config.ini.php' 0750 ${cfg.user} ${cfg.group} - -" + ]; + + services.nginx = mkIf (cfg.virtualHost != null) { + enable = true; + virtualHosts = { + ${cfg.virtualHost} = { + root = "${pkgs.rss-bridge}"; + + locations."/" = { + tryFiles = "$uri /index.php$is_args$args"; + }; + + locations."~ ^/index.php(/|$)" = { + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi_params; + 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 RSSBRIDGE_DATA ${cfg.dataDir}; + ''; + }; + }; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/selfoss.nix b/nixpkgs/nixos/modules/services/web-apps/selfoss.nix new file mode 100644 index 000000000000..8debd4904e88 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/selfoss.nix @@ -0,0 +1,164 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.selfoss; + + poolName = "selfoss_pool"; + + dataDir = "/var/lib/selfoss"; + + selfoss-config = + let + db_type = cfg.database.type; + default_port = if (db_type == "mysql") then 3306 else 5342; + in + pkgs.writeText "selfoss-config.ini" '' + [globals] + ${lib.optionalString (db_type != "sqlite") '' + db_type=${db_type} + db_host=${cfg.database.host} + db_database=${cfg.database.name} + db_username=${cfg.database.user} + db_password=${cfg.database.password} + db_port=${toString (if (cfg.database.port != null) then cfg.database.port + else default_port)} + '' + } + ${cfg.extraConfig} + ''; +in + { + options = { + services.selfoss = { + enable = mkEnableOption (lib.mdDoc "selfoss"); + + user = mkOption { + type = types.str; + default = "nginx"; + description = lib.mdDoc '' + User account under which both the service and the web-application run. + ''; + }; + + pool = mkOption { + type = types.str; + default = "${poolName}"; + description = lib.mdDoc '' + Name of existing phpfpm pool that is used to run web-application. + If not specified a pool will be created automatically with + default values. + ''; + }; + + database = { + type = mkOption { + type = types.enum ["pgsql" "mysql" "sqlite"]; + default = "sqlite"; + description = lib.mdDoc '' + Database to store feeds. Supported are sqlite, pgsql and mysql. + ''; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc '' + Host of the database (has no effect if type is "sqlite"). + ''; + }; + + name = mkOption { + type = types.str; + default = "tt_rss"; + description = lib.mdDoc '' + Name of the existing database (has no effect if type is "sqlite"). + ''; + }; + + user = mkOption { + type = types.str; + default = "tt_rss"; + description = lib.mdDoc '' + The database user. The user must exist and has access to + the specified database (has no effect if type is "sqlite"). + ''; + }; + + password = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The database user's password (has no effect if type is "sqlite"). + ''; + }; + + port = mkOption { + type = types.nullOr types.int; + default = null; + description = lib.mdDoc '' + The database's port. If not set, the default ports will be + provided (5432 and 3306 for pgsql and mysql respectively) + (has no effect if type is "sqlite"). + ''; + }; + }; + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Extra configuration added to config.ini + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") { + ${poolName} = { + user = "nginx"; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "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; + }; + }; + }; + + systemd.services.selfoss-config = { + serviceConfig.Type = "oneshot"; + script = '' + mkdir -m 755 -p ${dataDir} + cd ${dataDir} + + # Delete all but the "data" folder + ls | grep -v data | while read line; do rm -rf $line; done || true + + # Create the files + cp -r "${pkgs.selfoss}/"* "${dataDir}" + ln -sf "${selfoss-config}" "${dataDir}/config.ini" + chown -R "${cfg.user}" "${dataDir}" + chmod -R 755 "${dataDir}" + ''; + wantedBy = [ "multi-user.target" ]; + }; + + systemd.services.selfoss-update = { + serviceConfig = { + ExecStart = "${pkgs.php}/bin/php ${dataDir}/cliupdate.php"; + User = "${cfg.user}"; + }; + startAt = "hourly"; + after = [ "selfoss-config.service" ]; + wantedBy = [ "multi-user.target" ]; + + }; + + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/sftpgo.nix b/nixpkgs/nixos/modules/services/web-apps/sftpgo.nix new file mode 100644 index 000000000000..846478ecbd6d --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/sftpgo.nix @@ -0,0 +1,375 @@ +{ options, config, lib, pkgs, utils, ... }: + +with lib; + +let + cfg = config.services.sftpgo; + defaultUser = "sftpgo"; + settingsFormat = pkgs.formats.json {}; + configFile = settingsFormat.generate "sftpgo.json" cfg.settings; + hasPrivilegedPorts = any (port: port > 0 && port < 1024) ( + catAttrs "port" (cfg.settings.httpd.bindings + ++ cfg.settings.ftpd.bindings + ++ cfg.settings.sftpd.bindings + ++ cfg.settings.webdavd.bindings + ) + ); +in +{ + options.services.sftpgo = { + enable = mkOption { + type = types.bool; + default = false; + description = mdDoc "sftpgo"; + }; + + package = mkOption { + type = types.package; + default = pkgs.sftpgo; + defaultText = literalExpression "pkgs.sftpgo"; + description = mdDoc '' + Which SFTPGo package to use. + ''; + }; + + extraArgs = mkOption { + type = with types; listOf str; + default = []; + description = mdDoc '' + Additional command line arguments to pass to the sftpgo daemon. + ''; + example = [ "--log-level" "info" ]; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/sftpgo"; + description = mdDoc '' + The directory where SFTPGo stores its data files. + ''; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = mdDoc '' + User account name under which SFTPGo runs. + ''; + }; + + group = mkOption { + type = types.str; + default = defaultUser; + description = mdDoc '' + Group name under which SFTPGo runs. + ''; + }; + + loadDataFile = mkOption { + default = null; + type = with types; nullOr path; + description = mdDoc '' + Path to a json file containing users and folders to load (or update) on startup. + Check the [documentation](https://github.com/drakkan/sftpgo/blob/main/docs/full-configuration.md) + for the `--loaddata-from` command line argument for more info. + ''; + }; + + settings = mkOption { + default = {}; + description = mdDoc '' + The primary sftpgo configuration. See the + [configuration reference](https://github.com/drakkan/sftpgo/blob/main/docs/full-configuration.md) + for possible values. + ''; + type = with types; submodule { + freeformType = settingsFormat.type; + options = { + httpd.bindings = mkOption { + default = []; + description = mdDoc '' + Configure listen addresses and ports for httpd. + ''; + type = types.listOf (types.submodule { + freeformType = settingsFormat.type; + options = { + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = mdDoc '' + Network listen address. Leave blank to listen on all available network interfaces. + On *NIX you can specify an absolute path to listen on a Unix-domain socket. + ''; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = mdDoc '' + The port for serving HTTP(S) requests. + + Setting the port to `0` disables listening on this interface binding. + ''; + }; + + enable_web_admin = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Enable the built-in web admin for this interface binding. + ''; + }; + + enable_web_client = mkOption { + type = types.bool; + default = true; + description = mdDoc '' + Enable the built-in web client for this interface binding. + ''; + }; + }; + }); + }; + + ftpd.bindings = mkOption { + default = []; + description = mdDoc '' + Configure listen addresses and ports for ftpd. + ''; + type = types.listOf (types.submodule { + freeformType = settingsFormat.type; + options = { + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = mdDoc '' + Network listen address. Leave blank to listen on all available network interfaces. + On *NIX you can specify an absolute path to listen on a Unix-domain socket. + ''; + }; + + port = mkOption { + type = types.port; + default = 0; + description = mdDoc '' + The port for serving FTP requests. + + Setting the port to `0` disables listening on this interface binding. + ''; + }; + }; + }); + }; + + sftpd.bindings = mkOption { + default = []; + description = mdDoc '' + Configure listen addresses and ports for sftpd. + ''; + type = types.listOf (types.submodule { + freeformType = settingsFormat.type; + options = { + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = mdDoc '' + Network listen address. Leave blank to listen on all available network interfaces. + On *NIX you can specify an absolute path to listen on a Unix-domain socket. + ''; + }; + + port = mkOption { + type = types.port; + default = 0; + description = mdDoc '' + The port for serving SFTP requests. + + Setting the port to `0` disables listening on this interface binding. + ''; + }; + }; + }); + }; + + webdavd.bindings = mkOption { + default = []; + description = mdDoc '' + Configure listen addresses and ports for webdavd. + ''; + type = types.listOf (types.submodule { + freeformType = settingsFormat.type; + options = { + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = mdDoc '' + Network listen address. Leave blank to listen on all available network interfaces. + On *NIX you can specify an absolute path to listen on a Unix-domain socket. + ''; + }; + + port = mkOption { + type = types.port; + default = 0; + description = mdDoc '' + The port for serving WebDAV requests. + + Setting the port to `0` disables listening on this interface binding. + ''; + }; + }; + }); + }; + + smtp = mkOption { + default = {}; + description = mdDoc '' + SMTP configuration section. + ''; + type = types.submodule { + freeformType = settingsFormat.type; + options = { + host = mkOption { + type = types.str; + default = ""; + description = mdDoc '' + Location of SMTP email server. Leave empty to disable email sending capabilities. + ''; + }; + + port = mkOption { + type = types.port; + default = 465; + description = mdDoc "Port of the SMTP Server."; + }; + + encryption = mkOption { + type = types.enum [ 0 1 2 ]; + default = 1; + description = mdDoc '' + Encryption scheme: + - `0`: No encryption + - `1`: TLS + - `2`: STARTTLS + ''; + }; + + auth_type = mkOption { + type = types.enum [ 0 1 2 ]; + default = 0; + description = mdDoc '' + - `0`: Plain + - `1`: Login + - `2`: CRAM-MD5 + ''; + }; + + user = mkOption { + type = types.str; + default = "sftpgo"; + description = mdDoc "SMTP username."; + }; + + from = mkOption { + type = types.str; + default = "SFTPGo <sftpgo@example.com>"; + description = mdDoc '' + From address. + ''; + }; + }; + }; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + services.sftpgo.settings = (mapAttrs (name: mkDefault) { + ftpd.bindings = [{ port = 0; }]; + httpd.bindings = [{ port = 0; }]; + sftpd.bindings = [{ port = 0; }]; + webdavd.bindings = [{ port = 0; }]; + httpd.openapi_path = "${cfg.package}/share/sftpgo/openapi"; + httpd.templates_path = "${cfg.package}/share/sftpgo/templates"; + httpd.static_files_path = "${cfg.package}/share/sftpgo/static"; + smtp.templates_path = "${cfg.package}/share/sftpgo/templates"; + }); + + users = optionalAttrs (cfg.user == defaultUser) { + users = { + ${defaultUser} = { + description = "SFTPGo system user"; + isSystemUser = true; + group = defaultUser; + home = cfg.dataDir; + }; + }; + + groups = { + ${defaultUser} = { + members = [ defaultUser ]; + }; + }; + }; + + systemd.services.sftpgo = { + description = "SFTPGo daemon"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + SFTPGO_CONFIG_FILE = mkDefault configFile; + SFTPGO_LOG_FILE_PATH = mkDefault ""; # log to journal + SFTPGO_LOADDATA_FROM = mkIf (cfg.loadDataFile != null) cfg.loadDataFile; + }; + + serviceConfig = mkMerge [ + ({ + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.dataDir; + ReadWritePaths = [ cfg.dataDir ]; + LimitNOFILE = 8192; # taken from upstream + KillMode = "mixed"; + ExecStart = "${cfg.package}/bin/sftpgo serve ${utils.escapeSystemdExecArgs cfg.extraArgs}"; + ExecReload = "${pkgs.util-linux}/bin/kill -s HUP $MAINPID"; + + # Service hardening + CapabilityBoundingSet = [ (optionalString hasPrivilegedPorts "CAP_NET_BIND_SERVICE") ]; + DevicePolicy = "closed"; + 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 AF_UNIX"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" ]; + UMask = "0077"; + }) + (mkIf hasPrivilegedPorts { + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + }) + (mkIf (cfg.dataDir == options.services.sftpgo.dataDir.default) { + StateDirectory = baseNameOf cfg.dataDir; + }) + ]; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/shiori.nix b/nixpkgs/nixos/modules/services/web-apps/shiori.nix new file mode 100644 index 000000000000..f0505e052e1c --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/shiori.nix @@ -0,0 +1,96 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.shiori; +in { + options = { + services.shiori = { + enable = mkEnableOption (lib.mdDoc "Shiori simple bookmarks manager"); + + package = mkOption { + type = types.package; + default = pkgs.shiori; + defaultText = literalExpression "pkgs.shiori"; + description = lib.mdDoc "The Shiori package to use."; + }; + + address = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + The IP address on which Shiori will listen. + If empty, listens on all interfaces. + ''; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = lib.mdDoc "The port of the Shiori web application"; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.shiori = with cfg; { + description = "Shiori simple bookmarks manager"; + wantedBy = [ "multi-user.target" ]; + + environment.SHIORI_DIR = "/var/lib/shiori"; + + serviceConfig = { + ExecStart = "${package}/bin/shiori serve --address '${address}' --port '${toString port}'"; + + DynamicUser = true; + StateDirectory = "shiori"; + # As the RootDirectory + RuntimeDirectory = "shiori"; + + # Security options + + BindReadOnlyPaths = [ + "/nix/store" + + # For SSL certificates, and the resolv.conf + "/etc" + ]; + + CapabilityBoundingSet = ""; + + DeviceAllow = ""; + + LockPersonality = true; + + MemoryDenyWriteExecute = true; + + PrivateDevices = true; + PrivateUsers = true; + + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + + RestrictNamespaces = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictRealtime = true; + RestrictSUIDSGID = true; + + RootDirectory = "/run/shiori"; + + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + SystemCallFilter = [ + "@system-service" + "~@cpu-emulation" "~@debug" "~@keyring" "~@memlock" "~@obsolete" "~@privileged" "~@setuid" + ]; + }; + }; + }; + + meta.maintainers = with maintainers; [ minijackson ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/snipe-it.nix b/nixpkgs/nixos/modules/services/web-apps/snipe-it.nix new file mode 100644 index 000000000000..93b0aafab64b --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/snipe-it.nix @@ -0,0 +1,510 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.snipe-it; + snipe-it = pkgs.snipe-it.override { + dataDir = cfg.dataDir; + }; + db = cfg.database; + mail = cfg.mail; + + user = cfg.user; + group = cfg.group; + + tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME; + + # shell script for local administration + artisan = pkgs.writeScriptBin "snipe-it" '' + #! ${pkgs.runtimeShell} + cd ${snipe-it} + sudo=exec + if [[ "$USER" != ${user} ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${user}' + fi + $sudo ${pkgs.php}/bin/php artisan $* + ''; +in { + options.services.snipe-it = { + + enable = mkEnableOption (lib.mdDoc "A free open source IT asset/license management system"); + + user = mkOption { + default = "snipeit"; + description = lib.mdDoc "User snipe-it runs as."; + type = types.str; + }; + + group = mkOption { + default = "snipeit"; + description = lib.mdDoc "Group snipe-it runs as."; + type = types.str; + }; + + appKeyFile = mkOption { + description = lib.mdDoc '' + A file containing the Laravel APP_KEY - a 32 character long, + base64 encoded key used for encryption where needed. Can be + generated with `head -c 32 /dev/urandom | base64`. + ''; + example = "/run/keys/snipe-it/appkey"; + type = types.path; + }; + + hostName = lib.mkOption { + type = lib.types.str; + default = config.networking.fqdnOrHostName; + defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; + example = "snipe-it.example.com"; + description = lib.mdDoc '' + The hostname to serve Snipe-IT on. + ''; + }; + + appURL = mkOption { + description = lib.mdDoc '' + The root URL that you want to host Snipe-IT on. All URLs in Snipe-IT 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: `snipe-it snipe-it:update-url https://old.example.com https://new.example.com` + ''; + 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 = lib.mdDoc "snipe-it data directory"; + default = "/var/lib/snipe-it"; + type = types.path; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + name = mkOption { + type = types.str; + default = "snipeit"; + description = lib.mdDoc "Database name."; + }; + user = mkOption { + type = types.str; + default = user; + defaultText = literalExpression "user"; + description = lib.mdDoc "Database username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/snipe-it/dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + mail = { + driver = mkOption { + type = types.enum [ "smtp" "sendmail" ]; + default = "smtp"; + description = lib.mdDoc "Mail driver to use."; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Mail host address."; + }; + port = mkOption { + type = types.port; + default = 1025; + description = lib.mdDoc "Mail host port."; + }; + encryption = mkOption { + type = with types; nullOr (enum [ "tls" "ssl" ]); + default = null; + description = lib.mdDoc "SMTP encryption mechanism to use."; + }; + user = mkOption { + type = with types; nullOr str; + default = null; + example = "snipeit"; + description = lib.mdDoc "Mail username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/snipe-it/mailpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`mail.user`. + ''; + }; + backupNotificationAddress = mkOption { + type = types.str; + default = "backup@example.com"; + description = lib.mdDoc "Email Address to send Backup Notifications to."; + }; + from = { + name = mkOption { + type = types.str; + default = "Snipe-IT Asset Management"; + description = lib.mdDoc "Mail \"from\" name."; + }; + address = mkOption { + type = types.str; + default = "mail@example.com"; + description = lib.mdDoc "Mail \"from\" address."; + }; + }; + replyTo = { + name = mkOption { + type = types.str; + default = "Snipe-IT Asset Management"; + description = lib.mdDoc "Mail \"reply-to\" name."; + }; + address = mkOption { + type = types.str; + default = "mail@example.com"; + description = lib.mdDoc "Mail \"reply-to\" address."; + }; + }; + }; + + maxUploadSize = mkOption { + type = types.str; + default = "18M"; + example = "1G"; + description = lib.mdDoc "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 = lib.mdDoc '' + Options for the snipe-it PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + nginx = mkOption { + type = types.submodule ( + recursiveUpdate + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {} + ); + default = {}; + example = literalExpression '' + { + serverAliases = [ + "snipe-it.''${config.networking.domain}" + ]; + # To enable encryption and let let's encrypt take care of certificate + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + 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 (oneOf [ str path ]); + description = lib.mdDoc '' + The path to a file containing the value the + option should be set to in the final + configuration file. + ''; + }; + }; + }))); + default = {}; + example = literalExpression '' + { + ALLOWED_IFRAME_HOSTS = "https://example.com"; + WKHTMLTOPDF = "''${pkgs.wkhtmltopdf}/bin/wkhtmltopdf"; + AUTH_METHOD = "oidc"; + OIDC_NAME = "MyLogin"; + OIDC_DISPLAY_NAME_CLAIMS = "name"; + OIDC_CLIENT_ID = "snipe-it"; + OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"}; + OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm"; + OIDC_ISSUER_DISCOVER = true; + } + ''; + description = lib.mdDoc '' + Snipe-IT configuration options to set in the + {file}`.env` file. + Refer to <https://snipe-it.readme.io/docs/configuration> + for details on supported values. + + 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 to get a better picture of + this: in the resulting {file}`.env` file, the + `OIDC_CLIENT_SECRET` key will be set to the + contents of the {file}`/run/keys/oidc_secret` + file. + ''; + }; + }; + + config = mkIf cfg.enable { + + assertions = [ + { assertion = db.createLocally -> db.user == user; + message = "services.snipe-it.database.user must be set to ${user} if services.snipe-it.database.createLocally is set true."; + } + { assertion = db.createLocally -> db.passwordFile == null; + message = "services.snipe-it.database.passwordFile cannot be specified if services.snipe-it.database.createLocally is set to true."; + } + ]; + + environment.systemPackages = [ artisan ]; + + services.snipe-it.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; + DB_PASSWORD._secret = db.passwordFile; + MAIL_DRIVER = mail.driver; + MAIL_FROM_NAME = mail.from.name; + MAIL_FROM_ADDR = mail.from.address; + MAIL_REPLYTO_NAME = mail.from.name; + MAIL_REPLYTO_ADDR = mail.from.address; + MAIL_BACKUP_NOTIFICATION_ADDRESS = mail.backupNotificationAddress; + MAIL_HOST = mail.host; + MAIL_PORT = mail.port; + MAIL_USERNAME = mail.user; + MAIL_ENCRYPTION = mail.encryption; + MAIL_PASSWORD._secret = mail.passwordFile; + APP_SERVICES_CACHE = "/run/snipe-it/cache/services.php"; + APP_PACKAGES_CACHE = "/run/snipe-it/cache/packages.php"; + APP_CONFIG_CACHE = "/run/snipe-it/cache/config.php"; + APP_ROUTES_CACHE = "/run/snipe-it/cache/routes-v7.php"; + APP_EVENTS_CACHE = "/run/snipe-it/cache/events.php"; + SESSION_SECURE_COOKIE = tlsEnabled; + }; + + 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.snipe-it = { + inherit user group; + phpPackage = pkgs.php81; + phpOptions = '' + 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; + virtualHosts."${cfg.hostName}" = mkMerge [ cfg.nginx { + root = mkForce "${snipe-it}/public"; + extraConfig = optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "fastcgi_param HTTPS on;"; + locations = { + "/" = { + index = "index.php"; + extraConfig = ''try_files $uri $uri/ /index.php?$query_string;''; + }; + "~ \.php$" = { + extraConfig = '' + try_files $uri $uri/ /index.php?$query_string; + 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."snipe-it".socket}; + ${optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "fastcgi_param HTTPS on;"} + ''; + }; + "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = { + extraConfig = "expires 365d;"; + }; + }; + }]; + }; + + systemd.services.snipe-it-setup = { + description = "Preparation tasks for snipe-it"; + before = [ "phpfpm-snipe-it.service" ]; + after = optional db.createLocally "mysql.service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = user; + WorkingDirectory = snipe-it; + RuntimeDirectory = "snipe-it/cache"; + RuntimeDirectoryMode = "0700"; + }; + path = [ pkgs.replace-secret ]; + script = + let + isSecret = v: isAttrs v && v ? _secret && (isString v._secret || builtins.isPath v._secret); + snipeITEnvVars = 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 + if (isString v._secret) then + hashString "sha256" v._secret + else + hashString "sha256" (builtins.readFile 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 [ + ( + if (isString file) then + builtins.hashString "sha256" file + else + builtins.hashString "sha256" (builtins.readFile file) + ) + file + "${cfg.dataDir}/.env" + ]} + ''; + secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; + filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ {} null ])) cfg.config; + snipeITEnv = pkgs.writeText "snipeIT.env" (snipeITEnvVars filteredConfig); + in '' + # error handling + set -euo pipefail + + # set permissions + umask 077 + + # create .env file + install -T -m 0600 -o ${user} ${snipeITEnv} "${cfg.dataDir}/.env" + + # replace secrets + ${secretReplacements} + + # prepend `base64:` if it does not exist in APP_KEY + if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then + sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env" + fi + + # purge cache + rm "${cfg.dataDir}"/bootstrap/cache/*.php || true + + # migrate db + ${pkgs.php}/bin/php artisan migrate --force + + # A placeholder file for invalid barcodes + invalid_barcode_location="${cfg.dataDir}/public/uploads/barcodes/invalid_barcode.gif" + if [ ! -e "$invalid_barcode_location" ]; then + cp ${snipe-it}/share/snipe-it/invalid_barcode.gif "$invalid_barcode_location" + fi + ''; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0710 ${user} ${group} - -" + "d ${cfg.dataDir}/bootstrap 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/bootstrap/cache 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/accessories 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/assets 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/avatars 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/barcodes 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/categories 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/companies 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/components 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/consumables 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/departments 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/locations 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/manufacturers 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/models 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads/suppliers 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} - -" + "d ${cfg.dataDir}/storage/private_uploads 0700 ${user} ${group} - -" + ]; + + users = { + users = mkIf (user == "snipeit") { + snipeit = { + inherit group; + isSystemUser = true; + }; + "${config.services.nginx.user}".extraGroups = [ group ]; + }; + groups = mkIf (group == "snipeit") { + snipeit = {}; + }; + }; + + }; + + meta.maintainers = with maintainers; [ yayayayaka ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/sogo.nix b/nixpkgs/nixos/modules/services/web-apps/sogo.nix new file mode 100644 index 000000000000..5e5d9472829d --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/sogo.nix @@ -0,0 +1,271 @@ +{ config, pkgs, lib, ... }: with lib; let + cfg = config.services.sogo; + + preStart = pkgs.writeShellScriptBin "sogo-prestart" '' + touch /etc/sogo/sogo.conf + chown sogo:sogo /etc/sogo/sogo.conf + chmod 640 /etc/sogo/sogo.conf + + ${if (cfg.configReplaces != {}) then '' + # Insert secrets + ${concatStringsSep "\n" (mapAttrsToList (k: v: ''export ${k}="$(cat "${v}" | tr -d '\n')"'') cfg.configReplaces)} + + ${pkgs.perl}/bin/perl -p ${concatStringsSep " " (mapAttrsToList (k: v: '' -e 's/${k}/''${ENV{"${k}"}}/g;' '') cfg.configReplaces)} /etc/sogo/sogo.conf.raw > /etc/sogo/sogo.conf + '' else '' + cp /etc/sogo/sogo.conf.raw /etc/sogo/sogo.conf + ''} + ''; + +in { + options.services.sogo = with types; { + enable = mkEnableOption (lib.mdDoc "SOGo groupware"); + + vhostName = mkOption { + description = lib.mdDoc "Name of the nginx vhost"; + type = str; + default = "sogo"; + }; + + timezone = mkOption { + description = lib.mdDoc "Timezone of your SOGo instance"; + type = str; + example = "America/Montreal"; + }; + + language = mkOption { + description = lib.mdDoc "Language of SOGo"; + type = str; + default = "English"; + }; + + ealarmsCredFile = mkOption { + description = lib.mdDoc "Optional path to a credentials file for email alarms"; + type = nullOr str; + default = null; + }; + + configReplaces = mkOption { + description = lib.mdDoc '' + Replacement-filepath mapping for sogo.conf. + Every key is replaced with the contents of the file specified as value. + + In the example, every occurrence of LDAP_BINDPW will be replaced with the text of the + specified file. + ''; + type = attrsOf str; + default = {}; + example = { + LDAP_BINDPW = "/var/lib/secrets/sogo/ldappw"; + }; + }; + + extraConfig = mkOption { + description = lib.mdDoc "Extra sogo.conf configuration lines"; + type = lines; + default = ""; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ pkgs.sogo ]; + + environment.etc."sogo/sogo.conf.raw".text = '' + { + // Mandatory parameters + SOGoTimeZone = "${cfg.timezone}"; + SOGoLanguage = "${cfg.language}"; + // Paths + WOSendMail = "/run/wrappers/bin/sendmail"; + SOGoMailSpoolPath = "/var/lib/sogo/spool"; + // Enable CSRF protection + SOGoXSRFValidationEnabled = YES; + // Remove dates from log (jornald does that) + NGLogDefaultLogEventFormatterClass = "NGLogEventFormatter"; + // Extra config + ${cfg.extraConfig} + } + ''; + + systemd.services.sogo = { + description = "SOGo groupware"; + after = [ "postgresql.service" "mysql.service" "memcached.service" "openldap.service" "dovecot2.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ config.environment.etc."sogo/sogo.conf.raw".source ]; + + environment.LDAPTLS_CACERT = "/etc/ssl/certs/ca-certificates.crt"; + + serviceConfig = { + Type = "forking"; + ExecStartPre = "+" + preStart + "/bin/sogo-prestart"; + ExecStart = "${pkgs.sogo}/bin/sogod -WOLogFile - -WOPidFile /run/sogo/sogo.pid"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RuntimeDirectory = "sogo"; + StateDirectory = "sogo/spool"; + + User = "sogo"; + Group = "sogo"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + LockPersonality = true; + RestrictRealtime = true; + PrivateMounts = true; + PrivateUsers = true; + MemoryDenyWriteExecute = true; + SystemCallFilter = "@basic-io @file-system @network-io @system-service @timer"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6"; + }; + }; + + systemd.services.sogo-tmpwatch = { + description = "SOGo tmpwatch"; + + startAt = [ "hourly" ]; + script = '' + SOGOSPOOL=/var/lib/sogo/spool + + find "$SOGOSPOOL" -type f -user sogo -atime +23 -delete > /dev/null + find "$SOGOSPOOL" -mindepth 1 -type d -user sogo -empty -delete > /dev/null + ''; + + serviceConfig = { + Type = "oneshot"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + StateDirectory = "sogo/spool"; + + User = "sogo"; + Group = "sogo"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + LockPersonality = true; + RestrictRealtime = true; + PrivateMounts = true; + PrivateUsers = true; + PrivateNetwork = true; + SystemCallFilter = "@basic-io @file-system @system-service"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = ""; + }; + }; + + systemd.services.sogo-ealarms = { + description = "SOGo email alarms"; + + after = [ "postgresql.service" "mysqld.service" "memcached.service" "openldap.service" "dovecot2.service" "sogo.service" ]; + restartTriggers = [ config.environment.etc."sogo/sogo.conf.raw".source ]; + + startAt = [ "minutely" ]; + + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.sogo}/bin/sogo-ealarms-notify${optionalString (cfg.ealarmsCredFile != null) " -p ${cfg.ealarmsCredFile}"}"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + StateDirectory = "sogo/spool"; + + User = "sogo"; + Group = "sogo"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + LockPersonality = true; + RestrictRealtime = true; + PrivateMounts = true; + PrivateUsers = true; + MemoryDenyWriteExecute = true; + SystemCallFilter = "@basic-io @file-system @network-io @system-service"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6"; + }; + }; + + # nginx vhost + services.nginx.virtualHosts."${cfg.vhostName}" = { + locations."/".extraConfig = '' + rewrite ^ https://$server_name/SOGo; + allow all; + ''; + + # For iOS 7 + locations."/principals/".extraConfig = '' + rewrite ^ https://$server_name/SOGo/dav; + allow all; + ''; + + locations."^~/SOGo".extraConfig = '' + proxy_pass http://127.0.0.1:20000; + proxy_redirect http://127.0.0.1:20000 default; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header x-webobjects-server-protocol HTTP/1.0; + proxy_set_header x-webobjects-remote-host 127.0.0.1; + proxy_set_header x-webobjects-server-port $server_port; + proxy_set_header x-webobjects-server-name $server_name; + proxy_set_header x-webobjects-server-url $scheme://$host; + proxy_connect_timeout 90; + proxy_send_timeout 90; + proxy_read_timeout 90; + proxy_buffer_size 4k; + proxy_buffers 4 32k; + proxy_busy_buffers_size 64k; + proxy_temp_file_write_size 64k; + client_max_body_size 50m; + client_body_buffer_size 128k; + break; + ''; + + locations."/SOGo.woa/WebServerResources/".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/WebServerResources/; + allow all; + ''; + + locations."/SOGo/WebServerResources/".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/WebServerResources/; + allow all; + ''; + + locations."~ ^/SOGo/so/ControlPanel/Products/([^/]*)/Resources/(.*)$".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/$1.SOGo/Resources/$2; + ''; + + locations."~ ^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\\.(jpg|png|gif|css|js)$".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/$1.SOGo/Resources/$2; + ''; + }; + + # User and group + users.groups.sogo = {}; + users.users.sogo = { + group = "sogo"; + isSystemUser = true; + description = "SOGo service user"; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/trilium.nix b/nixpkgs/nixos/modules/services/web-apps/trilium.nix new file mode 100644 index 000000000000..a91d64f620b6 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/trilium.nix @@ -0,0 +1,155 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.trilium-server; + configIni = pkgs.writeText "trilium-config.ini" '' + [General] + # Instance name can be used to distinguish between different instances + instanceName=${cfg.instanceName} + + # Disable automatically generating desktop icon + noDesktopIcon=true + noBackup=${lib.boolToString cfg.noBackup} + noAuthentication=${lib.boolToString cfg.noAuthentication} + + [Network] + # host setting is relevant only for web deployments - set the host on which the server will listen + host=${cfg.host} + # port setting is relevant only for web deployments, desktop builds run on random free port + port=${toString cfg.port} + # true for TLS/SSL/HTTPS (secure), false for HTTP (unsecure). + https=false + ''; +in +{ + + options.services.trilium-server = with lib; { + enable = mkEnableOption (lib.mdDoc "trilium-server"); + + dataDir = mkOption { + type = types.str; + default = "/var/lib/trilium"; + description = lib.mdDoc '' + The directory storing the notes database and the configuration. + ''; + }; + + instanceName = mkOption { + type = types.str; + default = "Trilium"; + description = lib.mdDoc '' + Instance name used to distinguish between different instances + ''; + }; + + noBackup = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Disable periodic database backups. + ''; + }; + + noAuthentication = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + If set to true, no password is required to access the web frontend. + ''; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = lib.mdDoc '' + The host address to bind to (defaults to localhost). + ''; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = lib.mdDoc '' + The port number to bind to. + ''; + }; + + nginx = mkOption { + default = {}; + description = lib.mdDoc '' + Configuration for nginx reverse proxy. + ''; + + type = types.submodule { + options = { + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Configure the nginx reverse proxy settings. + ''; + }; + + hostName = mkOption { + type = types.str; + description = lib.mdDoc '' + The hostname use to setup the virtualhost configuration + ''; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + meta.maintainers = with lib.maintainers; [ fliegendewurst ]; + + users.groups.trilium = {}; + users.users.trilium = { + description = "Trilium User"; + group = "trilium"; + home = cfg.dataDir; + isSystemUser = true; + }; + + systemd.services.trilium-server = { + wantedBy = [ "multi-user.target" ]; + environment.TRILIUM_DATA_DIR = cfg.dataDir; + serviceConfig = { + ExecStart = "${pkgs.trilium-server}/bin/trilium-server"; + User = "trilium"; + Group = "trilium"; + PrivateTmp = "true"; + }; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 trilium trilium - -" + "L+ ${cfg.dataDir}/config.ini - - - - ${configIni}" + ]; + + } + + (lib.mkIf cfg.nginx.enable { + services.nginx = { + enable = true; + virtualHosts."${cfg.nginx.hostName}" = { + locations."/" = { + proxyPass = "http://${cfg.host}:${toString cfg.port}/"; + extraConfig = '' + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + ''; + }; + extraConfig = '' + client_max_body_size 0; + ''; + }; + }; + }) + ]); +} diff --git a/nixpkgs/nixos/modules/services/web-apps/tt-rss.nix b/nixpkgs/nixos/modules/services/web-apps/tt-rss.nix new file mode 100644 index 000000000000..3102e6a46953 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/tt-rss.nix @@ -0,0 +1,687 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.tt-rss; + + configVersion = 26; + + dbPort = if cfg.database.port == null + then (if cfg.database.type == "pgsql" then 5432 else 3306) + else cfg.database.port; + + poolName = "tt-rss"; + + mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql"; + + tt-rss-config = let + password = + if (cfg.database.password != null) then + "'${(escape ["'" "\\"] cfg.database.password)}'" + else if (cfg.database.passwordFile != null) then + "file_get_contents('${cfg.database.passwordFile}')" + else + null + ; + in pkgs.writeText "config.php" '' + <?php + putenv('TTRSS_PHP_EXECUTABLE=${pkgs.php}/bin/php'); + + putenv('TTRSS_LOCK_DIRECTORY=${cfg.root}/lock'); + putenv('TTRSS_CACHE_DIR=${cfg.root}/cache'); + putenv('TTRSS_ICONS_DIR=${cfg.root}/feed-icons'); + putenv('TTRSS_ICONS_URL=feed-icons'); + putenv('TTRSS_SELF_URL_PATH=${cfg.selfUrlPath}'); + + putenv('TTRSS_MYSQL_CHARSET=UTF8'); + + putenv('TTRSS_DB_TYPE=${cfg.database.type}'); + putenv('TTRSS_DB_HOST=${optionalString (cfg.database.host != null) cfg.database.host}'); + putenv('TTRSS_DB_USER=${cfg.database.user}'); + putenv('TTRSS_DB_NAME=${cfg.database.name}'); + putenv('TTRSS_DB_PASS=' ${optionalString (password != null) ". ${password}"}); + putenv('TTRSS_DB_PORT=${toString dbPort}'); + + putenv('TTRSS_AUTH_AUTO_CREATE=${boolToString cfg.auth.autoCreate}'); + putenv('TTRSS_AUTH_AUTO_LOGIN=${boolToString cfg.auth.autoLogin}'); + + putenv('TTRSS_FEED_CRYPT_KEY=${escape ["'" "\\"] cfg.feedCryptKey}'); + + + putenv('TTRSS_SINGLE_USER_MODE=${boolToString cfg.singleUserMode}'); + + putenv('TTRSS_SIMPLE_UPDATE_MODE=${boolToString cfg.simpleUpdateMode}'); + + # Never check for updates - the running version of the code should + # be controlled entirely by the version of TT-RSS active in the + # current Nix profile. If TT-RSS updates itself to a version + # requiring a database schema upgrade, and then the SystemD + # tt-rss.service is restarted, the old code copied from the Nix + # store will overwrite the updated version, causing the code to + # detect the need for a schema "upgrade" (since the schema version + # in the database is different than in the code), but the update + # schema operation in TT-RSS will do nothing because the schema + # version in the database is newer than that in the code. + putenv('TTRSS_CHECK_FOR_UPDATES=false'); + + putenv('TTRSS_FORCE_ARTICLE_PURGE=${toString cfg.forceArticlePurge}'); + putenv('TTRSS_SESSION_COOKIE_LIFETIME=${toString cfg.sessionCookieLifetime}'); + putenv('TTRSS_ENABLE_GZIP_OUTPUT=${boolToString cfg.enableGZipOutput}'); + + putenv('TTRSS_PLUGINS=${builtins.concatStringsSep "," cfg.plugins}'); + + putenv('TTRSS_LOG_DESTINATION=${cfg.logDestination}'); + putenv('TTRSS_CONFIG_VERSION=${toString configVersion}'); + + + putenv('TTRSS_PUBSUBHUBBUB_ENABLED=${boolToString cfg.pubSubHubbub.enable}'); + putenv('TTRSS_PUBSUBHUBBUB_HUB=${cfg.pubSubHubbub.hub}'); + + putenv('TTRSS_SPHINX_SERVER=${cfg.sphinx.server}'); + putenv('TTRSS_SPHINX_INDEX=${builtins.concatStringsSep "," cfg.sphinx.index}'); + + putenv('TTRSS_ENABLE_REGISTRATION=${boolToString cfg.registration.enable}'); + putenv('TTRSS_REG_NOTIFY_ADDRESS=${cfg.registration.notifyAddress}'); + putenv('TTRSS_REG_MAX_USERS=${toString cfg.registration.maxUsers}'); + + putenv('TTRSS_SMTP_SERVER=${cfg.email.server}'); + putenv('TTRSS_SMTP_LOGIN=${cfg.email.login}'); + putenv('TTRSS_SMTP_PASSWORD=${escape ["'" "\\"] cfg.email.password}'); + putenv('TTRSS_SMTP_SECURE=${cfg.email.security}'); + + putenv('TTRSS_SMTP_FROM_NAME=${escape ["'" "\\"] cfg.email.fromName}'); + putenv('TTRSS_SMTP_FROM_ADDRESS=${escape ["'" "\\"] cfg.email.fromAddress}'); + putenv('TTRSS_DIGEST_SUBJECT=${escape ["'" "\\"] cfg.email.digestSubject}'); + + ${cfg.extraConfig} + ''; + + # tt-rss and plugins and themes and config.php + servedRoot = pkgs.runCommand "tt-rss-served-root" {} '' + cp --no-preserve=mode -r ${pkgs.tt-rss} $out + cp ${tt-rss-config} $out/config.php + ${optionalString (cfg.pluginPackages != []) '' + for plugin in ${concatStringsSep " " cfg.pluginPackages}; do + cp -r "$plugin"/* "$out/plugins.local/" + done + ''} + ${optionalString (cfg.themePackages != []) '' + for theme in ${concatStringsSep " " cfg.themePackages}; do + cp -r "$theme"/* "$out/themes.local/" + done + ''} + ''; + + in { + + ###### interface + + options = { + + services.tt-rss = { + + enable = mkEnableOption (lib.mdDoc "tt-rss"); + + root = mkOption { + type = types.path; + default = "/var/lib/tt-rss"; + description = lib.mdDoc '' + Root of the application. + ''; + }; + + user = mkOption { + type = types.str; + default = "tt_rss"; + description = lib.mdDoc '' + User account under which both the update daemon and the web-application run. + ''; + }; + + pool = mkOption { + type = types.str; + default = "${poolName}"; + description = lib.mdDoc '' + 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 = "tt-rss"; + description = lib.mdDoc '' + Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost. + ''; + }; + + database = { + type = mkOption { + type = types.enum ["pgsql" "mysql"]; + default = "pgsql"; + description = lib.mdDoc '' + Database to store feeds. Supported are pgsql and mysql. + ''; + }; + + host = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + Host of the database. Leave null to use Unix domain socket. + ''; + }; + + name = mkOption { + type = types.str; + default = "tt_rss"; + description = lib.mdDoc '' + Name of the existing database. + ''; + }; + + user = mkOption { + type = types.str; + default = "tt_rss"; + description = lib.mdDoc '' + The database user. The user must exist and has access to + the specified database. + ''; + }; + + password = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The database user's password. + ''; + }; + + passwordFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc '' + The database user's password. + ''; + }; + + port = mkOption { + type = types.nullOr types.port; + default = null; + description = lib.mdDoc '' + The database's port. If not set, the default ports will be provided (5432 + and 3306 for pgsql and mysql respectively). + ''; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + auth = { + autoCreate = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Allow authentication modules to auto-create users in tt-rss internal + database when authenticated successfully. + ''; + }; + + autoLogin = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Automatically login user on remote or other kind of externally supplied + authentication, otherwise redirect to login form as normal. + If set to true, users won't be able to set application language + and settings profile. + ''; + }; + }; + + pubSubHubbub = { + hub = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + URL to a PubSubHubbub-compatible hub server. If defined, "Published + articles" generated feed would automatically become PUSH-enabled. + ''; + }; + + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable client PubSubHubbub support in tt-rss. When disabled, tt-rss + won't try to subscribe to PUSH feed updates. + ''; + }; + }; + + sphinx = { + server = mkOption { + type = types.str; + default = "localhost:9312"; + description = lib.mdDoc '' + Hostname:port combination for the Sphinx server. + ''; + }; + + index = mkOption { + type = types.listOf types.str; + default = ["ttrss" "delta"]; + description = lib.mdDoc '' + Index names in Sphinx configuration. Example configuration + files are available on tt-rss wiki. + ''; + }; + }; + + registration = { + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Allow users to register themselves. Please be aware that allowing + random people to access your tt-rss installation is a security risk + and potentially might lead to data loss or server exploit. Disabled + by default. + ''; + }; + + notifyAddress = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Email address to send new user notifications to. + ''; + }; + + maxUsers = mkOption { + type = types.int; + default = 0; + description = lib.mdDoc '' + Maximum amount of users which will be allowed to register on this + system. 0 - no limit. + ''; + }; + }; + + email = { + server = mkOption { + type = types.str; + default = ""; + example = "localhost:25"; + description = lib.mdDoc '' + Hostname:port combination to send outgoing mail. Blank - use system + MTA. + ''; + }; + + login = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + SMTP authentication login used when sending outgoing mail. + ''; + }; + + password = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + SMTP authentication password used when sending outgoing mail. + ''; + }; + + security = mkOption { + type = types.enum ["" "ssl" "tls"]; + default = ""; + description = lib.mdDoc '' + Used to select a secure SMTP connection. Allowed values: ssl, tls, + or empty. + ''; + }; + + fromName = mkOption { + type = types.str; + default = "Tiny Tiny RSS"; + description = lib.mdDoc '' + Name for sending outgoing mail. This applies to password reset + notifications, digest emails and any other mail. + ''; + }; + + fromAddress = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Address for sending outgoing mail. This applies to password reset + notifications, digest emails and any other mail. + ''; + }; + + digestSubject = mkOption { + type = types.str; + default = "[tt-rss] New headlines for last 24 hours"; + description = lib.mdDoc '' + Subject line for email digests. + ''; + }; + }; + + sessionCookieLifetime = mkOption { + type = types.int; + default = 86400; + description = lib.mdDoc '' + Default lifetime of a session (e.g. login) cookie. In seconds, + 0 means cookie will be deleted when browser closes. + ''; + }; + + selfUrlPath = mkOption { + type = types.str; + description = lib.mdDoc '' + Full URL of your tt-rss installation. This should be set to the + location of tt-rss directory, e.g. http://example.org/tt-rss/ + You need to set this option correctly otherwise several features + including PUSH, bookmarklets and browser integration will not work properly. + ''; + example = "http://localhost"; + }; + + feedCryptKey = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc '' + Key used for encryption of passwords for password-protected feeds + in the database. A string of 24 random characters. If left blank, encryption + is not used. Requires mcrypt functions. + Warning: changing this key will make your stored feed passwords impossible + to decrypt. + ''; + }; + + singleUserMode = mkOption { + type = types.bool; + default = false; + + description = lib.mdDoc '' + Operate in single user mode, disables all functionality related to + multiple users and authentication. Enabling this assumes you have + your tt-rss directory protected by other means (e.g. http auth). + ''; + }; + + simpleUpdateMode = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enables fallback update mode where tt-rss tries to update feeds in + background while tt-rss is open in your browser. + If you don't have a lot of feeds and don't want to or can't run + background processes while not running tt-rss, this method is generally + viable to keep your feeds up to date. + Still, there are more robust (and recommended) updating methods + available, you can read about them here: http://tt-rss.org/wiki/UpdatingFeeds + ''; + }; + + forceArticlePurge = mkOption { + type = types.int; + default = 0; + description = lib.mdDoc '' + When this option is not 0, users ability to control feed purging + intervals is disabled and all articles (which are not starred) + older than this amount of days are purged. + ''; + }; + + enableGZipOutput = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Selectively gzip output to improve wire performance. This requires + PHP Zlib extension on the server. + Enabling this can break tt-rss in several httpd/php configurations, + if you experience weird errors and tt-rss failing to start, blank pages + after login, or content encoding errors, disable it. + ''; + }; + + plugins = mkOption { + type = types.listOf types.str; + default = ["auth_internal" "note"]; + description = lib.mdDoc '' + List of plugins to load automatically for all users. + System plugins have to be specified here. Please enable at least one + authentication plugin here (auth_*). + Users may enable other user plugins from Preferences/Plugins but may not + disable plugins specified in this list. + Disabling auth_internal in this list would automatically disable + reset password link on the login form. + ''; + }; + + pluginPackages = mkOption { + type = types.listOf types.package; + default = []; + description = lib.mdDoc '' + List of plugins to install. The list elements are expected to + be derivations. All elements in this derivation are automatically + copied to the `plugins.local` directory. + ''; + }; + + themePackages = mkOption { + type = types.listOf types.package; + default = []; + description = lib.mdDoc '' + List of themes to install. The list elements are expected to + be derivations. All elements in this derivation are automatically + copied to the `themes.local` directory. + ''; + }; + + logDestination = mkOption { + type = types.enum ["" "sql" "syslog"]; + default = "sql"; + description = lib.mdDoc '' + Log destination to use. Possible values: sql (uses internal logging + you can read in Preferences -> System), syslog - logs to system log. + Setting this to blank uses PHP logging (usually to http server + error.log). + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Additional lines to append to `config.php`. + ''; + }; + }; + }; + + imports = [ + (mkRemovedOptionModule ["services" "tt-rss" "checkForUpdates"] '' + This option was removed because setting this to true will cause TT-RSS + to be unable to start if an automatic update of the code in + services.tt-rss.root leads to a database schema upgrade that is not + supported by the code active in the Nix store. + '') + ]; + + ###### implementation + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = cfg.database.password != null -> cfg.database.passwordFile == null; + message = "Cannot set both password and passwordFile"; + } + ]; + + services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") { + ${poolName} = { + inherit (cfg) user; + phpPackage = pkgs.php81; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "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; + }; + }; + }; + + # NOTE: No configuration is done if not using virtual host + services.nginx = mkIf (cfg.virtualHost != null) { + enable = true; + virtualHosts = { + ${cfg.virtualHost} = { + root = "${cfg.root}/www"; + + locations."/" = { + index = "index.php"; + }; + + locations."^~ /feed-icons" = { + root = "${cfg.root}"; + }; + + locations."~ \\.php$" = { + extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket}; + fastcgi_index index.php; + ''; + }; + }; + }; + }; + + systemd.tmpfiles.rules = [ + "d '${cfg.root}' 0555 ${cfg.user} tt_rss - -" + "d '${cfg.root}/lock' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache/upload' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache/images' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache/export' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/feed-icons' 0755 ${cfg.user} tt_rss - -" + "L+ '${cfg.root}/www' - - - - ${servedRoot}" + ]; + + systemd.services = { + phpfpm-tt-rss = mkIf (cfg.pool == "${poolName}") { + restartTriggers = [ servedRoot ]; + }; + + tt-rss = { + description = "Tiny Tiny RSS feeds update daemon"; + + preStart = let + callSql = e: + if cfg.database.type == "pgsql" then '' + ${optionalString (cfg.database.password != null) "PGPASSWORD=${cfg.database.password}"} \ + ${optionalString (cfg.database.passwordFile != null) "PGPASSWORD=$(cat ${cfg.database.passwordFile})"} \ + ${config.services.postgresql.package}/bin/psql \ + -U ${cfg.database.user} \ + ${optionalString (cfg.database.host != null) "-h ${cfg.database.host} --port ${toString dbPort}"} \ + -c '${e}' \ + ${cfg.database.name}'' + + else if cfg.database.type == "mysql" then '' + echo '${e}' | ${config.services.mysql.package}/bin/mysql \ + -u ${cfg.database.user} \ + ${optionalString (cfg.database.password != null) "-p${cfg.database.password}"} \ + ${optionalString (cfg.database.host != null) "-h ${cfg.database.host} -P ${toString dbPort}"} \ + ${cfg.database.name}'' + + else ""; + + in (optionalString (cfg.database.type == "pgsql") '' + exists=$(${callSql "select count(*) > 0 from pg_tables where tableowner = user"} \ + | tail -n+3 | head -n-2 | sed -e 's/[ \n\t]*//') + + if [ "$exists" == 'f' ]; then + ${callSql "\\i ${pkgs.tt-rss}/schema/ttrss_schema_${cfg.database.type}.sql"} + else + echo 'The database contains some data. Leaving it as it is.' + fi; + '') + + + (optionalString (cfg.database.type == "mysql") '' + exists=$(${callSql "select count(*) > 0 from information_schema.tables where table_schema = schema()"} \ + | tail -n+2 | sed -e 's/[ \n\t]*//') + + if [ "$exists" == '0' ]; then + ${callSql "\\. ${pkgs.tt-rss}/schema/ttrss_schema_${cfg.database.type}.sql"} + else + echo 'The database contains some data. Leaving it as it is.' + fi; + ''); + + serviceConfig = { + User = "${cfg.user}"; + Group = "tt_rss"; + ExecStart = "${pkgs.php}/bin/php ${cfg.root}/www/update.php --daemon --quiet"; + Restart = "on-failure"; + RestartSec = "60"; + SyslogIdentifier = "tt-rss"; + }; + + wantedBy = [ "multi-user.target" ]; + requires = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + after = [ "network.target" ] ++ optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + }; + }; + + services.mysql = mkIf mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.user; + ensurePermissions = { + "${cfg.database.name}.*" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + services.postgresql = mkIf pgsqlLocal { + enable = mkDefault true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.user; + ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; }; + } + ]; + }; + + users.users.tt_rss = optionalAttrs (cfg.user == "tt_rss") { + description = "tt-rss service user"; + isSystemUser = true; + group = "tt_rss"; + }; + + users.groups.tt_rss = {}; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/vikunja.nix b/nixpkgs/nixos/modules/services/web-apps/vikunja.nix new file mode 100644 index 000000000000..8bc8e8c29259 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/vikunja.nix @@ -0,0 +1,151 @@ +{ pkgs, lib, config, ... }: + +with lib; + +let + cfg = config.services.vikunja; + format = pkgs.formats.yaml {}; + configFile = format.generate "config.yaml" cfg.settings; + useMysql = cfg.database.type == "mysql"; + usePostgresql = cfg.database.type == "postgres"; +in { + options.services.vikunja = with lib; { + enable = mkEnableOption (lib.mdDoc "vikunja service"); + package-api = mkOption { + default = pkgs.vikunja-api; + type = types.package; + defaultText = literalExpression "pkgs.vikunja-api"; + description = lib.mdDoc "vikunja-api derivation to use."; + }; + package-frontend = mkOption { + default = pkgs.vikunja-frontend; + type = types.package; + defaultText = literalExpression "pkgs.vikunja-frontend"; + description = lib.mdDoc "vikunja-frontend derivation to use."; + }; + environmentFiles = mkOption { + type = types.listOf types.path; + default = [ ]; + description = lib.mdDoc '' + List of environment files set in the vikunja systemd service. + For example passwords should be set in one of these files. + ''; + }; + setupNginx = mkOption { + type = types.bool; + default = config.services.nginx.enable; + defaultText = literalExpression "config.services.nginx.enable"; + description = lib.mdDoc '' + Whether to setup NGINX. + Further nginx configuration can be done by changing + {option}`services.nginx.virtualHosts.<frontendHostname>`. + This does not enable TLS or ACME by default. To enable this, set the + {option}`services.nginx.virtualHosts.<frontendHostname>.enableACME` to + `true` and if appropriate do the same for + {option}`services.nginx.virtualHosts.<frontendHostname>.forceSSL`. + ''; + }; + frontendScheme = mkOption { + type = types.enum [ "http" "https" ]; + description = lib.mdDoc '' + Whether the site is available via http or https. + This does not configure https or ACME in nginx! + ''; + }; + frontendHostname = mkOption { + type = types.str; + description = lib.mdDoc "The Hostname under which the frontend is running."; + }; + port = mkOption { + type = types.port; + default = 3456; + description = lib.mdDoc "The TCP port exposed by the API."; + }; + + settings = mkOption { + type = format.type; + default = {}; + description = lib.mdDoc '' + Vikunja configuration. Refer to + <https://vikunja.io/docs/config-options/> + for details on supported values. + ''; + }; + database = { + type = mkOption { + type = types.enum [ "sqlite" "mysql" "postgres" ]; + example = "postgres"; + default = "sqlite"; + description = lib.mdDoc "Database engine to use."; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address. Can also be a socket."; + }; + user = mkOption { + type = types.str; + default = "vikunja"; + description = lib.mdDoc "Database user."; + }; + database = mkOption { + type = types.str; + default = "vikunja"; + description = lib.mdDoc "Database name."; + }; + path = mkOption { + type = types.str; + default = "/var/lib/vikunja/vikunja.db"; + description = lib.mdDoc "Path to the sqlite3 database file."; + }; + }; + }; + config = lib.mkIf cfg.enable { + services.vikunja.settings = { + database = { + inherit (cfg.database) type host user database path; + }; + service = { + interface = ":${toString cfg.port}"; + frontendurl = "${cfg.frontendScheme}://${cfg.frontendHostname}/"; + }; + files = { + basepath = "/var/lib/vikunja/files"; + }; + }; + + systemd.services.vikunja-api = { + description = "vikunja-api"; + after = [ "network.target" ] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service"; + wantedBy = [ "multi-user.target" ]; + path = [ cfg.package-api ]; + restartTriggers = [ configFile ]; + + serviceConfig = { + Type = "simple"; + DynamicUser = true; + StateDirectory = "vikunja"; + ExecStart = "${cfg.package-api}/bin/vikunja"; + Restart = "always"; + EnvironmentFile = cfg.environmentFiles; + }; + }; + + services.nginx.virtualHosts."${cfg.frontendHostname}" = mkIf cfg.setupNginx { + locations = { + "/" = { + root = cfg.package-frontend; + tryFiles = "try_files $uri $uri/ /"; + }; + "~* ^/(api|dav|\\.well-known)/" = { + proxyPass = "http://localhost:${toString cfg.port}"; + extraConfig = '' + client_max_body_size 20M; + ''; + }; + }; + }; + + environment.etc."vikunja/config.yaml".source = configFile; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/whitebophir.nix b/nixpkgs/nixos/modules/services/web-apps/whitebophir.nix new file mode 100644 index 000000000000..b673a7c1179e --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/whitebophir.nix @@ -0,0 +1,52 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.whitebophir; +in { + options = { + services.whitebophir = { + enable = mkEnableOption (lib.mdDoc "whitebophir, an online collaborative whiteboard server (persistent state will be maintained under {file}`/var/lib/whitebophir`)"); + + package = mkOption { + default = pkgs.whitebophir; + defaultText = literalExpression "pkgs.whitebophir"; + type = types.package; + description = lib.mdDoc "Whitebophir package to use."; + }; + + listenAddress = mkOption { + type = types.str; + default = "0.0.0.0"; + description = lib.mdDoc "Address to listen on (use 0.0.0.0 to allow access from any address)."; + }; + + port = mkOption { + type = types.port; + default = 5001; + description = lib.mdDoc "Port to bind to."; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.whitebophir = { + description = "Whitebophir Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + environment = { + PORT = toString cfg.port; + HOST = toString cfg.listenAddress; + WBO_HISTORY_DIR = "/var/lib/whitebophir"; + }; + + serviceConfig = { + DynamicUser = true; + ExecStart = "${cfg.package}/bin/whitebophir"; + Restart = "always"; + StateDirectory = "whitebophir"; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/wiki-js.nix b/nixpkgs/nixos/modules/services/web-apps/wiki-js.nix new file mode 100644 index 000000000000..631740f51ce3 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/wiki-js.nix @@ -0,0 +1,142 @@ +{ lib, pkgs, config, ... }: + +with lib; + +let + cfg = config.services.wiki-js; + + format = pkgs.formats.json { }; + + configFile = format.generate "wiki-js.yml" cfg.settings; +in { + options.services.wiki-js = { + enable = mkEnableOption (lib.mdDoc "wiki-js"); + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/root/wiki-js.env"; + description = lib.mdDoc '' + Environment file to inject e.g. secrets into the configuration. + ''; + }; + + stateDirectoryName = mkOption { + default = "wiki-js"; + type = types.str; + description = lib.mdDoc '' + Name of the directory in {file}`/var/lib`. + ''; + }; + + settings = mkOption { + default = {}; + type = types.submodule { + freeformType = format.type; + options = { + port = mkOption { + type = types.port; + default = 3000; + description = lib.mdDoc '' + TCP port the process should listen to. + ''; + }; + + bindIP = mkOption { + default = "0.0.0.0"; + type = types.str; + description = lib.mdDoc '' + IPs the service should listen to. + ''; + }; + + db = { + type = mkOption { + default = "postgres"; + type = types.enum [ "postgres" "mysql" "mariadb" "mssql" ]; + description = lib.mdDoc '' + Database driver to use for persistence. Please note that `sqlite` + is currently not supported as the build process for it is currently not implemented + in `pkgs.wiki-js` and it's not recommended by upstream for + production use. + ''; + }; + host = mkOption { + type = types.str; + example = "/run/postgresql"; + description = lib.mdDoc '' + Hostname or socket-path to connect to. + ''; + }; + db = mkOption { + default = "wiki"; + type = types.str; + description = lib.mdDoc '' + Name of the database to use. + ''; + }; + }; + + logLevel = mkOption { + default = "info"; + type = types.enum [ "error" "warn" "info" "verbose" "debug" "silly" ]; + description = lib.mdDoc '' + Define how much detail is supposed to be logged at runtime. + ''; + }; + + offline = mkEnableOption (lib.mdDoc "offline mode") // { + description = lib.mdDoc '' + Disable latest file updates and enable + [sideloading](https://docs.requarks.io/install/sideload). + ''; + }; + }; + }; + description = lib.mdDoc '' + Settings to configure `wiki-js`. This directly + corresponds to [the upstream configuration options](https://docs.requarks.io/install/config). + + Secrets can be injected via the environment by + - specifying [](#opt-services.wiki-js.environmentFile) + to contain secrets + - and setting sensitive values to `$(ENVIRONMENT_VAR)` + with this value defined in the environment-file. + ''; + }; + }; + + config = mkIf cfg.enable { + services.wiki-js.settings.dataPath = "/var/lib/${cfg.stateDirectoryName}"; + systemd.services.wiki-js = { + description = "A modern and powerful wiki app built on Node.js"; + documentation = [ "https://docs.requarks.io/" ]; + wantedBy = [ "multi-user.target" ]; + + path = with pkgs; [ + # Needed for git storage. + git + # Needed for git+ssh storage. + openssh + ]; + + preStart = '' + ln -sf ${configFile} /var/lib/${cfg.stateDirectoryName}/config.yml + ln -sf ${pkgs.wiki-js}/server /var/lib/${cfg.stateDirectoryName} + ln -sf ${pkgs.wiki-js}/assets /var/lib/${cfg.stateDirectoryName} + ln -sf ${pkgs.wiki-js}/package.json /var/lib/${cfg.stateDirectoryName}/package.json + ''; + + serviceConfig = { + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; + StateDirectory = cfg.stateDirectoryName; + WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}"; + DynamicUser = true; + PrivateTmp = true; + ExecStart = "${pkgs.nodejs_18}/bin/node ${pkgs.wiki-js}/server"; + }; + }; + }; + + meta.maintainers = with maintainers; [ ma27 ]; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/wordpress.nix b/nixpkgs/nixos/modules/services/web-apps/wordpress.nix new file mode 100644 index 000000000000..d4c987da1144 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/wordpress.nix @@ -0,0 +1,573 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.wordpress; + eachSite = cfg.sites; + user = "wordpress"; + webserver = config.services.${cfg.webserver}; + stateDir = hostName: "/var/lib/wordpress/${hostName}"; + + pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { + pname = "wordpress-${hostName}"; + version = src.version; + src = cfg.package; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + # symlink the wordpress config + ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php + # symlink uploads directory + ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads + ln -s ${cfg.fontsDir} $out/share/wordpress/wp-content/fonts + + # https://github.com/NixOS/nixpkgs/pull/53399 + # + # Symlinking works for most plugins and themes, but Avada, for instance, fails to + # understand the symlink, causing its file path stripping to fail. This results in + # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js + # Since hard linking directories is not allowed, copying is the next best thing. + + # copy additional plugin(s), theme(s) and language(s) + ${concatStringsSep "\n" (mapAttrsToList (name: theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${name}") cfg.themes)} + ${concatStringsSep "\n" (mapAttrsToList (name: plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${name}") cfg.plugins)} + ${concatMapStringsSep "\n" (language: "cp -r ${language} $out/share/wordpress/wp-content/languages/") cfg.languages} + ''; + }; + + mergeConfig = cfg: { + # wordpress is installed onto a read-only file system + DISALLOW_FILE_EDIT = true; + AUTOMATIC_UPDATER_DISABLED = true; + DB_NAME = cfg.database.name; + DB_HOST = "${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}"; + DB_USER = cfg.database.user; + DB_CHARSET = "utf8"; + # Always set DB_PASSWORD even when passwordFile is not set. This is the + # default Wordpress behaviour. + DB_PASSWORD = if (cfg.database.passwordFile != null) then { _file = cfg.database.passwordFile; } else ""; + } // cfg.settings; + + wpConfig = hostName: cfg: let + conf_gen = c: mapAttrsToList (k: v: "define('${k}', ${mkPhpValue v});") cfg.mergedConfig; + in pkgs.writeTextFile { + name = "wp-config-${hostName}.php"; + text = '' + <?php + $table_prefix = '${cfg.database.tablePrefix}'; + + require_once('${stateDir hostName}/secret-keys.php'); + + ${cfg.extraConfig} + ${concatStringsSep "\n" (conf_gen cfg.mergedConfig)} + + if ( !defined('ABSPATH') ) + define('ABSPATH', dirname(__FILE__) . '/'); + + require_once(ABSPATH . 'wp-settings.php'); + ?> + ''; + checkPhase = "${pkgs.php81}/bin/php --syntax-check $target"; + }; + + mkPhpValue = v: let + isHasAttr = s: isAttrs v && hasAttr s v; + in + if isString v then escapeShellArg v + # NOTE: If any value contains a , (comma) this will not get escaped + else if isList v && any lib.strings.isCoercibleToString v then escapeShellArg (concatMapStringsSep "," toString v) + else if isInt v then toString v + else if isBool v then boolToString v + else if isHasAttr "_file" then "trim(file_get_contents(${lib.escapeShellArg v._file}))" + else if isHasAttr "_raw" then v._raw + else abort "The Wordpress config value ${lib.generators.toPretty {} v} can not be encoded." + ; + + secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ]; + secretsScript = hostStateDir: '' + # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839 + grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php" + if ! test -e "${hostStateDir}/secret-keys.php"; then + umask 0177 + echo "<?php" >> "${hostStateDir}/secret-keys.php" + ${concatMapStringsSep "\n" (var: '' + echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php" + '') secretsVars} + echo "?>" >> "${hostStateDir}/secret-keys.php" + chmod 440 "${hostStateDir}/secret-keys.php" + fi + ''; + + siteOpts = { lib, name, config, ... }: + { + options = { + package = mkOption { + type = types.package; + default = pkgs.wordpress; + defaultText = literalExpression "pkgs.wordpress"; + description = lib.mdDoc "Which WordPress package to use."; + }; + + uploadsDir = mkOption { + type = types.path; + default = "/var/lib/wordpress/${name}/uploads"; + description = lib.mdDoc '' + This directory is used for uploads of pictures. The directory passed here is automatically + created and permissions adjusted as required. + ''; + }; + + fontsDir = mkOption { + type = types.path; + default = "/var/lib/wordpress/${name}/fonts"; + description = lib.mdDoc '' + This directory is used to download fonts from a remote location, e.g. + to host google fonts locally. + ''; + }; + + plugins = mkOption { + type = with types; coercedTo + (listOf path) + (l: warn "setting this option with a list is deprecated" + listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l)) + (attrsOf path); + default = {}; + description = lib.mdDoc '' + Path(s) to respective plugin(s) which are copied from the 'plugins' directory. + + ::: {.note} + These plugins need to be packaged before use, see example. + ::: + ''; + example = literalExpression '' + { + inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin; + } + ''; + }; + + themes = mkOption { + type = with types; coercedTo + (listOf path) + (l: warn "setting this option with a list is deprecated" + listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l)) + (attrsOf path); + default = { inherit (pkgs.wordpressPackages.themes) twentytwentythree; }; + defaultText = literalExpression "{ inherit (pkgs.wordpressPackages.themes) twentytwentythree; }"; + description = lib.mdDoc '' + Path(s) to respective theme(s) which are copied from the 'theme' directory. + + ::: {.note} + These themes need to be packaged before use, see example. + ::: + ''; + example = literalExpression '' + { + inherit (pkgs.wordpressPackages.themes) responsive-theme; + } + ''; + }; + + languages = mkOption { + type = types.listOf types.path; + default = []; + description = lib.mdDoc '' + List of path(s) to respective language(s) which are copied from the 'languages' directory. + ''; + example = literalExpression '' + [( + # Let's package the German language. + # For other languages try to replace language and country code in the download URL with your desired one. + # Reference https://translate.wordpress.org for available translations and + # codes. + language-de = pkgs.stdenv.mkDerivation { + name = "language-de"; + src = pkgs.fetchurl { + url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz"; + # Name is required to invalidate the hash when wordpress is updated + name = "wordpress-''${pkgs.wordpress.version}-language-de" + sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0="; + }; + installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/"; + }; + )]; + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "wordpress"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "wordpress"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/wordpress-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + tablePrefix = mkOption { + type = types.str; + default = "wp_"; + description = lib.mdDoc '' + 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 wp_ for your database + prefix. Typically this is changed if you are installing multiple WordPress blogs + in the same database. + + See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = lib.mdDoc "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Options for the WordPress PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + settings = mkOption { + type = types.attrsOf types.anything; + default = {}; + description = lib.mdDoc '' + Structural Wordpress configuration. + Refer to <https://developer.wordpress.org/apis/wp-config-php> + for details and supported values. + ''; + example = literalExpression '' + { + WP_DEFAULT_THEME = "twentytwentytwo"; + WP_SITEURL = "https://example.org"; + WP_HOME = "https://example.org"; + WP_DEBUG = true; + WP_DEBUG_DISPLAY = true; + WPLANG = "de_DE"; + FORCE_SSL_ADMIN = true; + AUTOMATIC_UPDATER_DISABLED = true; + } + ''; + }; + + mergedConfig = mkOption { + readOnly = true; + default = mergeConfig config; + defaultText = literalExpression '' + { + DISALLOW_FILE_EDIT = true; + AUTOMATIC_UPDATER_DISABLED = true; + } + ''; + description = lib.mdDoc '' + Read only representation of the final configuration. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Any additional text to be appended to the wp-config.php + configuration file. This is a PHP script. For configuration + settings, see <https://codex.wordpress.org/Editing_wp-config.php>. + + **Note**: Please pass structured settings via + `services.wordpress.sites.${name}.settings` instead. + ''; + example = '' + @ini_set( 'log_errors', 'Off' ); + @ini_set( 'display_errors', 'On' ); + ''; + }; + + }; + + config.virtualHost.hostName = mkDefault name; + }; +in +{ + # interface + options = { + services.wordpress = { + + sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = {}; + description = lib.mdDoc "Specification of one or more WordPress sites to serve"; + }; + + webserver = mkOption { + type = types.enum [ "httpd" "nginx" "caddy" ]; + default = "httpd"; + description = lib.mdDoc '' + Whether to use apache2 or nginx 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 apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`. + See [](#opt-services.httpd.virtualHosts) for further information. + ''; + }; + + }; + }; + + # implementation + config = mkIf (eachSite != {}) (mkMerge [{ + + assertions = + (mapAttrsToList (hostName: cfg: + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = ''services.wordpress.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.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.''; + }) eachSite); + + + services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { + enable = true; + package = mkDefault pkgs.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 "wordpress-${hostName}" { + inherit user; + group = webserver.group; + settings = { + "listen.owner" = webserver.user; + "listen.group" = webserver.group; + } // cfg.poolConfig; + } + )) eachSite; + + } + + (mkIf (cfg.webserver == "httpd") { + services.httpd = { + enable = true; + extraModules = [ "proxy_fcgi" ]; + virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${pkg hostName cfg}/share/wordpress"; + extraConfig = '' + <Directory "${pkg hostName cfg}/share/wordpress"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/" + </If> + </FilesMatch> + + # standard wordpress .htaccess contents + <IfModule mod_rewrite.c> + RewriteEngine On + RewriteBase / + RewriteRule ^index\.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.php [L] + </IfModule> + + DirectoryIndex index.php + Require all granted + Options +FollowSymLinks -Indexes + </Directory> + + # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php + <Files wp-config.php> + Require all denied + </Files> + ''; + } ]) eachSite; + }; + }) + + { + systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [ + "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -" + "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" + "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" + "d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -" + "Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -" + ]) eachSite); + + systemd.services = mkMerge [ + (mapAttrs' (hostName: cfg: ( + nameValuePair "wordpress-init-${hostName}" { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-wordpress-${hostName}.service" ]; + after = optional cfg.database.createLocally "mysql.service"; + script = secretsScript (stateDir hostName); + + serviceConfig = { + Type = "oneshot"; + User = user; + Group = webserver.group; + }; + })) eachSite) + + (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { + httpd.after = [ "mysql.service" ]; + }) + ]; + + 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/wordpress"; + extraConfig = '' + index index.php; + ''; + locations = { + "/" = { + priority = 200; + extraConfig = '' + try_files $uri $uri/ /index.php$is_args$args; + ''; + }; + "~ \\.php$" = { + priority = 500; + extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${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; + ''; + }; + "~ /\\." = { + priority = 800; + extraConfig = "deny all;"; + }; + "~* /(?:uploads|files)/.*\\.php$" = { + priority = 900; + extraConfig = "deny all;"; + }; + "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = { + priority = 1000; + extraConfig = '' + expires max; + log_not_found off; + ''; + }; + }; + }) eachSite; + }; + }) + + (mkIf (cfg.webserver == "caddy") { + services.caddy = { + enable = true; + virtualHosts = mapAttrs' (hostName: cfg: ( + nameValuePair "http://${hostName}" { + extraConfig = '' + root * /${pkg hostName cfg}/share/wordpress + file_server + + php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket} + + @uploads { + path_regexp path /uploads\/(.*)\.php + } + rewrite @uploads / + + @wp-admin { + path not ^\/wp-admin/* + } + rewrite @wp-admin {path}/index.php?{query} + ''; + } + )) eachSite; + }; + }) + + + ]); +} diff --git a/nixpkgs/nixos/modules/services/web-apps/writefreely.nix b/nixpkgs/nixos/modules/services/web-apps/writefreely.nix new file mode 100644 index 000000000000..a7671aa717f4 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/writefreely.nix @@ -0,0 +1,484 @@ +{ config, lib, pkgs, ... }: + +let + inherit (builtins) toString; + inherit (lib) types mkIf mkOption mkDefault; + inherit (lib) optional optionals optionalAttrs optionalString; + + inherit (pkgs) sqlite; + + format = pkgs.formats.ini { + mkKeyValue = key: value: + let + value' = lib.optionalString (value != null) + (if builtins.isBool value then + if value == true then "true" else "false" + else + toString value); + in "${key} = ${value'}"; + }; + + cfg = config.services.writefreely; + + isSqlite = cfg.database.type == "sqlite3"; + isMysql = cfg.database.type == "mysql"; + isMysqlLocal = isMysql && cfg.database.createLocally == true; + + hostProtocol = if cfg.acme.enable then "https" else "http"; + + settings = cfg.settings // { + app = cfg.settings.app or { } // { + host = cfg.settings.app.host or "${hostProtocol}://${cfg.host}"; + }; + + database = if cfg.database.type == "sqlite3" then { + type = "sqlite3"; + filename = cfg.settings.database.filename or "writefreely.db"; + database = cfg.database.name; + } else { + type = "mysql"; + username = cfg.database.user; + password = "#dbpass#"; + database = cfg.database.name; + host = cfg.database.host; + port = cfg.database.port; + tls = cfg.database.tls; + }; + + server = cfg.settings.server or { } // { + bind = cfg.settings.server.bind or "localhost"; + gopher_port = cfg.settings.server.gopher_port or 0; + autocert = !cfg.nginx.enable && cfg.acme.enable; + templates_parent_dir = + cfg.settings.server.templates_parent_dir or cfg.package.src; + static_parent_dir = cfg.settings.server.static_parent_dir or assets; + pages_parent_dir = + cfg.settings.server.pages_parent_dir or cfg.package.src; + keys_parent_dir = cfg.settings.server.keys_parent_dir or cfg.stateDir; + }; + }; + + configFile = format.generate "config.ini" settings; + + assets = pkgs.stdenvNoCC.mkDerivation { + pname = "writefreely-assets"; + + inherit (cfg.package) version src; + + nativeBuildInputs = with pkgs.nodePackages; [ less ]; + + buildPhase = '' + mkdir -p $out + + cp -r static $out/ + ''; + + installPhase = '' + less_dir=$src/less + css_dir=$out/static/css + + lessc $less_dir/app.less $css_dir/write.css + lessc $less_dir/fonts.less $css_dir/fonts.css + lessc $less_dir/icons.less $css_dir/icons.css + lessc $less_dir/prose.less $css_dir/prose.css + ''; + }; + + withConfigFile = text: '' + db_pass=${ + optionalString (cfg.database.passwordFile != null) + "$(head -n1 ${cfg.database.passwordFile})" + } + + cp -f ${configFile} '${cfg.stateDir}/config.ini' + sed -e "s,#dbpass#,$db_pass,g" -i '${cfg.stateDir}/config.ini' + chmod 440 '${cfg.stateDir}/config.ini' + + ${text} + ''; + + withMysql = text: + withConfigFile '' + query () { + local result=$(${config.services.mysql.package}/bin/mysql \ + --user=${cfg.database.user} \ + --password=$db_pass \ + --database=${cfg.database.name} \ + --silent \ + --raw \ + --skip-column-names \ + --execute "$1" \ + ) + + echo $result + } + + ${text} + ''; + + withSqlite = text: + withConfigFile '' + query () { + local result=$(${sqlite}/bin/sqlite3 \ + '${cfg.stateDir}/${settings.database.filename}' + "$1" \ + ) + + echo $result + } + + ${text} + ''; +in { + options.services.writefreely = { + enable = + lib.mkEnableOption (lib.mdDoc "Writefreely, build a digital writing community"); + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.writefreely; + defaultText = lib.literalExpression "pkgs.writefreely"; + description = lib.mdDoc "Writefreely package to use."; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/writefreely"; + description = lib.mdDoc "The state directory where keys and data are stored."; + }; + + user = mkOption { + type = types.str; + default = "writefreely"; + description = lib.mdDoc "User under which Writefreely is ran."; + }; + + group = mkOption { + type = types.str; + default = "writefreely"; + description = lib.mdDoc "Group under which Writefreely is ran."; + }; + + host = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc "The public host name to serve."; + example = "example.com"; + }; + + settings = mkOption { + default = { }; + description = lib.mdDoc '' + Writefreely configuration ({file}`config.ini`). Refer to + <https://writefreely.org/docs/latest/admin/config> + for details. + ''; + + type = types.submodule { + freeformType = format.type; + + options = { + app = { + theme = mkOption { + type = types.str; + default = "write"; + description = lib.mdDoc "The theme to apply."; + }; + }; + + server = { + port = mkOption { + type = types.port; + default = if cfg.nginx.enable then 18080 else 80; + defaultText = "80"; + description = lib.mdDoc "The port WriteFreely should listen on."; + }; + }; + }; + }; + }; + + database = { + type = mkOption { + type = types.enum [ "sqlite3" "mysql" ]; + default = "sqlite3"; + description = lib.mdDoc "The database provider to use."; + }; + + name = mkOption { + type = types.str; + default = "writefreely"; + description = lib.mdDoc "The name of the database to store data in."; + }; + + user = mkOption { + type = types.nullOr types.str; + default = if cfg.database.type == "mysql" then "writefreely" else null; + defaultText = "writefreely"; + description = lib.mdDoc "The database user to connect as."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc "The file to load the database password from."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "The database host to connect to."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "The port used when connecting to the database host."; + }; + + tls = mkOption { + type = types.bool; + default = false; + description = + lib.mdDoc "Whether or not TLS should be used for the database connection."; + }; + + migrate = mkOption { + type = types.bool; + default = true; + description = + lib.mdDoc "Whether or not to automatically run migrations on startup."; + }; + + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + When {option}`services.writefreely.database.type` is set to + `"mysql"`, this option will enable the MySQL service locally. + ''; + }; + }; + + admin = { + name = mkOption { + type = types.nullOr types.str; + description = lib.mdDoc "The name of the first admin user."; + default = null; + }; + + initialPasswordFile = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to a file containing the initial password for the admin user. + If not provided, the default password will be set to `nixos`. + ''; + default = pkgs.writeText "default-admin-pass" "nixos"; + defaultText = "/nix/store/xxx-default-admin-pass"; + }; + }; + + nginx = { + enable = mkOption { + type = types.bool; + default = false; + description = + lib.mdDoc "Whether or not to enable and configure nginx as a proxy for WriteFreely."; + }; + + forceSSL = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Whether or not to force the use of SSL."; + }; + }; + + acme = { + enable = mkOption { + type = types.bool; + default = false; + description = + lib.mdDoc "Whether or not to automatically fetch and configure SSL certs."; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.host != ""; + message = "services.writefreely.host must be set"; + } + { + assertion = isMysqlLocal -> cfg.database.passwordFile != null; + message = + "services.writefreely.database.passwordFile must be set if services.writefreely.database.createLocally is set to true"; + } + { + assertion = isSqlite -> !cfg.database.createLocally; + message = + "services.writefreely.database.createLocally has no use when services.writefreely.database.type is set to sqlite3"; + } + ]; + + users = { + users = optionalAttrs (cfg.user == "writefreely") { + writefreely = { + group = cfg.group; + home = cfg.stateDir; + isSystemUser = true; + }; + }; + + groups = + optionalAttrs (cfg.group == "writefreely") { writefreely = { }; }; + }; + + systemd.tmpfiles.rules = + [ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" ]; + + systemd.services.writefreely = { + after = [ "network.target" ] + ++ optional isSqlite "writefreely-sqlite-init.service" + ++ optional isMysql "writefreely-mysql-init.service" + ++ optional isMysqlLocal "mysql.service"; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + Restart = "always"; + RestartSec = 20; + ExecStart = + "${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' serve"; + AmbientCapabilities = + optionalString (settings.server.port < 1024) "cap_net_bind_service"; + }; + + preStart = '' + if ! test -d "${cfg.stateDir}/keys"; then + mkdir -p ${cfg.stateDir}/keys + + # Key files end up with the wrong permissions by default. + # We need to correct them so that Writefreely can read them. + chmod -R 750 "${cfg.stateDir}/keys" + + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' keys generate + fi + ''; + }; + + systemd.services.writefreely-sqlite-init = mkIf isSqlite { + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ReadOnlyPaths = optional (cfg.admin.initialPasswordFile != null) + cfg.admin.initialPasswordFile; + }; + + script = let + migrateDatabase = optionalString cfg.database.migrate '' + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate + ''; + + createAdmin = optionalString (cfg.admin.name != null) '' + if [[ $(query "SELECT COUNT(*) FROM users") == 0 ]]; then + admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile}) + + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass + fi + ''; + in withSqlite '' + if ! test -f '${settings.database.filename}'; then + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init + fi + + ${migrateDatabase} + + ${createAdmin} + ''; + }; + + systemd.services.writefreely-mysql-init = mkIf isMysql { + wantedBy = [ "multi-user.target" ]; + after = optional isMysqlLocal "mysql.service"; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ReadOnlyPaths = optional isMysqlLocal cfg.database.passwordFile + ++ optional (cfg.admin.initialPasswordFile != null) + cfg.admin.initialPasswordFile; + }; + + script = let + updateUser = optionalString isMysqlLocal '' + # WriteFreely currently *requires* a password for authentication, so we + # need to update the user in MySQL accordingly. By default MySQL users + # authenticate with auth_socket or unix_socket. + # See: https://github.com/writefreely/writefreely/issues/568 + ${config.services.mysql.package}/bin/mysql --skip-column-names --execute "ALTER USER '${cfg.database.user}'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('$db_pass'); FLUSH PRIVILEGES;" + ''; + + migrateDatabase = optionalString cfg.database.migrate '' + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate + ''; + + createAdmin = optionalString (cfg.admin.name != null) '' + if [[ $(query 'SELECT COUNT(*) FROM users') == 0 ]]; then + admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile}) + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass + fi + ''; + in withMysql '' + ${updateUser} + + if [[ $(query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${cfg.database.name}'") == 0 ]]; then + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init + fi + + ${migrateDatabase} + + ${createAdmin} + ''; + }; + + services.mysql = mkIf isMysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "ALL PRIVILEGES"; + # WriteFreely requires the use of passwords, so we need permissions + # to `ALTER` the user to add password support and also to reload + # permissions so they can be used. + "*.*" = "CREATE USER, RELOAD"; + }; + }]; + }; + + services.nginx = lib.mkIf cfg.nginx.enable { + enable = true; + recommendedProxySettings = true; + + virtualHosts."${cfg.host}" = { + enableACME = cfg.acme.enable; + forceSSL = cfg.nginx.forceSSL; + + locations."/" = { + proxyPass = "http://127.0.0.1:${toString settings.server.port}"; + }; + }; + }; + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/youtrack.nix b/nixpkgs/nixos/modules/services/web-apps/youtrack.nix new file mode 100644 index 000000000000..09a2b9e965c0 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/youtrack.nix @@ -0,0 +1,181 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.youtrack; + + extraAttr = concatStringsSep " " (mapAttrsToList (k: v: "-D${k}=${v}") (stdParams // cfg.extraParams)); + mergeAttrList = lib.foldl' lib.mergeAttrs {}; + + stdParams = mergeAttrList [ + (optionalAttrs (cfg.baseUrl != null) { + "jetbrains.youtrack.baseUrl" = cfg.baseUrl; + }) + { + "java.aws.headless" = "true"; + "jetbrains.youtrack.disableBrowser" = "true"; + } + ]; +in +{ + options.services.youtrack = { + + enable = mkEnableOption (lib.mdDoc "YouTrack service"); + + address = mkOption { + description = lib.mdDoc '' + The interface youtrack will listen on. + ''; + default = "127.0.0.1"; + type = types.str; + }; + + baseUrl = mkOption { + description = lib.mdDoc '' + Base URL for youtrack. Will be auto-detected and stored in database. + ''; + type = types.nullOr types.str; + default = null; + }; + + extraParams = mkOption { + default = {}; + description = lib.mdDoc '' + Extra parameters to pass to youtrack. See + https://www.jetbrains.com/help/youtrack/standalone/YouTrack-Java-Start-Parameters.html + for more information. + ''; + example = literalExpression '' + { + "jetbrains.youtrack.overrideRootPassword" = "tortuga"; + } + ''; + type = types.attrsOf types.str; + }; + + package = mkOption { + description = lib.mdDoc '' + Package to use. + ''; + type = types.package; + default = pkgs.youtrack; + defaultText = literalExpression "pkgs.youtrack"; + }; + + port = mkOption { + description = lib.mdDoc '' + The port youtrack will listen on. + ''; + default = 8080; + type = types.port; + }; + + statePath = mkOption { + description = lib.mdDoc '' + Where to keep the youtrack database. + ''; + type = types.path; + default = "/var/lib/youtrack"; + }; + + virtualHost = mkOption { + description = lib.mdDoc '' + Name of the nginx virtual host to use and setup. + If null, do not setup anything. + ''; + default = null; + type = types.nullOr types.str; + }; + + jvmOpts = mkOption { + description = lib.mdDoc '' + Extra options to pass to the JVM. + See https://www.jetbrains.com/help/youtrack/standalone/Configure-JVM-Options.html + for more information. + ''; + type = types.separatedString " "; + example = "-XX:MetaspaceSize=250m"; + default = ""; + }; + + maxMemory = mkOption { + description = lib.mdDoc '' + Maximum Java heap size + ''; + type = types.str; + default = "1g"; + }; + + maxMetaspaceSize = mkOption { + description = lib.mdDoc '' + Maximum java Metaspace memory. + ''; + type = types.str; + default = "350m"; + }; + }; + + config = mkIf cfg.enable { + + systemd.services.youtrack = { + environment.HOME = cfg.statePath; + environment.YOUTRACK_JVM_OPTS = "${extraAttr}"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ unixtools.hostname ]; + serviceConfig = { + Type = "simple"; + User = "youtrack"; + Group = "youtrack"; + Restart = "on-failure"; + ExecStart = ''${cfg.package}/bin/youtrack --J-Xmx${cfg.maxMemory} --J-XX:MaxMetaspaceSize=${cfg.maxMetaspaceSize} ${cfg.jvmOpts} ${cfg.address}:${toString cfg.port}''; + }; + }; + + users.users.youtrack = { + description = "Youtrack service user"; + isSystemUser = true; + home = cfg.statePath; + createHome = true; + group = "youtrack"; + }; + + users.groups.youtrack = {}; + + services.nginx = mkIf (cfg.virtualHost != null) { + upstreams.youtrack.servers."${cfg.address}:${toString cfg.port}" = {}; + virtualHosts.${cfg.virtualHost}.locations = { + "/" = { + proxyPass = "http://youtrack"; + extraConfig = '' + client_max_body_size 10m; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + + "/api/eventSourceBus" = { + proxyPass = "http://youtrack"; + extraConfig = '' + proxy_cache off; + proxy_buffering off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_set_header Connection ""; + chunked_transfer_encoding off; + client_max_body_size 10m; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + + }; + }; + + }; +} diff --git a/nixpkgs/nixos/modules/services/web-apps/zabbix.nix b/nixpkgs/nixos/modules/services/web-apps/zabbix.nix new file mode 100644 index 000000000000..2cea7e7cea72 --- /dev/null +++ b/nixpkgs/nixos/modules/services/web-apps/zabbix.nix @@ -0,0 +1,238 @@ +{ config, lib, options, pkgs, ... }: + +let + + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types; + inherit (lib) literalExpression mapAttrs optionalString versionAtLeast; + + cfg = config.services.zabbixWeb; + opt = options.services.zabbixWeb; + fpm = config.services.phpfpm.pools.zabbix; + + user = "zabbix"; + group = "zabbix"; + stateDir = "/var/lib/zabbix"; + + zabbixConfig = pkgs.writeText "zabbix.conf.php" '' + <?php + // Zabbix GUI configuration file. + global $DB; + $DB['TYPE'] = '${ { mysql = "MYSQL"; pgsql = "POSTGRESQL"; oracle = "ORACLE"; }.${cfg.database.type} }'; + $DB['SERVER'] = '${cfg.database.host}'; + $DB['PORT'] = '${toString cfg.database.port}'; + $DB['DATABASE'] = '${cfg.database.name}'; + $DB['USER'] = '${cfg.database.user}'; + # NOTE: file_get_contents adds newline at the end of returned string + $DB['PASSWORD'] = ${if cfg.database.passwordFile != null then "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")" else "''"}; + // Schema name. Used for IBM DB2 and PostgreSQL. + $DB['SCHEMA'] = '''; + $ZBX_SERVER = '${cfg.server.address}'; + $ZBX_SERVER_PORT = '${toString cfg.server.port}'; + $ZBX_SERVER_NAME = '''; + $IMAGE_FORMAT_DEFAULT = IMAGE_FORMAT_PNG; + + ${cfg.extraConfig} + ''; + +in +{ + # interface + + options.services = { + zabbixWeb = { + enable = mkEnableOption (lib.mdDoc "the Zabbix web interface"); + + package = mkOption { + type = types.package; + default = pkgs.zabbix.web; + defaultText = literalExpression "zabbix.web"; + description = lib.mdDoc "Which Zabbix package to use."; + }; + + server = { + port = mkOption { + type = types.port; + description = lib.mdDoc "The port of the Zabbix server to connect to."; + default = 10051; + }; + + address = mkOption { + type = types.str; + description = lib.mdDoc "The IP address or hostname of the Zabbix server to connect to."; + default = "localhost"; + }; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "pgsql" "oracle" ]; + example = "mysql"; + default = "pgsql"; + description = lib.mdDoc "Database engine to use."; + }; + + host = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + default = + if cfg.database.type == "mysql" then config.services.mysql.port + else if cfg.database.type == "pgsql" then config.services.postgresql.port + else 1521; + defaultText = literalExpression '' + if config.${opt.database.type} == "mysql" then config.${options.services.mysql.port} + else if config.${opt.database.type} == "pgsql" then config.${options.services.postgresql.port} + else 1521 + ''; + description = lib.mdDoc "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "zabbix"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "zabbix"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/zabbix-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/postgresql"; + description = lib.mdDoc "Path to the unix socket file to use for authentication."; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "zabbix.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + Apache configuration can be done by adapting `services.httpd.virtualHosts.<name>`. + See [](#opt-services.httpd.virtualHosts) for further information. + ''; + }; + + 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 = lib.mdDoc '' + Options for the Zabbix PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Additional configuration to be copied verbatim into {file}`zabbix.conf.php`. + ''; + }; + + }; + }; + + # implementation + + config = mkIf cfg.enable { + + services.zabbixWeb.extraConfig = optionalString ((versionAtLeast config.system.stateVersion "20.09") && (versionAtLeast cfg.package.version "5.0.0")) '' + $DB['DOUBLE_IEEE754'] = 'true'; + ''; + + systemd.tmpfiles.rules = [ + "d '${stateDir}' 0750 ${user} ${group} - -" + "d '${stateDir}/session' 0750 ${user} ${config.services.httpd.group} - -" + ]; + + services.phpfpm.pools.zabbix = { + inherit user; + group = config.services.httpd.group; + phpOptions = '' + # https://www.zabbix.com/documentation/current/manual/installation/install + memory_limit = 128M + post_max_size = 16M + upload_max_filesize = 2M + max_execution_time = 300 + max_input_time = 300 + session.auto_start = 0 + mbstring.func_overload = 0 + always_populate_raw_post_data = -1 + # https://bbs.archlinux.org/viewtopic.php?pid=1745214#p1745214 + session.save_path = ${stateDir}/session + '' + optionalString (config.time.timeZone != null) '' + date.timezone = "${config.time.timeZone}" + '' + optionalString (cfg.database.type == "oracle") '' + extension=${pkgs.phpPackages.oci8}/lib/php/extensions/oci8.so + ''; + phpEnv.ZABBIX_CONFIG = "${zabbixConfig}"; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + adminAddr = mkDefault cfg.virtualHost.adminAddr; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${cfg.package}/share/zabbix"; + extraConfig = '' + <Directory "${cfg.package}/share/zabbix"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + AllowOverride all + Options -Indexes + DirectoryIndex index.php + </Directory> + ''; + } ]; + }; + + users.users.${user} = mapAttrs (name: mkDefault) { + description = "Zabbix daemon user"; + uid = config.ids.uids.zabbix; + inherit group; + }; + + users.groups.${group} = mapAttrs (name: mkDefault) { + gid = config.ids.gids.zabbix; + }; + + }; +} |