1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.pgadmin;
7
8 _base = with types; [ int bool str ];
9 base = with types; oneOf ([ (listOf (oneOf _base)) (attrsOf (oneOf _base)) ] ++ _base);
10
11 formatAttrset = attr:
12 "{${concatStringsSep "\n" (mapAttrsToList (key: value: "${builtins.toJSON key}: ${formatPyValue value},") attr)}}";
13
14 formatPyValue = value:
15 if builtins.isString value then builtins.toJSON value
16 else if value ? _expr then value._expr
17 else if builtins.isInt value then toString value
18 else if builtins.isBool value then (if value then "True" else "False")
19 else if builtins.isAttrs value then (formatAttrset value)
20 else if builtins.isList value then "[${concatStringsSep "\n" (map (v: "${formatPyValue v},") value)}]"
21 else throw "Unrecognized type";
22
23 formatPy = attrs:
24 concatStringsSep "\n" (mapAttrsToList (key: value: "${key} = ${formatPyValue value}") attrs);
25
26 pyType = with types; attrsOf (oneOf [ (attrsOf base) (listOf base) base ]);
27in
28{
29 options.services.pgadmin = {
30 enable = mkEnableOption "PostgreSQL Admin 4";
31
32 port = mkOption {
33 description = "Port for pgadmin4 to run on";
34 type = types.port;
35 default = 5050;
36 };
37
38 package = mkPackageOptionMD pkgs "pgadmin4" { };
39
40 initialEmail = mkOption {
41 description = "Initial email for the pgAdmin account";
42 type = types.str;
43 };
44
45 initialPasswordFile = mkOption {
46 description = ''
47 Initial password file for the pgAdmin account. Minimum length by default is 6.
48 Please see `services.pgadmin.minimumPasswordLength`.
49 NOTE: Should be string not a store path, to prevent the password from being world readable
50 '';
51 type = types.path;
52 };
53
54 minimumPasswordLength = mkOption {
55 description = "Minimum length of the password";
56 type = types.int;
57 default = 6;
58 };
59
60 emailServer = {
61 enable = mkOption {
62 description = ''
63 Enable SMTP email server. This is necessary, if you want to use password recovery or change your own password
64 '';
65 type = types.bool;
66 default = false;
67 };
68 address = mkOption {
69 description = "SMTP server for email delivery";
70 type = types.str;
71 default = "localhost";
72 };
73 port = mkOption {
74 description = "SMTP server port for email delivery";
75 type = types.port;
76 default = 25;
77 };
78 useSSL = mkOption {
79 description = "SMTP server should use SSL";
80 type = types.bool;
81 default = false;
82 };
83 useTLS = mkOption {
84 description = "SMTP server should use TLS";
85 type = types.bool;
86 default = false;
87 };
88 username = mkOption {
89 description = "SMTP server username for email delivery";
90 type = types.nullOr types.str;
91 default = null;
92 };
93 sender = mkOption {
94 description = ''
95 SMTP server sender email for email delivery. Some servers require this to be a valid email address from that server
96 '';
97 type = types.str;
98 example = "noreply@example.com";
99 };
100 passwordFile = mkOption {
101 description = ''
102 Password for SMTP email account.
103 NOTE: Should be string not a store path, to prevent the password from being world readable
104 '';
105 type = types.path;
106 };
107 };
108
109 openFirewall = mkEnableOption "firewall passthrough for pgadmin4";
110
111 settings = mkOption {
112 description = ''
113 Settings for pgadmin4.
114 [Documentation](https://www.pgadmin.org/docs/pgadmin4/development/config_py.html)
115 '';
116 type = pyType;
117 default = { };
118 };
119 };
120
121 config = mkIf (cfg.enable) {
122 networking.firewall.allowedTCPPorts = mkIf (cfg.openFirewall) [ cfg.port ];
123
124 services.pgadmin.settings = {
125 DEFAULT_SERVER_PORT = cfg.port;
126 PASSWORD_LENGTH_MIN = cfg.minimumPasswordLength;
127 SERVER_MODE = true;
128 UPGRADE_CHECK_ENABLED = false;
129 } // (optionalAttrs cfg.openFirewall {
130 DEFAULT_SERVER = mkDefault "::";
131 }) // (optionalAttrs cfg.emailServer.enable {
132 MAIL_SERVER = cfg.emailServer.address;
133 MAIL_PORT = cfg.emailServer.port;
134 MAIL_USE_SSL = cfg.emailServer.useSSL;
135 MAIL_USE_TLS = cfg.emailServer.useTLS;
136 MAIL_USERNAME = cfg.emailServer.username;
137 SECURITY_EMAIL_SENDER = cfg.emailServer.sender;
138 });
139
140 systemd.services.pgadmin = {
141 wantedBy = [ "multi-user.target" ];
142 after = [ "network.target" ];
143 requires = [ "network.target" ];
144 # we're adding this optionally so just in case there's any race it'll be caught
145 # in case postgres doesn't start, pgadmin will just start normally
146 wants = [ "postgresql.service" ];
147
148 path = [ config.services.postgresql.package pkgs.coreutils pkgs.bash ];
149
150 preStart = ''
151 # NOTE: this is idempotent (aka running it twice has no effect)
152 # Check here for password length to prevent pgadmin from starting
153 # and presenting a hard to find error message
154 # see https://github.com/NixOS/nixpkgs/issues/270624
155 PW_FILE="$CREDENTIALS_DIRECTORY/initial_password"
156 PW_LENGTH=$(wc -m < "$PW_FILE")
157 if [ $PW_LENGTH -lt ${toString cfg.minimumPasswordLength} ]; then
158 echo "Password must be at least ${toString cfg.minimumPasswordLength} characters long"
159 exit 1
160 fi
161 (
162 # Email address:
163 echo ${escapeShellArg cfg.initialEmail}
164
165 # file might not contain newline. echo hack fixes that.
166 PW=$(cat "$PW_FILE")
167
168 # Password:
169 echo "$PW"
170 # Retype password:
171 echo "$PW"
172 ) | ${cfg.package}/bin/pgadmin4-cli setup-db
173 '';
174
175 restartTriggers = [
176 "/etc/pgadmin/config_system.py"
177 ];
178
179 serviceConfig = {
180 User = "pgadmin";
181 DynamicUser = true;
182 LogsDirectory = "pgadmin";
183 StateDirectory = "pgadmin";
184 ExecStart = "${cfg.package}/bin/pgadmin4";
185 LoadCredential = [ "initial_password:${cfg.initialPasswordFile}" ]
186 ++ optional cfg.emailServer.enable "email_password:${cfg.emailServer.passwordFile}";
187 };
188 };
189
190 users.users.pgadmin = {
191 isSystemUser = true;
192 group = "pgadmin";
193 };
194
195 users.groups.pgadmin = { };
196
197 environment.etc."pgadmin/config_system.py" = {
198 text = lib.optionalString cfg.emailServer.enable ''
199 import os
200 with open(os.path.join(os.environ['CREDENTIALS_DIRECTORY'], 'email_password')) as f:
201 pw = f.read()
202 MAIL_PASSWORD = pw
203 '' + formatPy cfg.settings;
204 mode = "0600";
205 user = "pgadmin";
206 group = "pgadmin";
207 };
208 };
209}