at master 11 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7let 8 inherit (lib) 9 concatStringsSep 10 filterAttrs 11 getExe 12 hasPrefix 13 hasSuffix 14 isString 15 literalExpression 16 maintainers 17 mapAttrs 18 mkEnableOption 19 mkIf 20 mkOption 21 mkPackageOption 22 optional 23 optionalString 24 types 25 ; 26 27 cfg = config.services.umami; 28 29 nonFileSettings = filterAttrs (k: _: !hasSuffix "_FILE" k) cfg.settings; 30in 31{ 32 options.services.umami = { 33 enable = mkEnableOption "umami"; 34 35 package = mkPackageOption pkgs "umami" { } // { 36 apply = 37 pkg: 38 pkg.override { 39 databaseType = cfg.settings.DATABASE_TYPE; 40 collectApiEndpoint = optionalString ( 41 cfg.settings.COLLECT_API_ENDPOINT != null 42 ) cfg.settings.COLLECT_API_ENDPOINT; 43 trackerScriptNames = cfg.settings.TRACKER_SCRIPT_NAME; 44 basePath = cfg.settings.BASE_PATH; 45 }; 46 }; 47 48 createPostgresqlDatabase = mkOption { 49 type = types.bool; 50 default = true; 51 example = false; 52 description = '' 53 Whether to automatically create the database for Umami using PostgreSQL. 54 Both the database name and username will be `umami`, and the connection is 55 made through unix sockets using peer authentication. 56 ''; 57 }; 58 59 settings = mkOption { 60 description = '' 61 Additional configuration (environment variables) for Umami, see 62 <https://umami.is/docs/environment-variables> for supported values. 63 ''; 64 65 type = types.submodule { 66 freeformType = 67 with types; 68 attrsOf (oneOf [ 69 bool 70 int 71 str 72 ]); 73 74 options = { 75 APP_SECRET_FILE = mkOption { 76 type = types.nullOr ( 77 types.str 78 // { 79 # We don't want users to be able to pass a path literal here but 80 # it should look like a path. 81 check = it: isString it && types.path.check it; 82 } 83 ); 84 default = null; 85 example = "/run/secrets/umamiAppSecret"; 86 description = '' 87 A file containing a secure random string. This is used for signing user sessions. 88 The contents of the file are read through systemd credentials, therefore the 89 user running umami does not need permissions to read the file. 90 If you wish to set this to a string instead (not recommended since it will be 91 placed world-readable in the Nix store), you can use the APP_SECRET option. 92 ''; 93 }; 94 DATABASE_URL = mkOption { 95 type = types.nullOr ( 96 types.str 97 // { 98 check = 99 it: 100 isString it 101 && ((hasPrefix "postgresql://" it) || (hasPrefix "postgres://" it) || (hasPrefix "mysql://" it)); 102 } 103 ); 104 # For some reason, Prisma requires the username in the connection string 105 # and can't derive it from the current user. 106 default = 107 if cfg.createPostgresqlDatabase then 108 "postgresql://umami@localhost/umami?host=/run/postgresql" 109 else 110 null; 111 defaultText = literalExpression ''if config.services.umami.createPostgresqlDatabase then "postgresql://umami@localhost/umami?host=/run/postgresql" else null''; 112 example = "postgresql://root:root@localhost/umami"; 113 description = '' 114 Connection string for the database. Must start with `postgresql://`, `postgres://` 115 or `mysql://`. 116 ''; 117 }; 118 DATABASE_URL_FILE = mkOption { 119 type = types.nullOr ( 120 types.str 121 // { 122 # We don't want users to be able to pass a path literal here but 123 # it should look like a path. 124 check = it: isString it && types.path.check it; 125 } 126 ); 127 default = null; 128 example = "/run/secrets/umamiDatabaseUrl"; 129 description = '' 130 A file containing a connection string for the database. The connection string 131 must start with `postgresql://`, `postgres://` or `mysql://`. 132 If using this, then DATABASE_TYPE must be set to the appropriate value. 133 The contents of the file are read through systemd credentials, therefore the 134 user running umami does not need permissions to read the file. 135 ''; 136 }; 137 DATABASE_TYPE = mkOption { 138 type = types.nullOr ( 139 types.enum [ 140 "postgresql" 141 "mysql" 142 ] 143 ); 144 default = 145 if cfg.settings.DATABASE_URL != null && hasPrefix "mysql://" cfg.settings.DATABASE_URL then 146 "mysql" 147 else 148 "postgresql"; 149 defaultText = literalExpression ''if config.services.umami.settings.DATABASE_URL != null && hasPrefix "mysql://" config.services.umami.settings.DATABASE_URL then "mysql" else "postgresql"''; 150 example = "mysql"; 151 description = '' 152 The type of database to use. This is automatically inferred from DATABASE_URL, but 153 must be set manually if you are using DATABASE_URL_FILE. 154 ''; 155 }; 156 COLLECT_API_ENDPOINT = mkOption { 157 type = types.nullOr types.str; 158 default = null; 159 example = "/api/alternate-send"; 160 description = '' 161 Allows you to send metrics to a location different than the default `/api/send`. 162 ''; 163 }; 164 TRACKER_SCRIPT_NAME = mkOption { 165 type = types.listOf types.str; 166 default = [ ]; 167 example = [ "tracker.js" ]; 168 description = '' 169 Allows you to assign a custom name to the tracker script different from the default `script.js`. 170 ''; 171 }; 172 BASE_PATH = mkOption { 173 type = types.str; 174 default = ""; 175 example = "/analytics"; 176 description = '' 177 Allows you to host Umami under a subdirectory. 178 You may need to update your reverse proxy settings to correctly handle the BASE_PATH prefix. 179 ''; 180 }; 181 DISABLE_UPDATES = mkOption { 182 type = types.bool; 183 default = true; 184 example = false; 185 description = '' 186 Disables the check for new versions of Umami. 187 ''; 188 }; 189 DISABLE_TELEMETRY = mkOption { 190 type = types.bool; 191 default = false; 192 example = true; 193 description = '' 194 Umami collects completely anonymous telemetry data in order help improve the application. 195 You can choose to disable this if you don't want to participate. 196 ''; 197 }; 198 HOSTNAME = mkOption { 199 type = types.str; 200 default = "127.0.0.1"; 201 example = "0.0.0.0"; 202 description = '' 203 The address to listen on. 204 ''; 205 }; 206 PORT = mkOption { 207 type = types.port; 208 default = 3000; 209 example = 3010; 210 description = '' 211 The port to listen on. 212 ''; 213 }; 214 }; 215 }; 216 217 default = { }; 218 219 example = { 220 APP_SECRET_FILE = "/run/secrets/umamiAppSecret"; 221 DISABLE_TELEMETRY = true; 222 }; 223 }; 224 }; 225 226 config = mkIf cfg.enable { 227 assertions = [ 228 { 229 assertion = (cfg.settings.APP_SECRET_FILE != null) != (cfg.settings ? APP_SECRET); 230 message = "One (and only one) of services.umami.settings.APP_SECRET_FILE and services.umami.settings.APP_SECRET must be set."; 231 } 232 { 233 assertion = (cfg.settings.DATABASE_URL_FILE != null) != (cfg.settings.DATABASE_URL != null); 234 message = "One (and only one) of services.umami.settings.DATABASE_URL_FILE and services.umami.settings.DATABASE_URL must be set."; 235 } 236 { 237 assertion = 238 cfg.createPostgresqlDatabase 239 -> cfg.settings.DATABASE_URL == "postgresql://umami@localhost/umami?host=/run/postgresql"; 240 message = "The option config.services.umami.createPostgresqlDatabase is enabled, but config.services.umami.settings.DATABASE_URL has been modified."; 241 } 242 ]; 243 244 services.postgresql = mkIf cfg.createPostgresqlDatabase { 245 enable = true; 246 ensureDatabases = [ "umami" ]; 247 ensureUsers = [ 248 { 249 name = "umami"; 250 ensureDBOwnership = true; 251 ensureClauses.login = true; 252 } 253 ]; 254 }; 255 256 systemd.services.umami = { 257 environment = mapAttrs (_: toString) nonFileSettings; 258 259 description = "Umami: a simple, fast, privacy-focused alternative to Google Analytics"; 260 after = [ "network.target" ] ++ (optional (cfg.createPostgresqlDatabase) "postgresql.service"); 261 wantedBy = [ "multi-user.target" ]; 262 263 script = 264 let 265 loadCredentials = 266 (optional ( 267 cfg.settings.APP_SECRET_FILE != null 268 ) ''export APP_SECRET="$(systemd-creds cat appSecret)"'') 269 ++ (optional ( 270 cfg.settings.DATABASE_URL_FILE != null 271 ) ''export DATABASE_URL="$(systemd-creds cat databaseUrl)"''); 272 in 273 '' 274 ${concatStringsSep "\n" loadCredentials} 275 ${getExe cfg.package} 276 ''; 277 278 serviceConfig = { 279 Type = "simple"; 280 Restart = "on-failure"; 281 RestartSec = 3; 282 DynamicUser = true; 283 284 LoadCredential = 285 (optional (cfg.settings.APP_SECRET_FILE != null) "appSecret:${cfg.settings.APP_SECRET_FILE}") 286 ++ (optional ( 287 cfg.settings.DATABASE_URL_FILE != null 288 ) "databaseUrl:${cfg.settings.DATABASE_URL_FILE}"); 289 290 # Hardening 291 CapabilityBoundingSet = ""; 292 NoNewPrivileges = true; 293 PrivateUsers = true; 294 PrivateTmp = true; 295 PrivateDevices = true; 296 PrivateMounts = true; 297 ProtectClock = true; 298 ProtectControlGroups = true; 299 ProtectHome = true; 300 ProtectHostname = true; 301 ProtectKernelLogs = true; 302 ProtectKernelModules = true; 303 ProtectKernelTunables = true; 304 RestrictAddressFamilies = (optional cfg.createPostgresqlDatabase "AF_UNIX") ++ [ 305 "AF_INET" 306 "AF_INET6" 307 ]; 308 RestrictNamespaces = true; 309 RestrictRealtime = true; 310 RestrictSUIDSGID = true; 311 }; 312 }; 313 }; 314 315 meta.maintainers = with maintainers; [ diogotcorreia ]; 316}