nixos/dawarich: init

This commit is contained in:
Diogo Correia 2025-07-09 19:49:25 +01:00
commit b0cb5d3e8f
No known key found for this signature in database
GPG key ID: 12B4F3AC9C065D08
2 changed files with 707 additions and 0 deletions

View file

@ -1598,6 +1598,7 @@
./services/web-apps/cryptpad.nix
./services/web-apps/dashy.nix
./services/web-apps/davis.nix
./services/web-apps/dawarich.nix
./services/web-apps/dependency-track.nix
./services/web-apps/dex.nix
./services/web-apps/discourse.nix

View file

@ -0,0 +1,706 @@
{
lib,
pkgs,
config,
options,
...
}:
let
cfg = config.services.dawarich;
opt = options.services.dawarich;
dataDir = "/var/lib/dawarich";
isRedisUnixSocket = lib.hasPrefix "/" cfg.redis.host;
redisEnv =
if isRedisUnixSocket then
{
REDIS_URL = "unix://${config.services.redis.servers.dawarich.unixSocket}";
}
else
{
# Does not support passwords, but upstream does not provide an adequate env variable
# Perhaps patch or make a PR upstream in the future
REDIS_URL = "redis://${cfg.redis.host}:${toString cfg.redis.port}";
};
env = {
RAILS_ENV = "production";
NODE_ENV = "production";
BUNDLE_USER_HOME = "/tmp/bundle"; # will use private tmp inside systemd unit
SELF_HOSTED = "true";
STORE_GEODATA = "true";
APPLICATION_PROTOCOL = "http";
TIME_ZONE = config.time.timeZone; # otherwise upstream forces it to Europe/London
DOMAIN = cfg.localDomain;
APPLICATION_HOSTS = "127.0.0.1,::1,${cfg.localDomain}";
BOOTSNAP_CACHE_DIR = "/var/cache/dawarich/precompile";
LD_PRELOAD = "${lib.getLib pkgs.jemalloc}/lib/libjemalloc.so";
DATABASE_USER = cfg.database.user;
DATABASE_HOST = cfg.database.host;
DATABASE_NAME = cfg.database.name;
DATABASE_PORT = toString cfg.database.port;
SMTP_SERVER = cfg.smtp.host;
SMTP_PORT = toString cfg.smtp.port;
SMTP_FROM = cfg.smtp.fromAddress;
SMTP_USERNAME = cfg.smtp.user;
PORT = toString cfg.webPort;
}
// redisEnv
// cfg.environment;
systemCallFilter =
let
allowedSystemCalls = [
"@cpu-emulation"
"@debug"
"@keyring"
"@ipc"
"@mount"
"@obsolete"
"@privileged"
"@setuid"
];
in
[
("~" + lib.concatStringsSep " " allowedSystemCalls)
"@chown"
"pipe"
"pipe2"
];
cfgService = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.package;
CacheDirectory = "dawarich";
CacheDirectoryMode = "0750";
StateDirectory = "dawarich";
StateDirectoryMode = "0750";
LogsDirectory = "dawarich";
LogsDirectoryMode = "0750";
ProcSubset = "pid";
ProtectProc = "invisible";
UMask = "0027";
CapabilityBoundingSet = "";
NoNewPrivileges = true;
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;
SystemCallArchitectures = "native";
SystemCallFilter = systemCallFilter;
# ensure permissions to connect to the redis socket
SupplementaryGroups = lib.mkIf (cfg.redis.createLocally && isRedisUnixSocket) [
config.services.redis.servers.dawarich.group
];
};
# Units that all Dawarich units After= and Requires= on
commonUnits =
lib.optional cfg.redis.createLocally "redis-dawarich.service"
++ lib.optional cfg.database.createLocally "postgresql.target"
++ lib.optional cfg.automaticMigrations "dawarich-init-db.service";
defaultSecretKeyBaseFile = "${dataDir}/secrets/secret-key-base";
needsGenCredentialsUnit = cfg.secretKeyBaseFile == null;
credentials = {
SECRET_KEY_BASE = lib.defaultTo defaultSecretKeyBaseFile cfg.secretKeyBaseFile;
}
// lib.optionalAttrs (cfg.database.passwordFile != null) {
DATABASE_PASSWORD = cfg.database.passwordFile;
}
// lib.optionalAttrs (cfg.smtp.passwordFile != null) {
SMTP_PASSWORD = cfg.smtp.passwordFile;
};
loadCredentialsIntoEnv = lib.concatMapAttrsStringSep "\n" (
name: _: ''export ${name}="$(systemd-creds cat ${name})"''
) credentials;
loadCredentials = lib.mapAttrsToList (name: path: "${name}:${path}") credentials;
dawarichRails = pkgs.writeShellApplication {
name = "dawarich-rails";
text =
let
sourceExtraEnv = lib.concatMapStrings (p: "source ${p}\n") cfg.extraEnvFiles;
command = pkgs.writeShellScript "dawarich-rails-unwrapped" ''
${sourceExtraEnv}
${loadCredentialsIntoEnv}
export RAILS_ROOT="${cfg.package}"
exec ${lib.getExe' cfg.package "rails"} "$@"
'';
env' = lib.filterAttrs (_: value: value != null) env;
supplementaryGroups = lib.optionalString (cfg.redis.createLocally && isRedisUnixSocket) (
lib.escapeShellArg "--property=SupplementaryGroups=${config.services.redis.servers.dawarich.group}"
);
in
''
exec ${lib.getExe' config.systemd.package "systemd-run"} \
${
lib.escapeShellArgs (map (credential: "--property=LoadCredential=${credential}") loadCredentials)
} \
${
lib.escapeShellArgs (lib.mapAttrsToList (name: value: "--setenv=${name}=${toString value}") env')
} \
--uid=${lib.escapeShellArg cfg.user} \
--gid=${lib.escapeShellArg cfg.group} \
${supplementaryGroups} \
--working-directory=${lib.escapeShellArg cfg.package} \
--property=PrivateTmp=yes \
--pty \
--wait \
--collect \
--service-type=exec \
--quiet \
-- \
${command} "$@"
'';
};
dawarichConsole = pkgs.writeShellScriptBin "dawarich-console" ''
exec ${lib.getExe dawarichRails} console "$@"
'';
sidekiqUnits = lib.attrsets.mapAttrs' (
name: processCfg:
lib.nameValuePair "dawarich-sidekiq-${name}" (
let
jobClassArgs = lib.concatMapStringsSep " " (c: "-q ${c}") processCfg.jobClasses;
jobClassLabel = lib.optionalString (
processCfg.jobClasses != [ ]
) " (${lib.concatStringsSep ", " processCfg.jobClasses})";
threads = toString (if processCfg.threads == null then cfg.sidekiqThreads else processCfg.threads);
in
{
after = [
"network.target"
]
++ lib.optional needsGenCredentialsUnit "dawarich-init-credentials.service"
++ commonUnits;
requires = lib.optional needsGenCredentialsUnit "dawarich-init-credentials.service" ++ commonUnits;
description = "Dawarich sidekiq${jobClassLabel}";
wantedBy = [ "dawarich.target" ];
environment = env // {
RAILS_MAX_THREADS = threads;
};
script = ''
${loadCredentialsIntoEnv}
${lib.getExe' cfg.package "sidekiq"} ${jobClassArgs} -c ${threads} -r ${cfg.package}
'';
serviceConfig = {
Restart = "always";
RestartSec = 20;
LoadCredential = loadCredentials;
EnvironmentFile = cfg.extraEnvFiles;
LimitNOFILE = "1024000";
}
// cfgService;
}
)
) cfg.sidekiqProcesses;
in
{
options = {
services.dawarich = {
enable = lib.mkEnableOption "Dawarich, a self-hostable alternative to Google Location History";
configureNginx = lib.mkOption {
description = ''
Configure nginx as a reverse proxy for dawarich.
Alternatively you can configure a reverse-proxy of your choice to serve these paths:
`/ -> ''${pkgs.dawarich}/public`
`/ -> 127.0.0.1:{{ webPort }} `(If there was no file in the directory above.)
Make sure that websockets are forwarded properly. You might want to set up caching
of some requests. Take a look at dawarich's provided reverse proxy configurations at
`https://dawarich.app/docs/tutorials/reverse-proxy`.
'';
type = lib.types.bool;
default = true;
};
user = lib.mkOption {
description = ''
User under which dawarich runs. If it is set to "dawarich",
that user will be created, otherwise it should be set to the
name of a user created elsewhere.
'';
type = lib.types.str;
default = "dawarich";
};
group = lib.mkOption {
description = ''
Group under which dawarich runs.
'';
type = lib.types.str;
default = "dawarich";
};
webPort = lib.mkOption {
description = "TCP port used by the dawarich web service.";
type = lib.types.port;
default = 3000;
};
sidekiqThreads = lib.mkOption {
description = ''
Worker threads used by the dawarich-sidekiq-all service.
If `sidekiqProcesses` is configured and any processes specify null `threads`, this value is used.
'';
type = lib.types.int;
default = 5;
};
sidekiqProcesses = lib.mkOption {
description = ''
How many Sidekiq processes should be used to handle background jobs, and which job classes they handle.
Can be used to [speed up](https://dawarich.app/docs/FAQ/#how-to-speed-up-the-import-process) the import process.
'';
type =
with lib.types;
attrsOf (submodule {
options = {
jobClasses = lib.mkOption {
# https://github.com/Freika/dawarich/blob/0.37.2/config/sidekiq.yml
type = listOf (enum [
"app_version_checking"
"archival"
"cache"
"data_migrations"
"default"
"digests"
"exports"
"families"
"imports"
"mailers"
"places"
"points"
"reverse_geocoding"
"stats"
"tracks"
"trips"
"visit_suggesting"
]);
description = ''
If not empty, which job classes should be executed by this process.
*If left empty, all job classes will be executed by this process.*
'';
};
threads = lib.mkOption {
type = nullOr int;
description = ''
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;
};
geocoding = {
jobClasses = [ "reverse_geocoding" ];
threads = 10;
};
};
};
localDomain = lib.mkOption {
description = "The domain serving your Dawarich instance.";
example = "dawarich.example.org";
type = lib.types.str;
};
secretKeyBaseFile = lib.mkOption {
description = ''
Path to file containing the secret key base.
A new secret key base can be generated by running:
`nix build -f '<nixpkgs>' dawarich; cd result; bin/bundle exec rails secret`
This file is loaded using systemd credentials, and therefore does not need to be
owned by the dawarich user.
If this option is null, it will be created at ${defaultSecretKeyBaseFile}
with a new secret key base.
'';
default = null;
type = lib.types.nullOr lib.types.str;
};
redis = {
createLocally = lib.mkOption {
description = ''
Whether to configure a local Redis server for Dawarich.
The connection is performed via Unix sockets by default,
but that can be changed by configuring {option}`${opt.redis.host}` and {option}`${opt.redis.port}`.
'';
type = lib.types.bool;
default = true;
};
host = lib.mkOption {
description = "The redis host Dawarich will connect to.";
type = lib.types.str;
default = config.services.redis.servers.dawarich.unixSocket;
defaultText = lib.literalExpression "config.services.redis.servers.dawarich.unixSocket";
};
port = lib.mkOption {
description = "The port of the redis server Dawarich will connect to. Set to zero to disable TCP and use Unix sockets instead.";
type = lib.types.port;
default = 0;
};
};
database = {
createLocally = lib.mkOption {
description = ''
Whether to configure a local PostgreSQL server and database for Dawarich.
The connection is performed via Unix sockets.
'';
type = lib.types.bool;
default = true;
};
host = lib.mkOption {
type = lib.types.str;
default = "/run/postgresql";
example = "127.0.0.1";
description = "Hostname or address of the postgresql server. If an absolute path is given here, it will be interpreted as a unix socket path.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = 5432;
description = "Port of the postgresql server.";
};
name = lib.mkOption {
type = lib.types.str;
default = "dawarich";
description = "The name of the dawarich database.";
};
user = lib.mkOption {
type = lib.types.str;
default = "dawarich";
description = "The database user for dawarich.";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/keys/dawarich-db-password";
description = ''
A file containing the password corresponding to {option}`${opt.database.user}`.
'';
};
};
smtp = {
host = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "SMTP host used when sending emails to users.";
};
port = lib.mkOption {
type = lib.types.port;
default = 25;
description = "SMTP port used when sending emails to users.";
};
fromAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "dawarich@example.com";
description = ''"From" address used when sending emails to users.'';
};
user = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "dawarich@example.com";
description = "SMTP login name.";
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/keys/dawarich-smtp-password";
description = ''
Path to file containing the SMTP password.
'';
};
};
package = lib.mkPackageOption pkgs "dawarich" { };
environment = lib.mkOption {
type =
with lib.types;
attrsOf (
nullOr (oneOf [
str
path
package
])
);
default = { };
description = ''
Extra environment variables to pass to all dawarich services.
'';
};
extraEnvFiles = lib.mkOption {
type = with lib.types; listOf path;
default = [ ];
description = ''
Extra environment files to pass to all Dawarich services. Useful for passing down environment secrets.
'';
example = [ "/etc/dawarich/secret.env" ];
};
automaticMigrations = lib.mkOption {
description = "Whether to perform database migrations automatically";
type = lib.types.bool;
default = true;
};
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
assertions = [
{
assertion = !isRedisUnixSocket -> cfg.redis.port != 0;
message = ''
`services.dawarich.redis.port` needs to be configured if `services.dawarich.redis.host` is not a unix socket.
'';
}
];
environment.systemPackages = [
dawarichConsole
dawarichRails
];
systemd.targets.dawarich = {
description = "Target for all Dawarich services";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
};
systemd.services.dawarich-init-credentials = lib.mkIf needsGenCredentialsUnit {
script = ''
umask 077
''
+ lib.optionalString (cfg.secretKeyBaseFile == null) ''
if ! test -f ${defaultSecretKeyBaseFile}; then
mkdir -p $(dirname ${defaultSecretKeyBaseFile})
bin/bundle exec rails secret > ${defaultSecretKeyBaseFile}
fi
'';
environment = env;
serviceConfig = {
Type = "oneshot";
SyslogIdentifier = "dawarich-init-dirs";
# System Call Filtering
SystemCallFilter = [ "~@resources" ] ++ systemCallFilter;
}
// cfgService;
after = [ "network.target" ];
};
systemd.services.dawarich-init-db = lib.mkIf cfg.automaticMigrations {
script = ''
${loadCredentialsIntoEnv}
# Run primary database migrations first (needed before other migrations)
echo "Running primary database migrations..."
rails db:migrate
# Run data migrations
echo "Running DATA migrations..."
rake data:migrate
echo "Running seeds..."
rails db:seed
'';
path = [ cfg.package ];
environment = env;
serviceConfig = {
Type = "oneshot";
LoadCredential = loadCredentials;
EnvironmentFile = cfg.extraEnvFiles;
WorkingDirectory = cfg.package;
# System Call Filtering
SystemCallFilter = [ "~@resources" ] ++ systemCallFilter;
}
// cfgService;
# both postgres and redis are needed for the migrations to work
after = [
"network.target"
]
++ lib.optional needsGenCredentialsUnit "dawarich-init-credentials.service"
++ lib.optional cfg.database.createLocally "postgresql.target"
++ lib.optional cfg.redis.createLocally "redis-dawarich.service";
requires =
lib.optional needsGenCredentialsUnit "dawarich-init-credentials.service"
++ lib.optional cfg.database.createLocally "postgresql.target"
++ lib.optional cfg.redis.createLocally "redis-dawarich.service";
};
systemd.services.dawarich-web = {
after = [
"network.target"
]
++ lib.optional needsGenCredentialsUnit "dawarich-init-credentials.service"
++ commonUnits;
requires = lib.optional needsGenCredentialsUnit "dawarich-init-credentials.service" ++ commonUnits;
wantedBy = [ "dawarich.target" ];
description = "Dawarich web";
environment = env;
script = ''
${loadCredentialsIntoEnv}
${lib.getExe' cfg.package "rails"} server
'';
serviceConfig = {
Restart = "always";
RestartSec = 20;
LoadCredential = loadCredentials;
EnvironmentFile = cfg.extraEnvFiles;
WorkingDirectory = cfg.package;
# Runtime directory and mode
RuntimeDirectory = "dawarich-web";
RuntimeDirectoryMode = "0750";
}
// cfgService;
};
systemd.tmpfiles.settings."dawarich"."${dataDir}/imports/watched".d = {
group = "dawarich";
mode = "700";
user = "dawarich";
};
services.nginx = lib.mkIf cfg.configureNginx {
enable = true;
virtualHosts."${cfg.localDomain}" = {
root = "${cfg.package}/public/";
locations."/" = {
tryFiles = "$uri @proxy";
};
locations."@proxy" = {
proxyPass = "http://127.0.0.1:${toString cfg.webPort}";
recommendedProxySettings = true;
proxyWebsockets = true;
};
};
};
services.redis.servers = lib.mkIf cfg.redis.createLocally {
dawarich = {
enable = true;
port = cfg.redis.port;
bind = lib.mkIf (!isRedisUnixSocket) cfg.redis.host;
};
};
services.postgresql = lib.mkIf cfg.database.createLocally {
enable = true;
extensions = ps: with ps; [ postgis ];
ensureUsers = [
{
inherit (cfg.database) name;
ensureDBOwnership = true;
}
];
ensureDatabases = [ cfg.database.name ];
};
systemd.services.postgresql-setup.serviceConfig.ExecStartPost =
let
# https://github.com/Freika/dawarich/blob/0.30.6/db/schema.rb#L15-L16
# https://postgis.net/documentation/getting_started/
sqlFile = pkgs.writeText "dawarich-postgres-setup.sql" ''
CREATE EXTENSION IF NOT EXISTS postgis;
SELECT postgis_extensions_upgrade();
'';
in
lib.mkIf cfg.database.createLocally [
''
${lib.getExe' config.services.postgresql.package "psql"} -d "${cfg.database.name}" -f "${sqlFile}"
''
];
users.users = lib.mkIf (cfg.user == "dawarich") {
dawarich = {
isSystemUser = true;
home = cfg.package;
inherit (cfg) group;
};
};
users.groups = lib.mkIf (cfg.group == "dawarich") { ${cfg.group} = { }; };
}
{
systemd.services = lib.mkMerge [
sidekiqUnits
];
}
]
);
meta.maintainers = with lib.maintainers; [
diogotcorreia
];
}