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