at 23.11-pre 19 kB view raw
1{ config, lib, pkgs, ... }: 2 3with lib; 4 5let 6 7 cfg = config.services.mysql; 8 9 isMariaDB = lib.getName cfg.package == lib.getName pkgs.mariadb; 10 11 mysqldOptions = 12 "--user=${cfg.user} --datadir=${cfg.dataDir} --basedir=${cfg.package}"; 13 14 format = pkgs.formats.ini { listsAsDuplicateKeys = true; }; 15 configFile = format.generate "my.cnf" cfg.settings; 16 17in 18 19{ 20 imports = [ 21 (mkRemovedOptionModule [ "services" "mysql" "pidDir" ] "Don't wait for pidfiles, describe dependencies through systemd.") 22 (mkRemovedOptionModule [ "services" "mysql" "rootPassword" ] "Use socket authentication or set the password outside of the nix store.") 23 (mkRemovedOptionModule [ "services" "mysql" "extraOptions" ] "Use services.mysql.settings.mysqld instead.") 24 (mkRemovedOptionModule [ "services" "mysql" "bind" ] "Use services.mysql.settings.mysqld.bind-address instead.") 25 (mkRemovedOptionModule [ "services" "mysql" "port" ] "Use services.mysql.settings.mysqld.port instead.") 26 ]; 27 28 ###### interface 29 30 options = { 31 32 services.mysql = { 33 34 enable = mkEnableOption (lib.mdDoc "MySQL server"); 35 36 package = mkOption { 37 type = types.package; 38 example = literalExpression "pkgs.mariadb"; 39 description = lib.mdDoc '' 40 Which MySQL derivation to use. MariaDB packages are supported too. 41 ''; 42 }; 43 44 user = mkOption { 45 type = types.str; 46 default = "mysql"; 47 description = lib.mdDoc '' 48 User account under which MySQL runs. 49 50 ::: {.note} 51 If left as the default value this user will automatically be created 52 on system activation, otherwise you are responsible for 53 ensuring the user exists before the MySQL service starts. 54 ::: 55 ''; 56 }; 57 58 group = mkOption { 59 type = types.str; 60 default = "mysql"; 61 description = lib.mdDoc '' 62 Group account under which MySQL runs. 63 64 ::: {.note} 65 If left as the default value this group will automatically be created 66 on system activation, otherwise you are responsible for 67 ensuring the user exists before the MySQL service starts. 68 ::: 69 ''; 70 }; 71 72 dataDir = mkOption { 73 type = types.path; 74 example = "/var/lib/mysql"; 75 description = lib.mdDoc '' 76 The data directory for MySQL. 77 78 ::: {.note} 79 If left as the default value of `/var/lib/mysql` this directory will automatically be created before the MySQL 80 server starts, otherwise you are responsible for ensuring the directory exists with appropriate ownership and permissions. 81 ::: 82 ''; 83 }; 84 85 configFile = mkOption { 86 type = types.path; 87 default = configFile; 88 defaultText = '' 89 A configuration file automatically generated by NixOS. 90 ''; 91 description = lib.mdDoc '' 92 Override the configuration file used by MySQL. By default, 93 NixOS generates one automatically from {option}`services.mysql.settings`. 94 ''; 95 example = literalExpression '' 96 pkgs.writeText "my.cnf" ''' 97 [mysqld] 98 datadir = /var/lib/mysql 99 bind-address = 127.0.0.1 100 port = 3336 101 102 !includedir /etc/mysql/conf.d/ 103 '''; 104 ''; 105 }; 106 107 settings = mkOption { 108 type = format.type; 109 default = {}; 110 description = lib.mdDoc '' 111 MySQL configuration. Refer to 112 <https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html>, 113 <https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html>, 114 and <https://mariadb.com/kb/en/server-system-variables/> 115 for details on supported values. 116 117 ::: {.note} 118 MySQL configuration options such as `--quick` should be treated as 119 boolean options and provided values such as `true`, `false`, 120 `1`, or `0`. See the provided example below. 121 ::: 122 ''; 123 example = literalExpression '' 124 { 125 mysqld = { 126 key_buffer_size = "6G"; 127 table_cache = 1600; 128 log-error = "/var/log/mysql_err.log"; 129 plugin-load-add = [ "server_audit" "ed25519=auth_ed25519" ]; 130 }; 131 mysqldump = { 132 quick = true; 133 max_allowed_packet = "16M"; 134 }; 135 } 136 ''; 137 }; 138 139 initialDatabases = mkOption { 140 type = types.listOf (types.submodule { 141 options = { 142 name = mkOption { 143 type = types.str; 144 description = lib.mdDoc '' 145 The name of the database to create. 146 ''; 147 }; 148 schema = mkOption { 149 type = types.nullOr types.path; 150 default = null; 151 description = lib.mdDoc '' 152 The initial schema of the database; if null (the default), 153 an empty database is created. 154 ''; 155 }; 156 }; 157 }); 158 default = []; 159 description = lib.mdDoc '' 160 List of database names and their initial schemas that should be used to create databases on the first startup 161 of MySQL. The schema attribute is optional: If not specified, an empty database is created. 162 ''; 163 example = literalExpression '' 164 [ 165 { name = "foodatabase"; schema = ./foodatabase.sql; } 166 { name = "bardatabase"; } 167 ] 168 ''; 169 }; 170 171 initialScript = mkOption { 172 type = types.nullOr types.path; 173 default = null; 174 description = lib.mdDoc "A file containing SQL statements to be executed on the first startup. Can be used for granting certain permissions on the database."; 175 }; 176 177 ensureDatabases = mkOption { 178 type = types.listOf types.str; 179 default = []; 180 description = lib.mdDoc '' 181 Ensures that the specified databases exist. 182 This option will never delete existing databases, especially not when the value of this 183 option is changed. This means that databases created once through this option or 184 otherwise have to be removed manually. 185 ''; 186 example = [ 187 "nextcloud" 188 "matomo" 189 ]; 190 }; 191 192 ensureUsers = mkOption { 193 type = types.listOf (types.submodule { 194 options = { 195 name = mkOption { 196 type = types.str; 197 description = lib.mdDoc '' 198 Name of the user to ensure. 199 ''; 200 }; 201 ensurePermissions = mkOption { 202 type = types.attrsOf types.str; 203 default = {}; 204 description = lib.mdDoc '' 205 Permissions to ensure for the user, specified as attribute set. 206 The attribute names specify the database and tables to grant the permissions for, 207 separated by a dot. You may use wildcards here. 208 The attribute values specfiy the permissions to grant. 209 You may specify one or multiple comma-separated SQL privileges here. 210 211 For more information on how to specify the target 212 and on which privileges exist, see the 213 [GRANT syntax](https://mariadb.com/kb/en/library/grant/). 214 The attributes are used as `GRANT ''${attrName} ON ''${attrValue}`. 215 ''; 216 example = literalExpression '' 217 { 218 "database.*" = "ALL PRIVILEGES"; 219 "*.*" = "SELECT, LOCK TABLES"; 220 } 221 ''; 222 }; 223 }; 224 }); 225 default = []; 226 description = lib.mdDoc '' 227 Ensures that the specified users exist and have at least the ensured permissions. 228 The MySQL users will be identified using Unix socket authentication. This authenticates the Unix user with the 229 same name only, and that without the need for a password. 230 This option will never delete existing users or remove permissions, especially not when the value of this 231 option is changed. This means that users created and permissions assigned once through this option or 232 otherwise have to be removed manually. 233 ''; 234 example = literalExpression '' 235 [ 236 { 237 name = "nextcloud"; 238 ensurePermissions = { 239 "nextcloud.*" = "ALL PRIVILEGES"; 240 }; 241 } 242 { 243 name = "backup"; 244 ensurePermissions = { 245 "*.*" = "SELECT, LOCK TABLES"; 246 }; 247 } 248 ] 249 ''; 250 }; 251 252 replication = { 253 role = mkOption { 254 type = types.enum [ "master" "slave" "none" ]; 255 default = "none"; 256 description = lib.mdDoc "Role of the MySQL server instance."; 257 }; 258 259 serverId = mkOption { 260 type = types.int; 261 default = 1; 262 description = lib.mdDoc "Id of the MySQL server instance. This number must be unique for each instance."; 263 }; 264 265 masterHost = mkOption { 266 type = types.str; 267 description = lib.mdDoc "Hostname of the MySQL master server."; 268 }; 269 270 slaveHost = mkOption { 271 type = types.str; 272 description = lib.mdDoc "Hostname of the MySQL slave server."; 273 }; 274 275 masterUser = mkOption { 276 type = types.str; 277 description = lib.mdDoc "Username of the MySQL replication user."; 278 }; 279 280 masterPassword = mkOption { 281 type = types.str; 282 description = lib.mdDoc "Password of the MySQL replication user."; 283 }; 284 285 masterPort = mkOption { 286 type = types.port; 287 default = 3306; 288 description = lib.mdDoc "Port number on which the MySQL master server runs."; 289 }; 290 }; 291 }; 292 293 }; 294 295 296 ###### implementation 297 298 config = mkIf cfg.enable { 299 300 services.mysql.dataDir = 301 mkDefault (if versionAtLeast config.system.stateVersion "17.09" then "/var/lib/mysql" 302 else "/var/mysql"); 303 304 services.mysql.settings.mysqld = mkMerge [ 305 { 306 datadir = cfg.dataDir; 307 port = mkDefault 3306; 308 } 309 (mkIf (cfg.replication.role == "master" || cfg.replication.role == "slave") { 310 log-bin = "mysql-bin-${toString cfg.replication.serverId}"; 311 log-bin-index = "mysql-bin-${toString cfg.replication.serverId}.index"; 312 relay-log = "mysql-relay-bin"; 313 server-id = cfg.replication.serverId; 314 binlog-ignore-db = [ "information_schema" "performance_schema" "mysql" ]; 315 }) 316 (mkIf (!isMariaDB) { 317 plugin-load-add = "auth_socket.so"; 318 }) 319 ]; 320 321 users.users = optionalAttrs (cfg.user == "mysql") { 322 mysql = { 323 description = "MySQL server user"; 324 group = cfg.group; 325 uid = config.ids.uids.mysql; 326 }; 327 }; 328 329 users.groups = optionalAttrs (cfg.group == "mysql") { 330 mysql.gid = config.ids.gids.mysql; 331 }; 332 333 environment.systemPackages = [ cfg.package ]; 334 335 environment.etc."my.cnf".source = cfg.configFile; 336 337 systemd.services.mysql = { 338 description = "MySQL Server"; 339 340 after = [ "network.target" ]; 341 wantedBy = [ "multi-user.target" ]; 342 restartTriggers = [ cfg.configFile ]; 343 344 unitConfig.RequiresMountsFor = cfg.dataDir; 345 346 path = [ 347 # Needed for the mysql_install_db command in the preStart script 348 # which calls the hostname command. 349 pkgs.nettools 350 ]; 351 352 preStart = if isMariaDB then '' 353 if ! test -e ${cfg.dataDir}/mysql; then 354 ${cfg.package}/bin/mysql_install_db --defaults-file=/etc/my.cnf ${mysqldOptions} 355 touch ${cfg.dataDir}/mysql_init 356 fi 357 '' else '' 358 if ! test -e ${cfg.dataDir}/mysql; then 359 ${cfg.package}/bin/mysqld --defaults-file=/etc/my.cnf ${mysqldOptions} --initialize-insecure 360 touch ${cfg.dataDir}/mysql_init 361 fi 362 ''; 363 364 script = '' 365 # https://mariadb.com/kb/en/getting-started-with-mariadb-galera-cluster/#systemd-and-galera-recovery 366 if test -n "''${_WSREP_START_POSITION}"; then 367 if test -e "${cfg.package}/bin/galera_recovery"; then 368 VAR=$(cd ${cfg.package}/bin/..; ${cfg.package}/bin/galera_recovery); [[ $? -eq 0 ]] && export _WSREP_START_POSITION=$VAR || exit 1 369 fi 370 fi 371 372 # The last two environment variables are used for starting Galera clusters 373 exec ${cfg.package}/bin/mysqld --defaults-file=/etc/my.cnf ${mysqldOptions} $_WSREP_NEW_CLUSTER $_WSREP_START_POSITION 374 ''; 375 376 postStart = let 377 # The super user account to use on *first* run of MySQL server 378 superUser = if isMariaDB then cfg.user else "root"; 379 in '' 380 ${optionalString (!isMariaDB) '' 381 # Wait until the MySQL server is available for use 382 count=0 383 while [ ! -e /run/mysqld/mysqld.sock ] 384 do 385 if [ $count -eq 30 ] 386 then 387 echo "Tried 30 times, giving up..." 388 exit 1 389 fi 390 391 echo "MySQL daemon not yet started. Waiting for 1 second..." 392 count=$((count++)) 393 sleep 1 394 done 395 ''} 396 397 if [ -f ${cfg.dataDir}/mysql_init ] 398 then 399 # While MariaDB comes with a 'mysql' super user account since 10.4.x, MySQL does not 400 # Since we don't want to run this service as 'root' we need to ensure the account exists on first run 401 ( echo "CREATE USER IF NOT EXISTS '${cfg.user}'@'localhost' IDENTIFIED WITH ${if isMariaDB then "unix_socket" else "auth_socket"};" 402 echo "GRANT ALL PRIVILEGES ON *.* TO '${cfg.user}'@'localhost' WITH GRANT OPTION;" 403 ) | ${cfg.package}/bin/mysql -u ${superUser} -N 404 405 ${concatMapStrings (database: '' 406 # Create initial databases 407 if ! test -e "${cfg.dataDir}/${database.name}"; then 408 echo "Creating initial database: ${database.name}" 409 ( echo 'create database `${database.name}`;' 410 411 ${optionalString (database.schema != null) '' 412 echo 'use `${database.name}`;' 413 414 # TODO: this silently falls through if database.schema does not exist, 415 # we should catch this somehow and exit, but can't do it here because we're in a subshell. 416 if [ -f "${database.schema}" ] 417 then 418 cat ${database.schema} 419 elif [ -d "${database.schema}" ] 420 then 421 cat ${database.schema}/mysql-databases/*.sql 422 fi 423 ''} 424 ) | ${cfg.package}/bin/mysql -u ${superUser} -N 425 fi 426 '') cfg.initialDatabases} 427 428 ${optionalString (cfg.replication.role == "master") 429 '' 430 # Set up the replication master 431 432 ( echo "use mysql;" 433 echo "CREATE USER '${cfg.replication.masterUser}'@'${cfg.replication.slaveHost}' IDENTIFIED WITH mysql_native_password;" 434 echo "SET PASSWORD FOR '${cfg.replication.masterUser}'@'${cfg.replication.slaveHost}' = PASSWORD('${cfg.replication.masterPassword}');" 435 echo "GRANT REPLICATION SLAVE ON *.* TO '${cfg.replication.masterUser}'@'${cfg.replication.slaveHost}';" 436 ) | ${cfg.package}/bin/mysql -u ${superUser} -N 437 ''} 438 439 ${optionalString (cfg.replication.role == "slave") 440 '' 441 # Set up the replication slave 442 443 ( echo "stop slave;" 444 echo "change master to master_host='${cfg.replication.masterHost}', master_user='${cfg.replication.masterUser}', master_password='${cfg.replication.masterPassword}';" 445 echo "start slave;" 446 ) | ${cfg.package}/bin/mysql -u ${superUser} -N 447 ''} 448 449 ${optionalString (cfg.initialScript != null) 450 '' 451 # Execute initial script 452 # using toString to avoid copying the file to nix store if given as path instead of string, 453 # as it might contain credentials 454 cat ${toString cfg.initialScript} | ${cfg.package}/bin/mysql -u ${superUser} -N 455 ''} 456 457 rm ${cfg.dataDir}/mysql_init 458 fi 459 460 ${optionalString (cfg.ensureDatabases != []) '' 461 ( 462 ${concatMapStrings (database: '' 463 echo "CREATE DATABASE IF NOT EXISTS \`${database}\`;" 464 '') cfg.ensureDatabases} 465 ) | ${cfg.package}/bin/mysql -N 466 ''} 467 468 ${concatMapStrings (user: 469 '' 470 ( echo "CREATE USER IF NOT EXISTS '${user.name}'@'localhost' IDENTIFIED WITH ${if isMariaDB then "unix_socket" else "auth_socket"};" 471 ${concatStringsSep "\n" (mapAttrsToList (database: permission: '' 472 echo "GRANT ${permission} ON ${database} TO '${user.name}'@'localhost';" 473 '') user.ensurePermissions)} 474 ) | ${cfg.package}/bin/mysql -N 475 '') cfg.ensureUsers} 476 ''; 477 478 serviceConfig = mkMerge [ 479 { 480 Type = if isMariaDB then "notify" else "simple"; 481 Restart = "on-abort"; 482 RestartSec = "5s"; 483 484 # User and group 485 User = cfg.user; 486 Group = cfg.group; 487 # Runtime directory and mode 488 RuntimeDirectory = "mysqld"; 489 RuntimeDirectoryMode = "0755"; 490 # Access write directories 491 ReadWritePaths = [ cfg.dataDir ]; 492 # Capabilities 493 CapabilityBoundingSet = ""; 494 # Security 495 NoNewPrivileges = true; 496 # Sandboxing 497 ProtectSystem = "strict"; 498 ProtectHome = true; 499 PrivateTmp = true; 500 PrivateDevices = true; 501 ProtectHostname = true; 502 ProtectKernelTunables = true; 503 ProtectKernelModules = true; 504 ProtectControlGroups = true; 505 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; 506 LockPersonality = true; 507 MemoryDenyWriteExecute = true; 508 RestrictRealtime = true; 509 RestrictSUIDSGID = true; 510 PrivateMounts = true; 511 # System Call Filtering 512 SystemCallArchitectures = "native"; 513 } 514 (mkIf (cfg.dataDir == "/var/lib/mysql") { 515 StateDirectory = "mysql"; 516 StateDirectoryMode = "0700"; 517 }) 518 ]; 519 }; 520 }; 521}