summaryrefslogtreecommitdiff
path: root/hosts/surtr/email
diff options
context:
space:
mode:
Diffstat (limited to 'hosts/surtr/email')
-rw-r--r--hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py16
-rw-r--r--hosts/surtr/email/default.nix219
-rw-r--r--hosts/surtr/email/internal-policy-server/.envrc4
-rw-r--r--hosts/surtr/email/internal-policy-server/.gitignore2
-rw-r--r--hosts/surtr/email/internal-policy-server/internal_policy_server/__init__.py0
-rw-r--r--hosts/surtr/email/internal-policy-server/internal_policy_server/__main__.py106
-rw-r--r--hosts/surtr/email/internal-policy-server/pyproject.toml18
-rw-r--r--hosts/surtr/email/internal-policy-server/uv.lock119
8 files changed, 406 insertions, 78 deletions
diff --git a/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py b/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py
index 00182523..7c931559 100644
--- a/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py
+++ b/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py
@@ -28,10 +28,12 @@ class PolicyHandler(StreamRequestHandler):
28 28
29 allowed = False 29 allowed = False
30 user = None 30 user = None
31 relay_eligible = False
31 if self.args['sasl_username']: 32 if self.args['sasl_username']:
32 user = self.args['sasl_username'] 33 user = self.args['sasl_username']
33 if self.args['ccert_subject']: 34 if self.args['ccert_subject']:
34 user = self.args['ccert_subject'] 35 user = self.args['ccert_subject']
36 relay_eligible = True
35 37
36 if user: 38 if user:
37 with self.server.db_pool.connection() as conn: 39 with self.server.db_pool.connection() as conn:
@@ -44,10 +46,16 @@ class PolicyHandler(StreamRequestHandler):
44 46
45 with conn.cursor() as cur: 47 with conn.cursor() as cur:
46 cur.row_factory = namedtuple_row 48 cur.row_factory = namedtuple_row
47 cur.execute('SELECT "mailbox"."mailbox" as "user", "local", "extension", "domain" FROM "mailbox" INNER JOIN "mailbox_mapping" ON "mailbox".id = "mailbox_mapping"."mailbox" WHERE "mailbox"."mailbox" = %(user)s AND ("local" = %(local)s OR "local" IS NULL) AND ("extension" = %(extension)s OR "extension" IS NULL) AND "domain" = %(domain)s', params = {'user': user, 'local': local, 'extension': extension if extension is not None else '', 'domain': domain}, prepare=True) 49
48 for record in cur: 50 if relay_eligible:
49 logger.debug('Received result: %s', record) 51 cur.execute('SELECT EXISTS(SELECT true FROM "mailbox" INNER JOIN "relay_access" ON "mailbox".id = "relay_access"."mailbox" WHERE "mailbox"."mailbox" = %(user)s AND ("domain" = %(domain)s OR %(domain)s ilike CONCAT(\'%%_.\', "domain"))) as "exists"', params = {'user': user, 'domain': domain})
50 allowed = True 52 if (row := cur.fetchone()) is not None:
53 allowed = row.exists
54
55 if not allowed:
56 cur.execute('SELECT EXISTS(SELECT true FROM "mailbox" INNER JOIN "mailbox_mapping" ON "mailbox".id = "mailbox_mapping"."mailbox" WHERE "mailbox"."mailbox" = %(user)s AND ("local" = %(local)s OR "local" IS NULL) AND ("extension" = %(extension)s OR "extension" IS NULL) AND "domain" = %(domain)s) as "exists"', params = {'user': user, 'local': local, 'extension': extension if extension is not None else '', 'domain': domain}, prepare=True)
57 if (row := cur.fetchone()) is not None:
58 allowed = row.exists
51 59
52 action = '550 5.7.0 Sender address not authorized for current user' 60 action = '550 5.7.0 Sender address not authorized for current user'
53 if allowed: 61 if allowed:
diff --git a/hosts/surtr/email/default.nix b/hosts/surtr/email/default.nix
index 4196a8bc..fa7ddac6 100644
--- a/hosts/surtr/email/default.nix
+++ b/hosts/surtr/email/default.nix
@@ -1,4 +1,4 @@
1{ config, pkgs, lib, flakeInputs, ... }: 1{ config, pkgs, lib, flake, flakeInputs, ... }:
2 2
3with lib; 3with lib;
4 4
@@ -15,7 +15,7 @@ let
15 15
16 for file in $out/pipe/bin/*; do 16 for file in $out/pipe/bin/*; do
17 wrapProgram $file \ 17 wrapProgram $file \
18 --set PATH "${pkgs.coreutils}/bin:${pkgs.rspamd}/bin" 18 --set PATH "${makeBinPath (with pkgs; [coreutils rspamd])}"
19 done 19 done
20 ''; 20 '';
21 }; 21 };
@@ -33,12 +33,28 @@ let
33 }); 33 });
34 }); 34 });
35 }; 35 };
36 internal-policy-server =
37 let
38 workspace = flakeInputs.uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./internal-policy-server; };
39 pythonSet = flake.lib.pythonSet {
40 inherit pkgs;
41 python = pkgs.python312;
42 overlay = workspace.mkPyprojectOverlay {
43 sourcePreference = "wheel";
44 };
45 };
46 virtualEnv = pythonSet.mkVirtualEnv "internal-policy-server-env" workspace.deps.default;
47 in virtualEnv.overrideAttrs (oldAttrs: {
48 meta = (oldAttrs.meta or {}) // {
49 mainProgram = "internal-policy-server";
50 };
51 });
36 52
37 nftables-nologin-script = pkgs.writeScript "nftables-mail-nologin" '' 53 nftables-nologin-script = pkgs.resholve.writeScript "nftables-mail-nologin" {
38 #!${pkgs.zsh}/bin/zsh 54 inputs = with pkgs; [inetutils nftables gnugrep findutils];
39 55 interpreter = lib.getExe pkgs.zsh;
56 } ''
40 set -e 57 set -e
41 export PATH="${lib.makeBinPath (with pkgs; [inetutils nftables])}:$PATH"
42 58
43 typeset -a as_sets mnt_bys route route6 59 typeset -a as_sets mnt_bys route route6
44 as_sets=(${lib.escapeShellArgs config.services.email.nologin.ASSets}) 60 as_sets=(${lib.escapeShellArgs config.services.email.nologin.ASSets})
@@ -51,7 +67,7 @@ let
51 elif [[ "''${line}" =~ "^route6:\s+(.+)$" ]]; then 67 elif [[ "''${line}" =~ "^route6:\s+(.+)$" ]]; then
52 route6+=($match[1]) 68 route6+=($match[1])
53 fi 69 fi
54 done < <(whois -h whois.radb.net "!i''${as_set},1" | egrep -o 'AS[0-9]+' | xargs -- whois -h whois.radb.net -- -i origin) 70 done < <(whois -h whois.radb.net "!i''${as_set},1" | grep -Eo 'AS[0-9]+' | xargs whois -h whois.radb.net -- -i origin)
55 done 71 done
56 for mnt_by in $mnt_bys; do 72 for mnt_by in $mnt_bys; do
57 while IFS=$'\n' read line; do 73 while IFS=$'\n' read line; do
@@ -113,14 +129,14 @@ in {
113 setSendmail = true; 129 setSendmail = true;
114 postmasterAlias = ""; rootAlias = ""; extraAliases = ""; 130 postmasterAlias = ""; rootAlias = ""; extraAliases = "";
115 destination = []; 131 destination = [];
116 sslCert = "/run/credentials/postfix.service/surtr.yggdrasil.li.pem";
117 sslKey = "/run/credentials/postfix.service/surtr.yggdrasil.li.key.pem";
118 networks = []; 132 networks = [];
119 config = let 133 config = {
120 relay_ccert = "texthash:${pkgs.writeText "relay_ccert" ""}";
121 in {
122 smtpd_tls_security_level = "may"; 134 smtpd_tls_security_level = "may";
123 135
136 smtpd_tls_chain_files = [
137 "/run/credentials/postfix.service/surtr.yggdrasil.li.full.pem"
138 ];
139
124 #the dh params 140 #the dh params
125 smtpd_tls_dh1024_param_file = toString config.security.dhparams.params."postfix-1024".path; 141 smtpd_tls_dh1024_param_file = toString config.security.dhparams.params."postfix-1024".path;
126 smtpd_tls_dh512_param_file = toString config.security.dhparams.params."postfix-512".path; 142 smtpd_tls_dh512_param_file = toString config.security.dhparams.params."postfix-512".path;
@@ -155,12 +171,7 @@ in {
155 171
156 smtp_tls_connection_reuse = true; 172 smtp_tls_connection_reuse = true;
157 173
158 tls_server_sni_maps = ''texthash:${pkgs.writeText "sni" ( 174 tls_server_sni_maps = "inline:{${concatMapStringsSep ", " (domain: "{ ${domain} = /run/credentials/postfix.service/${removePrefix "." domain}.full.pem }") (concatMap (domain: [domain "mailin.${domain}" "mailsub.${domain}" ".${domain}"]) emailDomains)}}";
159 concatMapStringsSep "\n\n" (domain:
160 concatMapStringsSep "\n" (subdomain: "${subdomain} /run/credentials/postfix.service/${removePrefix "." subdomain}.full.pem")
161 [domain "mailin.${domain}" "mailsub.${domain}" ".${domain}"]
162 ) emailDomains
163 )}'';
164 175
165 smtp_tls_policy_maps = "socketmap:unix:${config.services.postfix-mta-sts-resolver.settings.path}:postfix"; 176 smtp_tls_policy_maps = "socketmap:unix:${config.services.postfix-mta-sts-resolver.settings.path}:postfix";
166 177
@@ -184,25 +195,26 @@ in {
184 dbname = email 195 dbname = email
185 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s' 196 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s'
186 ''}" 197 ''}"
187 "check_ccert_access ${relay_ccert}"
188 "reject_non_fqdn_helo_hostname" 198 "reject_non_fqdn_helo_hostname"
189 "reject_invalid_helo_hostname" 199 "reject_invalid_helo_hostname"
190 "reject_unauth_destination" 200 "reject_unauth_destination"
191 "reject_unknown_recipient_domain" 201 "reject_unknown_recipient_domain"
192 "reject_unverified_recipient" 202 "reject_unverified_recipient"
203 "check_policy_service unix:/run/postfix-internal-policy.sock"
193 ]; 204 ];
194 unverified_recipient_reject_code = "550"; 205 unverified_recipient_reject_code = "550";
195 unverified_recipient_reject_reason = "Recipient address lookup failed"; 206 unverified_recipient_reject_reason = "Recipient address lookup failed";
196 address_verify_map = "internal:address_verify_map"; 207 address_verify_map = "internal:address_verify_map";
197 address_verify_positive_expire_time = "1h"; 208 address_verify_positive_expire_time = "1h";
198 address_verify_positive_refresh_time = "15m"; 209 address_verify_positive_refresh_time = "15m";
199 address_verify_negative_expire_time = "15s"; 210 address_verify_negative_expire_time = "5m";
200 address_verify_negative_refresh_time = "5s"; 211 address_verify_negative_refresh_time = "1m";
201 address_verify_cache_cleanup_interval = "5s"; 212 address_verify_cache_cleanup_interval = "12h";
213 address_verify_poll_count = "\${stress?15}\${stress:30}";
202 address_verify_poll_delay = "1s"; 214 address_verify_poll_delay = "1s";
215 address_verify_sender_ttl = "30045s";
203 216
204 smtpd_relay_restrictions = [ 217 smtpd_relay_restrictions = [
205 "check_ccert_access ${relay_ccert}"
206 "reject_unauth_destination" 218 "reject_unauth_destination"
207 ]; 219 ];
208 220
@@ -213,7 +225,7 @@ in {
213 smtpd_client_event_limit_exceptions = ""; 225 smtpd_client_event_limit_exceptions = "";
214 226
215 milter_default_action = "accept"; 227 milter_default_action = "accept";
216 smtpd_milters = [config.services.opendkim.socket "local:/run/rspamd/rspamd-milter.sock"]; 228 smtpd_milters = [config.services.opendkim.socket "local:/run/rspamd/rspamd-milter.sock" "local:/run/postsrsd/postsrsd-milter.sock"];
217 non_smtpd_milters = [config.services.opendkim.socket "local:/run/rspamd/rspamd-milter.sock"]; 229 non_smtpd_milters = [config.services.opendkim.socket "local:/run/rspamd/rspamd-milter.sock"];
218 230
219 alias_maps = ""; 231 alias_maps = "";
@@ -235,11 +247,6 @@ in {
235 ::/0 silent-discard, dsn 247 ::/0 silent-discard, dsn
236 ''}"; 248 ''}";
237 249
238 sender_canonical_maps = "tcp:localhost:${toString config.services.postsrsd.forwardPort}";
239 sender_canonical_classes = "envelope_sender";
240 recipient_canonical_maps = "tcp:localhost:${toString config.services.postsrsd.reversePort}";
241 recipient_canonical_classes = ["envelope_recipient" "header_recipient"];
242
243 virtual_mailbox_domains = ''pgsql:${pkgs.writeText "virtual_mailbox_domains.cf" '' 250 virtual_mailbox_domains = ''pgsql:${pkgs.writeText "virtual_mailbox_domains.cf" ''
244 hosts = postgresql:///email 251 hosts = postgresql:///email
245 dbname = email 252 dbname = email
@@ -254,11 +261,24 @@ in {
254 virtual_transport = "dvlmtp:unix:/run/dovecot-lmtp"; 261 virtual_transport = "dvlmtp:unix:/run/dovecot-lmtp";
255 smtputf8_enable = false; 262 smtputf8_enable = false;
256 263
257 authorized_submit_users = "inline:{ root= postfwd= }"; 264 authorized_submit_users = "inline:{ root= postfwd= ${config.services.dovecot2.user}= }";
265 authorized_flush_users = "inline:{ root= }";
266 authorized_mailq_users = "inline:{ root= }";
258 267
259 postscreen_access_list = ""; 268 postscreen_access_list = "";
260 postscreen_denylist_action = "drop"; 269 postscreen_denylist_action = "drop";
261 postscreen_greet_action = "enforce"; 270 postscreen_greet_action = "enforce";
271
272 sender_bcc_maps = ''pgsql:${pkgs.writeText "sender_bcc_maps.cf" ''
273 hosts = postgresql:///email
274 dbname = email
275 query = SELECT value FROM sender_bcc_maps WHERE key = '%s'
276 ''}'';
277 recipient_bcc_maps = ''pgsql:${pkgs.writeText "recipient_bcc_maps.cf" ''
278 hosts = postgresql:///email
279 dbname = email
280 query = SELECT value FROM recipient_bcc_maps WHERE key = '%s'
281 ''}'';
262 }; 282 };
263 masterConfig = { 283 masterConfig = {
264 "465" = { 284 "465" = {
@@ -283,7 +303,7 @@ in {
283 hosts = postgresql:///email 303 hosts = postgresql:///email
284 dbname = email 304 dbname = email
285 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s' OR (lookup = regexp_replace('%s', '\+[^@]*@', '@') AND NOT EXISTS (SELECT 1 FROM virtual_mailbox_access WHERE lookup = '%s')) 305 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s' OR (lookup = regexp_replace('%s', '\+[^@]*@', '@') AND NOT EXISTS (SELECT 1 FROM virtual_mailbox_access WHERE lookup = '%s'))
286 ''},permit_tls_all_clientcerts,reject}'' 306 ''},check_policy_service unix:/run/postfix-internal-policy.sock,permit_tls_all_clientcerts,reject}''
287 "-o" "smtpd_relay_restrictions=permit_tls_all_clientcerts,reject" 307 "-o" "smtpd_relay_restrictions=permit_tls_all_clientcerts,reject"
288 "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}" 308 "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}"
289 "-o" "unverified_sender_reject_code=550" 309 "-o" "unverified_sender_reject_code=550"
@@ -313,7 +333,7 @@ in {
313 hosts = postgresql:///email 333 hosts = postgresql:///email
314 dbname = email 334 dbname = email
315 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s' OR (lookup = regexp_replace('%s', '\+[^@]*@', '@') AND NOT EXISTS (SELECT 1 FROM virtual_mailbox_access WHERE lookup = '%s')) 335 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s' OR (lookup = regexp_replace('%s', '\+[^@]*@', '@') AND NOT EXISTS (SELECT 1 FROM virtual_mailbox_access WHERE lookup = '%s'))
316 ''},permit_sasl_authenticated,reject}'' 336 ''},check_policy_service unix:/run/postfix-internal-policy.sock,permit_sasl_authenticated,reject}''
317 "-o" "smtpd_relay_restrictions=permit_sasl_authenticated,reject" 337 "-o" "smtpd_relay_restrictions=permit_sasl_authenticated,reject"
318 "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}" 338 "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}"
319 "-o" "unverified_sender_reject_code=550" 339 "-o" "unverified_sender_reject_code=550"
@@ -328,7 +348,10 @@ in {
328 maxproc = 0; 348 maxproc = 0;
329 args = [ 349 args = [
330 "-o" "header_checks=pcre:${pkgs.writeText "header_checks_submission" '' 350 "-o" "header_checks=pcre:${pkgs.writeText "header_checks_submission" ''
351 if /^Received: /
352 !/by surtr\.yggdrasil\.li/ STRIP
331 /^Received: from [^ ]+ \([^ ]+ [^ ]+\)\s+(.*)$/ REPLACE Received: $1 353 /^Received: from [^ ]+ \([^ ]+ [^ ]+\)\s+(.*)$/ REPLACE Received: $1
354 endif
332 ''}" 355 ''}"
333 ]; 356 ];
334 }; 357 };
@@ -364,17 +387,19 @@ in {
364 387
365 services.postsrsd = { 388 services.postsrsd = {
366 enable = true; 389 enable = true;
367 domain = "surtr.yggdrasil.li"; 390 domains = [ "surtr.yggdrasil.li" ] ++ concatMap (domain: [".${domain}" domain]) emailDomains;
368 separator = "+"; 391 separator = "+";
369 excludeDomains = [ "surtr.yggdrasil.li" 392 extraConfig = ''
370 ] ++ concatMap (domain: [".${domain}" domain]) emailDomains; 393 socketmap = unix:/run/postsrsd/postsrsd-socketmap.sock
394 milter = unix:/run/postsrsd/postsrsd-milter.sock
395 '';
371 }; 396 };
372 397
373 services.opendkim = { 398 services.opendkim = {
374 enable = true; 399 enable = true;
375 user = "postfix"; group = "postfix"; 400 user = "postfix"; group = "postfix";
376 socket = "local:/run/opendkim/opendkim.sock"; 401 socket = "local:/run/opendkim/opendkim.sock";
377 domains = ''csl:${concatStringsSep "," (["surtr.yggdrasil.li"] ++ emailDomains)}''; 402 domains = ''csl:${concatStringsSep "," (["surtr.yggdrasil.li" "yggdrasil.li" "141.li" "kleen.li" "synapse.li" "praseodym.org"] ++ emailDomains)}'';
378 selector = "surtr"; 403 selector = "surtr";
379 configFile = builtins.toFile "opendkim.conf" '' 404 configFile = builtins.toFile "opendkim.conf" ''
380 Syslog true 405 Syslog true
@@ -478,20 +503,20 @@ in {
478 }; 503 };
479 }; 504 };
480 505
481 users.groups.${config.services.rspamd.group}.members = [ config.services.postfix.user "dovecot2" ]; 506 users.groups.${config.services.rspamd.group}.members = [ config.services.postfix.user config.services.dovecot2.user ];
482 507
483 services.redis.servers.rspamd.enable = true; 508 services.redis.servers.rspamd.enable = true;
484 509
485 users.groups.${config.services.redis.servers.rspamd.user}.members = [ config.services.rspamd.user ]; 510 users.groups.${config.services.redis.servers.rspamd.user}.members = [ config.services.rspamd.user ];
486 511
512 environment.systemPackages = with pkgs; [ dovecot_pigeonhole dovecot_fts_xapian ];
487 services.dovecot2 = { 513 services.dovecot2 = {
488 enable = true; 514 enable = true;
489 enablePAM = false; 515 enablePAM = false;
490 sslServerCert = "/run/credentials/dovecot2.service/surtr.yggdrasil.li.pem"; 516 sslServerCert = "/run/credentials/dovecot.service/surtr.yggdrasil.li.pem";
491 sslServerKey = "/run/credentials/dovecot2.service/surtr.yggdrasil.li.key.pem"; 517 sslServerKey = "/run/credentials/dovecot.service/surtr.yggdrasil.li.key.pem";
492 sslCACert = toString ./ca/ca.crt; 518 sslCACert = toString ./ca/ca.crt;
493 mailLocation = "maildir:/var/lib/mail/%u/maildir:UTF-8:INDEX=/var/lib/dovecot/indices/%u"; 519 mailLocation = "maildir:/var/lib/mail/%u/maildir:UTF-8:INDEX=/var/lib/dovecot/indices/%u";
494 modules = with pkgs; [ dovecot_pigeonhole dovecot_fts_xapian ];
495 mailPlugins.globally.enable = [ "fts" "fts_xapian" ]; 520 mailPlugins.globally.enable = [ "fts" "fts_xapian" ];
496 protocols = [ "lmtp" "sieve" ]; 521 protocols = [ "lmtp" "sieve" ];
497 sieve = { 522 sieve = {
@@ -502,8 +527,8 @@ in {
502 dovecotSqlConf = pkgs.writeText "dovecot-sql.conf" '' 527 dovecotSqlConf = pkgs.writeText "dovecot-sql.conf" ''
503 driver = pgsql 528 driver = pgsql
504 connect = dbname=email 529 connect = dbname=email
505 password_query = SELECT (CASE WHEN '%k' = 'valid' AND '%m' = 'EXTERNAL' THEN NULL ELSE "password" END) as password, (CASE WHEN '%k' = 'valid' AND '%m' = 'EXTERNAL' THEN true WHEN password IS NULL THEN true ELSE NULL END) as nopassword, "user", quota_rule, 'dovecot2' as uid, 'dovecot2' as gid FROM imap_user WHERE "user" = '%n' 530 password_query = SELECT (CASE WHEN '%k' = 'valid' AND '%m' = 'EXTERNAL' THEN NULL ELSE "password" END) as password, (CASE WHEN '%k' = 'valid' AND '%m' = 'EXTERNAL' THEN true WHEN password IS NULL THEN true ELSE NULL END) as nopassword, "user", quota_rule, '${config.services.dovecot2.user}' as uid, '${config.services.dovecot2.group}' as gid FROM imap_user WHERE "user" = '%n'
506 user_query = SELECT "user", quota_rule, 'dovecot2' as uid, 'dovecot2' as gid FROM imap_user WHERE "user" = '%n' 531 user_query = SELECT "user", quota_rule, '${config.services.dovecot2.user}' as uid, 'dovecot2' as gid FROM imap_user WHERE "user" = '%n'
507 iterate_query = SELECT "user" FROM imap_user 532 iterate_query = SELECT "user" FROM imap_user
508 ''; 533 '';
509 in '' 534 in ''
@@ -511,16 +536,16 @@ in {
511 536
512 mail_plugins = $mail_plugins quota 537 mail_plugins = $mail_plugins quota
513 538
514 first_valid_uid = ${toString config.users.users.dovecot2.uid} 539 first_valid_uid = ${toString config.users.users.${config.services.dovecot2.user}.uid}
515 last_valid_uid = ${toString config.users.users.dovecot2.uid} 540 last_valid_uid = ${toString config.users.users.${config.services.dovecot2.user}.uid}
516 first_valid_gid = ${toString config.users.groups.dovecot2.gid} 541 first_valid_gid = ${toString config.users.groups.${config.services.dovecot2.group}.gid}
517 last_valid_gid = ${toString config.users.groups.dovecot2.gid} 542 last_valid_gid = ${toString config.users.groups.${config.services.dovecot2.group}.gid}
518 543
519 ${concatMapStringsSep "\n\n" (domain: 544 ${concatMapStringsSep "\n\n" (domain:
520 concatMapStringsSep "\n" (subdomain: '' 545 concatMapStringsSep "\n" (subdomain: ''
521 local_name ${subdomain} { 546 local_name ${subdomain} {
522 ssl_cert = </run/credentials/dovecot2.service/${subdomain}.pem 547 ssl_cert = </run/credentials/dovecot.service/${subdomain}.pem
523 ssl_key = </run/credentials/dovecot2.service/${subdomain}.key.pem 548 ssl_key = </run/credentials/dovecot.service/${subdomain}.key.pem
524 } 549 }
525 '') ["imap.${domain}" domain] 550 '') ["imap.${domain}" domain]
526 ) emailDomains} 551 ) emailDomains}
@@ -541,10 +566,10 @@ in {
541 auth_debug = yes 566 auth_debug = yes
542 567
543 service auth { 568 service auth {
544 user = dovecot2 569 user = ${config.services.dovecot2.user}
545 } 570 }
546 service auth-worker { 571 service auth-worker {
547 user = dovecot2 572 user = ${config.services.dovecot2.user}
548 } 573 }
549 574
550 userdb { 575 userdb {
@@ -565,7 +590,7 @@ in {
565 args = ${pkgs.writeText "dovecot-sql.conf" '' 590 args = ${pkgs.writeText "dovecot-sql.conf" ''
566 driver = pgsql 591 driver = pgsql
567 connect = dbname=email 592 connect = dbname=email
568 user_query = SELECT DISTINCT ON (extension IS NULL, local IS NULL) "user", quota_rule, 'dovecot2' as uid, 'dovecot2' as gid FROM lmtp_mapping WHERE CASE WHEN extension IS NOT NULL AND local IS NOT NULL THEN ('%n' :: citext) = local || '+' || extension AND domain = ('%d' :: citext) WHEN local IS NOT NULL THEN (local = ('%n' :: citext) OR ('%n' :: citext) ILIKE local || '+%%') AND domain = ('%d' :: citext) WHEN extension IS NOT NULL THEN ('%n' :: citext) ILIKE '%%+' || extension AND domain = ('%d' :: citext) ELSE domain = ('%d' :: citext) END ORDER BY (extension IS NULL) ASC, (local IS NULL) ASC 593 user_query = SELECT DISTINCT ON (extension IS NULL, local IS NULL) "user", quota_rule, '${config.services.dovecot2.user}' as uid, '${config.services.dovecot2.group}' as gid FROM lmtp_mapping WHERE CASE WHEN extension IS NOT NULL AND local IS NOT NULL THEN ('%n' :: citext) = local || '+' || extension AND domain = ('%d' :: citext) WHEN local IS NOT NULL THEN (local = ('%n' :: citext) OR ('%n' :: citext) ILIKE local || '+%%') AND domain = ('%d' :: citext) WHEN extension IS NOT NULL THEN ('%n' :: citext) ILIKE '%%+' || extension AND domain = ('%d' :: citext) ELSE domain = ('%d' :: citext) END ORDER BY (extension IS NULL) ASC, (local IS NULL) ASC
569 ''} 594 ''}
570 595
571 skip = never 596 skip = never
@@ -673,7 +698,7 @@ in {
673 plugin { 698 plugin {
674 plugin = fts fts_xapian 699 plugin = fts fts_xapian
675 fts = xapian 700 fts = xapian
676 fts_xapian = partial=2 full=20 attachments=1 verbose=1 701 fts_xapian = partial=3 full=20 attachments=1 verbose=1
677 702
678 fts_autoindex = yes 703 fts_autoindex = yes
679 704
@@ -688,12 +713,12 @@ in {
688 713
689 systemd.services.dovecot-fts-xapian-optimize = { 714 systemd.services.dovecot-fts-xapian-optimize = {
690 description = "Optimize dovecot indices for fts_xapian"; 715 description = "Optimize dovecot indices for fts_xapian";
691 requisite = [ "dovecot2.service" ]; 716 requisite = [ "dovecot.service" ];
692 after = [ "dovecot2.service" ]; 717 after = [ "dovecot.service" ];
693 startAt = "*-*-* 22:00:00 Europe/Berlin"; 718 startAt = "*-*-* 22:00:00 Europe/Berlin";
694 serviceConfig = { 719 serviceConfig = {
695 Type = "oneshot"; 720 Type = "oneshot";
696 ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A"; 721 ExecStart = "${getExe' pkgs.dovecot "doveadm"} fts optimize -A";
697 PrivateDevices = true; 722 PrivateDevices = true;
698 PrivateNetwork = true; 723 PrivateNetwork = true;
699 ProtectKernelTunables = true; 724 ProtectKernelTunables = true;
@@ -754,31 +779,29 @@ in {
754 779
755 security.acme.rfc2136Domains = { 780 security.acme.rfc2136Domains = {
756 "surtr.yggdrasil.li" = { 781 "surtr.yggdrasil.li" = {
757 restartUnits = [ "postfix.service" "dovecot2.service" ]; 782 restartUnits = [ "postfix.service" "dovecot.service" ];
758 }; 783 };
759 } // listToAttrs (map (domain: nameValuePair "spm.${domain}" { restartUnits = ["nginx.service"]; }) spmDomains) 784 } // listToAttrs (map (domain: nameValuePair "spm.${domain}" { restartUnits = ["nginx.service"]; }) spmDomains)
760 // listToAttrs (concatMap (domain: [ 785 // listToAttrs (concatMap (domain: [
761 (nameValuePair domain { restartUnits = ["postfix.service" "dovecot2.service"]; }) 786 (nameValuePair domain { restartUnits = ["postfix.service" "dovecot.service"]; })
762 (nameValuePair "mailin.${domain}" { restartUnits = ["postfix.service"]; }) 787 (nameValuePair "mailin.${domain}" { restartUnits = ["postfix.service"]; })
763 (nameValuePair "mailsub.${domain}" { restartUnits = ["postfix.service"]; }) 788 (nameValuePair "mailsub.${domain}" { restartUnits = ["postfix.service"]; })
764 (nameValuePair "imap.${domain}" { restartUnits = ["dovecot2.service"]; }) 789 (nameValuePair "imap.${domain}" { restartUnits = ["dovecot.service"]; })
765 (nameValuePair "mta-sts.${domain}" { restartUnits = ["nginx.service"]; }) 790 (nameValuePair "mta-sts.${domain}" { restartUnits = ["nginx.service"]; })
766 ]) emailDomains); 791 ]) emailDomains);
767 792
768 systemd.services.postfix = { 793 systemd.services.postfix = {
769 serviceConfig.LoadCredential = [ 794 serviceConfig.LoadCredential = let
770 "surtr.yggdrasil.li.key.pem:${config.security.acme.certs."surtr.yggdrasil.li".directory}/key.pem" 795 tlsCredential = domain: "${domain}.full.pem:${config.security.acme.certs.${domain}.directory}/full.pem";
771 "surtr.yggdrasil.li.pem:${config.security.acme.certs."surtr.yggdrasil.li".directory}/fullchain.pem" 796 in [
772 ] ++ concatMap (domain: 797 (tlsCredential "surtr.yggdrasil.li")
773 map (subdomain: "${subdomain}.full.pem:${config.security.acme.certs.${subdomain}.directory}/full.pem") 798 ] ++ concatMap (domain: map tlsCredential [domain "mailin.${domain}" "mailsub.${domain}"]) emailDomains;
774 [domain "mailin.${domain}" "mailsub.${domain}"]
775 ) emailDomains;
776 }; 799 };
777 800
778 systemd.services.dovecot2 = { 801 systemd.services.dovecot = {
779 preStart = '' 802 preStart = ''
780 for f in /etc/dovecot/sieve_flag.d/*.sieve /etc/dovecot/sieve_before.d/*.sieve; do 803 for f in /etc/dovecot/sieve_flag.d/*.sieve /etc/dovecot/sieve_before.d/*.sieve; do
781 ${pkgs.dovecot_pigeonhole}/bin/sievec $f 804 ${getExe' pkgs.dovecot_pigeonhole "sievec"} $f
782 done 805 done
783 ''; 806 '';
784 807
@@ -845,15 +868,16 @@ in {
845 charset utf-8; 868 charset utf-8;
846 source_charset utf-8; 869 source_charset utf-8;
847 ''; 870 '';
848 root = pkgs.runCommand "mta-sts.${domain}" {} '' 871 root = pkgs.writeTextFile {
849 mkdir -p $out/.well-known 872 name = "mta-sts.${domain}";
850 cp ${pkgs.writeText "mta-sts.${domain}.txt" '' 873 destination = "/.well-known/mta-sts.txt";
874 text = ''
851 version: STSv1 875 version: STSv1
852 mode: enforce 876 mode: enforce
853 max_age: 2419200 877 max_age: 2419200
854 mx: mailin.${domain} 878 mx: mailin.${domain}
855 ''} $out/.well-known/mta-sts.txt 879 '';
856 ''; 880 };
857 }; 881 };
858 }) emailDomains); 882 }) emailDomains);
859 }; 883 };
@@ -870,7 +894,7 @@ in {
870 systemd.services.spm = { 894 systemd.services.spm = {
871 serviceConfig = { 895 serviceConfig = {
872 Type = "notify"; 896 Type = "notify";
873 ExecStart = "${pkgs.spm}/bin/spm-server"; 897 ExecStart = getExe' pkgs.spm "spm-server";
874 User = "spm"; 898 User = "spm";
875 Group = "spm"; 899 Group = "spm";
876 900
@@ -928,7 +952,7 @@ in {
928 serviceConfig = { 952 serviceConfig = {
929 Type = "notify"; 953 Type = "notify";
930 954
931 ExecStart = "${ccert-policy-server}/bin/ccert-policy-server"; 955 ExecStart = getExe' ccert-policy-server "ccert-policy-server";
932 956
933 Environment = [ 957 Environment = [
934 "PGDATABASE=email" 958 "PGDATABASE=email"
@@ -961,6 +985,53 @@ in {
961 }; 985 };
962 users.groups."postfix-ccert-sender-policy" = {}; 986 users.groups."postfix-ccert-sender-policy" = {};
963 987
988 systemd.sockets."postfix-internal-policy" = {
989 requiredBy = ["postfix.service"];
990 wants = ["postfix-internal-policy.service"];
991 socketConfig = {
992 ListenStream = "/run/postfix-internal-policy.sock";
993 };
994 };
995 systemd.services."postfix-internal-policy" = {
996 after = [ "postgresql.service" ];
997 bindsTo = [ "postgresql.service" ];
998
999 serviceConfig = {
1000 Type = "notify";
1001
1002 ExecStart = lib.getExe internal-policy-server;
1003
1004 Environment = [
1005 "PGDATABASE=email"
1006 ];
1007
1008 DynamicUser = false;
1009 User = "postfix-internal-policy";
1010 Group = "postfix-internal-policy";
1011 ProtectSystem = "strict";
1012 SystemCallFilter = "@system-service";
1013 NoNewPrivileges = true;
1014 ProtectKernelTunables = true;
1015 ProtectKernelModules = true;
1016 ProtectKernelLogs = true;
1017 ProtectControlGroups = true;
1018 MemoryDenyWriteExecute = true;
1019 RestrictSUIDSGID = true;
1020 KeyringMode = "private";
1021 ProtectClock = true;
1022 RestrictRealtime = true;
1023 PrivateDevices = true;
1024 PrivateTmp = true;
1025 ProtectHostname = true;
1026 ReadWritePaths = ["/run/postgresql"];
1027 };
1028 };
1029 users.users."postfix-internal-policy" = {
1030 isSystemUser = true;
1031 group = "postfix-internal-policy";
1032 };
1033 users.groups."postfix-internal-policy" = {};
1034
964 services.postfwd = { 1035 services.postfwd = {
965 enable = true; 1036 enable = true;
966 cache = false; 1037 cache = false;
diff --git a/hosts/surtr/email/internal-policy-server/.envrc b/hosts/surtr/email/internal-policy-server/.envrc
new file mode 100644
index 00000000..2c909235
--- /dev/null
+++ b/hosts/surtr/email/internal-policy-server/.envrc
@@ -0,0 +1,4 @@
1use flake
2
3[[ -d ".venv" ]] || ( uv venv && uv sync )
4. .venv/bin/activate
diff --git a/hosts/surtr/email/internal-policy-server/.gitignore b/hosts/surtr/email/internal-policy-server/.gitignore
new file mode 100644
index 00000000..4ccfae70
--- /dev/null
+++ b/hosts/surtr/email/internal-policy-server/.gitignore
@@ -0,0 +1,2 @@
1.venv
2**/__pycache__
diff --git a/hosts/surtr/email/internal-policy-server/internal_policy_server/__init__.py b/hosts/surtr/email/internal-policy-server/internal_policy_server/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/hosts/surtr/email/internal-policy-server/internal_policy_server/__init__.py
diff --git a/hosts/surtr/email/internal-policy-server/internal_policy_server/__main__.py b/hosts/surtr/email/internal-policy-server/internal_policy_server/__main__.py
new file mode 100644
index 00000000..04f1a59a
--- /dev/null
+++ b/hosts/surtr/email/internal-policy-server/internal_policy_server/__main__.py
@@ -0,0 +1,106 @@
1from systemd.daemon import listen_fds
2from sdnotify import SystemdNotifier
3from socketserver import StreamRequestHandler, ThreadingMixIn
4from systemd_socketserver import SystemdSocketServer
5import sys
6from threading import Thread
7from psycopg_pool import ConnectionPool
8from psycopg.rows import namedtuple_row
9
10import logging
11
12
13class PolicyHandler(StreamRequestHandler):
14 def handle(self):
15 logger.debug('Handling new connection...')
16
17 self.args = dict()
18
19 line = None
20 while line := self.rfile.readline().removesuffix(b'\n'):
21 if b'=' not in line:
22 break
23
24 key, val = line.split(sep=b'=', maxsplit=1)
25 self.args[key.decode()] = val.decode()
26
27 logger.info('Connection parameters: %s', self.args)
28
29 allowed = False
30 user = None
31 if self.args['sasl_username']:
32 user = self.args['sasl_username']
33 if self.args['ccert_subject']:
34 user = self.args['ccert_subject']
35
36 with self.server.db_pool.connection() as conn:
37 local, domain = self.args['recipient'].split(sep='@', maxsplit=1)
38 extension = None
39 if '+' in local:
40 local, extension = local.split(sep='+', maxsplit=1)
41
42 logger.debug('Parsed recipient address: %s', {'local': local, 'extension': extension, 'domain': domain})
43
44 with conn.cursor() as cur:
45 cur.row_factory = namedtuple_row
46 cur.execute('SELECT id, internal FROM "mailbox_mapping" WHERE ("local" = %(local)s OR "local" IS NULL) AND ("extension" = %(extension)s OR "extension" IS NULL) AND "domain" = %(domain)s', params = {'local': local, 'extension': extension if extension is not None else '', 'domain': domain}, prepare = True)
47 if (row := cur.fetchone()) is not None:
48 if not row.internal:
49 logger.debug('Recipient mailbox is not internal')
50 allowed = True
51 elif user:
52 cur.execute('SELECT EXISTS(SELECT true FROM "mailbox_mapping_access" INNER JOIN "mailbox" ON "mailbox".id = "mailbox_mapping_access"."mailbox" WHERE mailbox_mapping = %(mailbox_mapping)s AND "mailbox"."mailbox" = %(user)s) as "exists"', params = { 'mailbox_mapping': row.id, 'user': user }, prepare = True)
53 if (row := cur.fetchone()) is not None:
54 allowed = row.exists
55 else:
56 logger.debug('Recipient is not local')
57 allowed = True
58
59 action = '550 5.7.0 Recipient mailbox mapping not authorized for current user'
60 if allowed:
61 action = 'DUNNO'
62
63 logger.info('Reached verdict: %s', {'allowed': allowed, 'action': action})
64 self.wfile.write(f'action={action}\n\n'.encode())
65
66class ThreadedSystemdSocketServer(ThreadingMixIn, SystemdSocketServer):
67 def __init__(self, fd, RequestHandlerClass):
68 super().__init__(fd, RequestHandlerClass)
69
70 self.db_pool = ConnectionPool(min_size=1)
71 self.db_pool.wait()
72
73def main():
74 global logger
75 logger = logging.getLogger(__name__)
76 console_handler = logging.StreamHandler()
77 console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') )
78 if sys.stderr.isatty():
79 console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') )
80 logger.addHandler(console_handler)
81 logger.setLevel(logging.DEBUG)
82
83 # log uncaught exceptions
84 def log_exceptions(type, value, tb):
85 global logger
86
87 logger.error(value)
88 sys.__excepthook__(type, value, tb) # calls default excepthook
89
90 sys.excepthook = log_exceptions
91
92 fds = listen_fds()
93 servers = [ThreadedSystemdSocketServer(fd, PolicyHandler) for fd in fds]
94
95 if servers:
96 for server in servers:
97 Thread(name=f'Server for fd{server.fileno()}', target=server.serve_forever).start()
98 else:
99 return 2
100
101 SystemdNotifier().notify('READY=1')
102
103 return 0
104
105if __name__ == '__main__':
106 sys.exit(main())
diff --git a/hosts/surtr/email/internal-policy-server/pyproject.toml b/hosts/surtr/email/internal-policy-server/pyproject.toml
new file mode 100644
index 00000000..c697cd01
--- /dev/null
+++ b/hosts/surtr/email/internal-policy-server/pyproject.toml
@@ -0,0 +1,18 @@
1[project]
2name = "internal-policy-server"
3version = "0.1.0"
4requires-python = ">=3.12"
5dependencies = [
6 "psycopg>=3.2.9",
7 "psycopg-binary>=3.2.9",
8 "psycopg-pool>=3.2.6",
9 "sdnotify>=0.3.2",
10 "systemd-socketserver>=1.0",
11]
12
13[project.scripts]
14internal-policy-server = "internal_policy_server.__main__:main"
15
16[build-system]
17requires = ["hatchling"]
18build-backend = "hatchling.build"
diff --git a/hosts/surtr/email/internal-policy-server/uv.lock b/hosts/surtr/email/internal-policy-server/uv.lock
new file mode 100644
index 00000000..f7a4e729
--- /dev/null
+++ b/hosts/surtr/email/internal-policy-server/uv.lock
@@ -0,0 +1,119 @@
1version = 1
2revision = 2
3requires-python = ">=3.12"
4
5[[package]]
6name = "internal-policy-server"
7version = "0.1.0"
8source = { editable = "." }
9dependencies = [
10 { name = "psycopg" },
11 { name = "psycopg-binary" },
12 { name = "psycopg-pool" },
13 { name = "sdnotify" },
14 { name = "systemd-socketserver" },
15]
16
17[package.metadata]
18requires-dist = [
19 { name = "psycopg", specifier = ">=3.2.9" },
20 { name = "psycopg-binary", specifier = ">=3.2.9" },
21 { name = "psycopg-pool", specifier = ">=3.2.6" },
22 { name = "sdnotify", specifier = ">=0.3.2" },
23 { name = "systemd-socketserver", specifier = ">=1.0" },
24]
25
26[[package]]
27name = "psycopg"
28version = "3.2.9"
29source = { registry = "https://pypi.org/simple" }
30dependencies = [
31 { name = "typing-extensions", marker = "python_full_version < '3.13'" },
32 { name = "tzdata", marker = "sys_platform == 'win32'" },
33]
34sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" }
35wheels = [
36 { url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" },
37]
38
39[[package]]
40name = "psycopg-binary"
41version = "3.2.9"
42source = { registry = "https://pypi.org/simple" }
43wheels = [
44 { url = "https://files.pythonhosted.org/packages/29/6f/ec9957e37a606cd7564412e03f41f1b3c3637a5be018d0849914cb06e674/psycopg_binary-3.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e", size = 4022205, upload-time = "2025-05-13T16:07:48.195Z" },
45 { url = "https://files.pythonhosted.org/packages/6b/ba/497b8bea72b20a862ac95a94386967b745a472d9ddc88bc3f32d5d5f0d43/psycopg_binary-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0", size = 4083795, upload-time = "2025-05-13T16:07:50.917Z" },
46 { url = "https://files.pythonhosted.org/packages/42/07/af9503e8e8bdad3911fd88e10e6a29240f9feaa99f57d6fac4a18b16f5a0/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff", size = 4655043, upload-time = "2025-05-13T16:07:54.857Z" },
47 { url = "https://files.pythonhosted.org/packages/28/ed/aff8c9850df1648cc6a5cc7a381f11ee78d98a6b807edd4a5ae276ad60ad/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724", size = 4477972, upload-time = "2025-05-13T16:07:57.925Z" },
48 { url = "https://files.pythonhosted.org/packages/5c/bd/8e9d1b77ec1a632818fe2f457c3a65af83c68710c4c162d6866947d08cc5/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d", size = 4737516, upload-time = "2025-05-13T16:08:01.616Z" },
49 { url = "https://files.pythonhosted.org/packages/46/ec/222238f774cd5a0881f3f3b18fb86daceae89cc410f91ef6a9fb4556f236/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6", size = 4436160, upload-time = "2025-05-13T16:08:04.278Z" },
50 { url = "https://files.pythonhosted.org/packages/37/78/af5af2a1b296eeca54ea7592cd19284739a844974c9747e516707e7b3b39/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40", size = 3753518, upload-time = "2025-05-13T16:08:07.567Z" },
51 { url = "https://files.pythonhosted.org/packages/ec/ac/8a3ed39ea069402e9e6e6a2f79d81a71879708b31cc3454283314994b1ae/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795", size = 3313598, upload-time = "2025-05-13T16:08:09.999Z" },
52 { url = "https://files.pythonhosted.org/packages/da/43/26549af068347c808fbfe5f07d2fa8cef747cfff7c695136172991d2378b/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a", size = 3407289, upload-time = "2025-05-13T16:08:12.66Z" },
53 { url = "https://files.pythonhosted.org/packages/67/55/ea8d227c77df8e8aec880ded398316735add8fda5eb4ff5cc96fac11e964/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4", size = 3472493, upload-time = "2025-05-13T16:08:15.672Z" },
54 { url = "https://files.pythonhosted.org/packages/3c/02/6ff2a5bc53c3cd653d281666728e29121149179c73fddefb1e437024c192/psycopg_binary-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21", size = 2927400, upload-time = "2025-05-13T16:08:18.652Z" },
55 { url = "https://files.pythonhosted.org/packages/28/0b/f61ff4e9f23396aca674ed4d5c9a5b7323738021d5d72d36d8b865b3deaf/psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e", size = 4017127, upload-time = "2025-05-13T16:08:21.391Z" },
56 { url = "https://files.pythonhosted.org/packages/bc/00/7e181fb1179fbfc24493738b61efd0453d4b70a0c4b12728e2b82db355fd/psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5", size = 4080322, upload-time = "2025-05-13T16:08:24.049Z" },
57 { url = "https://files.pythonhosted.org/packages/58/fd/94fc267c1d1392c4211e54ccb943be96ea4032e761573cf1047951887494/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351", size = 4655097, upload-time = "2025-05-13T16:08:27.376Z" },
58 { url = "https://files.pythonhosted.org/packages/41/17/31b3acf43de0b2ba83eac5878ff0dea5a608ca2a5c5dd48067999503a9de/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab", size = 4482114, upload-time = "2025-05-13T16:08:30.781Z" },
59 { url = "https://files.pythonhosted.org/packages/85/78/b4d75e5fd5a85e17f2beb977abbba3389d11a4536b116205846b0e1cf744/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748", size = 4737693, upload-time = "2025-05-13T16:08:34.625Z" },
60 { url = "https://files.pythonhosted.org/packages/3b/95/7325a8550e3388b00b5e54f4ced5e7346b531eb4573bf054c3dbbfdc14fe/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87", size = 4437423, upload-time = "2025-05-13T16:08:37.444Z" },
61 { url = "https://files.pythonhosted.org/packages/1a/db/cef77d08e59910d483df4ee6da8af51c03bb597f500f1fe818f0f3b925d3/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f", size = 3758667, upload-time = "2025-05-13T16:08:40.116Z" },
62 { url = "https://files.pythonhosted.org/packages/95/3e/252fcbffb47189aa84d723b54682e1bb6d05c8875fa50ce1ada914ae6e28/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2", size = 3320576, upload-time = "2025-05-13T16:08:43.243Z" },
63 { url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439, upload-time = "2025-05-13T16:08:47.321Z" },
64 { url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477, upload-time = "2025-05-13T16:08:51.166Z" },
65 { url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009, upload-time = "2025-05-13T16:08:53.67Z" },
66]
67
68[[package]]
69name = "psycopg-pool"
70version = "3.2.6"
71source = { registry = "https://pypi.org/simple" }
72dependencies = [
73 { name = "typing-extensions" },
74]
75sdist = { url = "https://files.pythonhosted.org/packages/cf/13/1e7850bb2c69a63267c3dbf37387d3f71a00fd0e2fa55c5db14d64ba1af4/psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5", size = 29770, upload-time = "2025-02-26T12:03:47.129Z" }
76wheels = [
77 { url = "https://files.pythonhosted.org/packages/47/fd/4feb52a55c1a4bd748f2acaed1903ab54a723c47f6d0242780f4d97104d4/psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7", size = 38252, upload-time = "2025-02-26T12:03:45.073Z" },
78]
79
80[[package]]
81name = "sdnotify"
82version = "0.3.2"
83source = { registry = "https://pypi.org/simple" }
84sdist = { url = "https://files.pythonhosted.org/packages/ce/d8/9fdc36b2a912bf78106de4b3f0de3891ff8f369e7a6f80be842b8b0b6bd5/sdnotify-0.3.2.tar.gz", hash = "sha256:73977fc746b36cc41184dd43c3fe81323e7b8b06c2bb0826c4f59a20c56bb9f1", size = 2459, upload-time = "2017-08-02T20:03:44.395Z" }
85
86[[package]]
87name = "systemd-python"
88version = "235"
89source = { registry = "https://pypi.org/simple" }
90sdist = { url = "https://files.pythonhosted.org/packages/10/9e/ab4458e00367223bda2dd7ccf0849a72235ee3e29b36dce732685d9b7ad9/systemd-python-235.tar.gz", hash = "sha256:4e57f39797fd5d9e2d22b8806a252d7c0106c936039d1e71c8c6b8008e695c0a", size = 61677, upload-time = "2023-02-11T13:42:16.588Z" }
91
92[[package]]
93name = "systemd-socketserver"
94version = "1.0"
95source = { registry = "https://pypi.org/simple" }
96dependencies = [
97 { name = "systemd-python" },
98]
99wheels = [
100 { url = "https://files.pythonhosted.org/packages/d8/4f/b28b7f08880120a26669b080ca74487c8c67e8b54dcb0467a8f0c9f38ed6/systemd_socketserver-1.0-py3-none-any.whl", hash = "sha256:987a8bfbf28d959e7c2966c742ad7bad482f05e121077defcf95bb38267db9a8", size = 3248, upload-time = "2020-04-26T05:26:40.661Z" },
101]
102
103[[package]]
104name = "typing-extensions"
105version = "4.13.2"
106source = { registry = "https://pypi.org/simple" }
107sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
108wheels = [
109 { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
110]
111
112[[package]]
113name = "tzdata"
114version = "2025.2"
115source = { registry = "https://pypi.org/simple" }
116sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
117wheels = [
118 { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
119]