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__.py18
-rw-r--r--hosts/surtr/email/default.nix248
-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, 437 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..45619fb0 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,12 +28,14 @@ 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 and '@' in self.args['sender']:
37 with self.server.db_pool.connection() as conn: 39 with self.server.db_pool.connection() as conn:
38 local, domain = self.args['sender'].split(sep='@', maxsplit=1) 40 local, domain = self.args['sender'].split(sep='@', maxsplit=1)
39 extension = None 41 extension = None
@@ -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 845f6455..b4b2b5c8 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
@@ -108,19 +124,20 @@ in {
108 services.postfix = { 124 services.postfix = {
109 enable = true; 125 enable = true;
110 enableSmtp = false; 126 enableSmtp = false;
111 hostname = "surtr.yggdrasil.li";
112 recipientDelimiter = "";
113 setSendmail = true; 127 setSendmail = true;
114 postmasterAlias = ""; rootAlias = ""; extraAliases = ""; 128 postmasterAlias = ""; rootAlias = ""; extraAliases = "";
115 destination = []; 129 settings.main = {
116 sslCert = "/run/credentials/postfix.service/surtr.yggdrasil.li.pem"; 130 recpipient_delimiter = "";
117 sslKey = "/run/credentials/postfix.service/surtr.yggdrasil.li.key.pem"; 131 mydestination = [];
118 networks = []; 132 mynetworks = [];
119 config = let 133 myhostname = "surtr.yggdrasil.li";
120 relay_ccert = "texthash:${pkgs.writeText "relay_ccert" ""}"; 134
121 in {
122 smtpd_tls_security_level = "may"; 135 smtpd_tls_security_level = "may";
123 136
137 smtpd_tls_chain_files = [
138 "/run/credentials/postfix.service/surtr.yggdrasil.li.full.pem"
139 ];
140
124 #the dh params 141 #the dh params
125 smtpd_tls_dh1024_param_file = toString config.security.dhparams.params."postfix-1024".path; 142 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; 143 smtpd_tls_dh512_param_file = toString config.security.dhparams.params."postfix-512".path;
@@ -155,21 +172,14 @@ in {
155 172
156 smtp_tls_connection_reuse = true; 173 smtp_tls_connection_reuse = true;
157 174
158 tls_server_sni_maps = ''texthash:${pkgs.writeText "sni" ( 175 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 176
165 smtp_tls_policy_maps = "socketmap:unix:${config.services.postfix-mta-sts-resolver.settings.path}:postfix"; 177 smtp_tls_policy_maps = "socketmap:unix:${config.services.postfix-mta-sts-resolver.settings.path}:postfix";
166 178
167 local_recipient_maps = ""; 179 local_recipient_maps = "";
168 180
169 # 10 GiB 181 message_size_limit = 10 * 1024 * 1024 * 1024;
170 message_size_limit = "10737418240"; 182 mailbox_size_limit = 10 * 1024 * 1024 * 1024;
171 # 10 GiB
172 mailbox_size_limit = "10737418240";
173 183
174 smtpd_delay_reject = true; 184 smtpd_delay_reject = true;
175 smtpd_helo_required = true; 185 smtpd_helo_required = true;
@@ -184,12 +194,12 @@ in {
184 dbname = email 194 dbname = email
185 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s' 195 query = SELECT action FROM virtual_mailbox_access WHERE lookup = '%s'
186 ''}" 196 ''}"
187 "check_ccert_access ${relay_ccert}"
188 "reject_non_fqdn_helo_hostname" 197 "reject_non_fqdn_helo_hostname"
189 "reject_invalid_helo_hostname" 198 "reject_invalid_helo_hostname"
190 "reject_unauth_destination" 199 "reject_unauth_destination"
191 "reject_unknown_recipient_domain" 200 "reject_unknown_recipient_domain"
192 "reject_unverified_recipient" 201 "reject_unverified_recipient"
202 "check_policy_service unix:/run/postfix-internal-policy.sock"
193 ]; 203 ];
194 unverified_recipient_reject_code = "550"; 204 unverified_recipient_reject_code = "550";
195 unverified_recipient_reject_reason = "Recipient address lookup failed"; 205 unverified_recipient_reject_reason = "Recipient address lookup failed";
@@ -204,7 +214,6 @@ in {
204 address_verify_sender_ttl = "30045s"; 214 address_verify_sender_ttl = "30045s";
205 215
206 smtpd_relay_restrictions = [ 216 smtpd_relay_restrictions = [
207 "check_ccert_access ${relay_ccert}"
208 "reject_unauth_destination" 217 "reject_unauth_destination"
209 ]; 218 ];
210 219
@@ -227,6 +236,37 @@ in {
227 bounce_queue_lifetime = "20m"; 236 bounce_queue_lifetime = "20m";
228 delay_warning_time = "10m"; 237 delay_warning_time = "10m";
229 238
239 failure_template_file = toString (pkgs.writeText "failure.cf" ''
240 Charset: us-ascii
241 From: Mail Delivery System <MAILER-DAEMON>
242 Subject: Undelivered Mail Returned to Sender
243 Postmaster-Subject: Postmaster Copy: Undelivered Mail
244
245 This is the mail system at host $myhostname.
246
247 I'm sorry to have to inform you that your message could not
248 be delivered to one or more recipients. It's attached below.
249
250 The mail system
251 '');
252 delay_template_file = toString (pkgs.writeText "delay.cf" ''
253 Charset: us-ascii
254 From: Mail Delivery System <MAILER-DAEMON>
255 Subject: Delayed Mail (still being retried)
256 Postmaster-Subject: Postmaster Warning: Delayed Mail
257
258 This is the mail system at host $myhostname.
259
260 ####################################################################
261 # THIS IS A WARNING ONLY. YOU DO NOT NEED TO RESEND YOUR MESSAGE. #
262 ####################################################################
263
264 Your message could not be delivered for more than $delay_warning_time_minutes minute(s).
265 It will be retried until it is $maximal_queue_lifetime_minutes minute(s) old.
266
267 The mail system
268 '');
269
230 smtpd_discard_ehlo_keyword_address_maps = "cidr:${pkgs.writeText "esmtp_access" '' 270 smtpd_discard_ehlo_keyword_address_maps = "cidr:${pkgs.writeText "esmtp_access" ''
231 # Allow DSN requests from local subnet only 271 # Allow DSN requests from local subnet only
232 192.168.0.0/16 silent-discard 272 192.168.0.0/16 silent-discard
@@ -251,13 +291,26 @@ in {
251 virtual_transport = "dvlmtp:unix:/run/dovecot-lmtp"; 291 virtual_transport = "dvlmtp:unix:/run/dovecot-lmtp";
252 smtputf8_enable = false; 292 smtputf8_enable = false;
253 293
254 authorized_submit_users = "inline:{ root= postfwd= }"; 294 authorized_submit_users = "inline:{ root= postfwd= ${config.services.dovecot2.user}= }";
295 authorized_flush_users = "inline:{ root= }";
296 authorized_mailq_users = "inline:{ root= }";
255 297
256 postscreen_access_list = ""; 298 postscreen_access_list = "";
257 postscreen_denylist_action = "drop"; 299 postscreen_denylist_action = "drop";
258 postscreen_greet_action = "enforce"; 300 postscreen_greet_action = "enforce";
301
302 sender_bcc_maps = ''pgsql:${pkgs.writeText "sender_bcc_maps.cf" ''
303 hosts = postgresql:///email
304 dbname = email
305 query = SELECT value FROM sender_bcc_maps WHERE key = '%s'
306 ''}'';
307 recipient_bcc_maps = ''pgsql:${pkgs.writeText "recipient_bcc_maps.cf" ''
308 hosts = postgresql:///email
309 dbname = email
310 query = SELECT value FROM recipient_bcc_maps WHERE key = '%s'
311 ''}'';
259 }; 312 };
260 masterConfig = { 313 settings.master = {
261 "465" = { 314 "465" = {
262 type = "inet"; 315 type = "inet";
263 private = false; 316 private = false;
@@ -280,7 +333,7 @@ in {
280 hosts = postgresql:///email 333 hosts = postgresql:///email
281 dbname = email 334 dbname = email
282 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'))
283 ''},permit_tls_all_clientcerts,reject}'' 336 ''},check_policy_service unix:/run/postfix-internal-policy.sock,permit_tls_all_clientcerts,reject}''
284 "-o" "smtpd_relay_restrictions=permit_tls_all_clientcerts,reject" 337 "-o" "smtpd_relay_restrictions=permit_tls_all_clientcerts,reject"
285 "-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}"
286 "-o" "unverified_sender_reject_code=550" 339 "-o" "unverified_sender_reject_code=550"
@@ -310,7 +363,7 @@ in {
310 hosts = postgresql:///email 363 hosts = postgresql:///email
311 dbname = email 364 dbname = email
312 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')) 365 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'))
313 ''},permit_sasl_authenticated,reject}'' 366 ''},check_policy_service unix:/run/postfix-internal-policy.sock,permit_sasl_authenticated,reject}''
314 "-o" "smtpd_relay_restrictions=permit_sasl_authenticated,reject" 367 "-o" "smtpd_relay_restrictions=permit_sasl_authenticated,reject"
315 "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}" 368 "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}"
316 "-o" "unverified_sender_reject_code=550" 369 "-o" "unverified_sender_reject_code=550"
@@ -325,7 +378,10 @@ in {
325 maxproc = 0; 378 maxproc = 0;
326 args = [ 379 args = [
327 "-o" "header_checks=pcre:${pkgs.writeText "header_checks_submission" '' 380 "-o" "header_checks=pcre:${pkgs.writeText "header_checks_submission" ''
381 if /^Received: /
382 !/by surtr\.yggdrasil\.li/ STRIP
328 /^Received: from [^ ]+ \([^ ]+ [^ ]+\)\s+(.*)$/ REPLACE Received: $1 383 /^Received: from [^ ]+ \([^ ]+ [^ ]+\)\s+(.*)$/ REPLACE Received: $1
384 endif
329 ''}" 385 ''}"
330 ]; 386 ];
331 }; 387 };
@@ -373,7 +429,7 @@ in {
373 enable = true; 429 enable = true;
374 user = "postfix"; group = "postfix"; 430 user = "postfix"; group = "postfix";
375 socket = "local:/run/opendkim/opendkim.sock"; 431 socket = "local:/run/opendkim/opendkim.sock";
376 domains = ''csl:${concatStringsSep "," (["surtr.yggdrasil.li"] ++ emailDomains)}''; 432 domains = ''csl:${concatStringsSep "," (["surtr.yggdrasil.li" "yggdrasil.li" "141.li" "kleen.li" "synapse.li" "praseodym.org"] ++ emailDomains)}'';
377 selector = "surtr"; 433 selector = "surtr";
378 configFile = builtins.toFile "opendkim.conf" '' 434 configFile = builtins.toFile "opendkim.conf" ''
379 Syslog true 435 Syslog true
@@ -477,7 +533,7 @@ in {
477 }; 533 };
478 }; 534 };
479 535
480 users.groups.${config.services.rspamd.group}.members = [ config.services.postfix.user "dovecot2" ]; 536 users.groups.${config.services.rspamd.group}.members = [ config.services.postfix.user config.services.dovecot2.user ];
481 537
482 services.redis.servers.rspamd.enable = true; 538 services.redis.servers.rspamd.enable = true;
483 539
@@ -487,22 +543,22 @@ in {
487 services.dovecot2 = { 543 services.dovecot2 = {
488 enable = true; 544 enable = true;
489 enablePAM = false; 545 enablePAM = false;
490 sslServerCert = "/run/credentials/dovecot2.service/surtr.yggdrasil.li.pem"; 546 sslServerCert = "/run/credentials/dovecot.service/surtr.yggdrasil.li.pem";
491 sslServerKey = "/run/credentials/dovecot2.service/surtr.yggdrasil.li.key.pem"; 547 sslServerKey = "/run/credentials/dovecot.service/surtr.yggdrasil.li.key.pem";
492 sslCACert = toString ./ca/ca.crt; 548 sslCACert = toString ./ca/ca.crt;
493 mailLocation = "maildir:/var/lib/mail/%u/maildir:UTF-8:INDEX=/var/lib/dovecot/indices/%u"; 549 mailLocation = "maildir:/var/lib/mail/%u/maildir:UTF-8:INDEX=/var/lib/dovecot/indices/%u";
494 mailPlugins.globally.enable = [ "fts" "fts_xapian" ]; 550 mailPlugins.globally.enable = [ "fts" "fts_xapian" ];
495 protocols = [ "lmtp" "sieve" ]; 551 protocols = [ "lmtp" "sieve" ];
496 sieve = { 552 sieve = {
497 extensions = ["copy" "imapsieve" "variables" "imap4flags" "vacation"]; 553 extensions = ["copy" "imapsieve" "variables" "imap4flags" "vacation" "vacation-seconds" "vnd.dovecot.debug"];
498 globalExtensions = ["copy" "imapsieve" "variables" "imap4flags" "vacation"]; 554 globalExtensions = ["copy" "imapsieve" "variables" "imap4flags" "vacation" "vacation-seconds" "vnd.dovecot.debug"];
499 }; 555 };
500 extraConfig = let 556 extraConfig = let
501 dovecotSqlConf = pkgs.writeText "dovecot-sql.conf" '' 557 dovecotSqlConf = pkgs.writeText "dovecot-sql.conf" ''
502 driver = pgsql 558 driver = pgsql
503 connect = dbname=email 559 connect = dbname=email
504 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' 560 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'
505 user_query = SELECT "user", quota_rule, 'dovecot2' as uid, 'dovecot2' as gid FROM imap_user WHERE "user" = '%n' 561 user_query = SELECT "user", quota_rule, '${config.services.dovecot2.user}' as uid, 'dovecot2' as gid FROM imap_user WHERE "user" = '%n'
506 iterate_query = SELECT "user" FROM imap_user 562 iterate_query = SELECT "user" FROM imap_user
507 ''; 563 '';
508 in '' 564 in ''
@@ -510,16 +566,16 @@ in {
510 566
511 mail_plugins = $mail_plugins quota 567 mail_plugins = $mail_plugins quota
512 568
513 first_valid_uid = ${toString config.users.users.dovecot2.uid} 569 first_valid_uid = ${toString config.users.users.${config.services.dovecot2.user}.uid}
514 last_valid_uid = ${toString config.users.users.dovecot2.uid} 570 last_valid_uid = ${toString config.users.users.${config.services.dovecot2.user}.uid}
515 first_valid_gid = ${toString config.users.groups.dovecot2.gid} 571 first_valid_gid = ${toString config.users.groups.${config.services.dovecot2.group}.gid}
516 last_valid_gid = ${toString config.users.groups.dovecot2.gid} 572 last_valid_gid = ${toString config.users.groups.${config.services.dovecot2.group}.gid}
517 573
518 ${concatMapStringsSep "\n\n" (domain: 574 ${concatMapStringsSep "\n\n" (domain:
519 concatMapStringsSep "\n" (subdomain: '' 575 concatMapStringsSep "\n" (subdomain: ''
520 local_name ${subdomain} { 576 local_name ${subdomain} {
521 ssl_cert = </run/credentials/dovecot2.service/${subdomain}.pem 577 ssl_cert = </run/credentials/dovecot.service/${subdomain}.pem
522 ssl_key = </run/credentials/dovecot2.service/${subdomain}.key.pem 578 ssl_key = </run/credentials/dovecot.service/${subdomain}.key.pem
523 } 579 }
524 '') ["imap.${domain}" domain] 580 '') ["imap.${domain}" domain]
525 ) emailDomains} 581 ) emailDomains}
@@ -540,10 +596,10 @@ in {
540 auth_debug = yes 596 auth_debug = yes
541 597
542 service auth { 598 service auth {
543 user = dovecot2 599 user = ${config.services.dovecot2.user}
544 } 600 }
545 service auth-worker { 601 service auth-worker {
546 user = dovecot2 602 user = ${config.services.dovecot2.user}
547 } 603 }
548 604
549 userdb { 605 userdb {
@@ -564,7 +620,7 @@ in {
564 args = ${pkgs.writeText "dovecot-sql.conf" '' 620 args = ${pkgs.writeText "dovecot-sql.conf" ''
565 driver = pgsql 621 driver = pgsql
566 connect = dbname=email 622 connect = dbname=email
567 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 623 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
568 ''} 624 ''}
569 625
570 skip = never 626 skip = never
@@ -634,7 +690,7 @@ in {
634 quota_status_success = DUNNO 690 quota_status_success = DUNNO
635 quota_status_nouser = DUNNO 691 quota_status_nouser = DUNNO
636 quota_grace = 10%% 692 quota_grace = 10%%
637 quota_max_mail_size = ${config.services.postfix.config.message_size_limit} 693 quota_max_mail_size = ${toString config.services.postfix.settings.main.message_size_limit}
638 quota_vsizes = yes 694 quota_vsizes = yes
639 } 695 }
640 696
@@ -672,7 +728,7 @@ in {
672 plugin { 728 plugin {
673 plugin = fts fts_xapian 729 plugin = fts fts_xapian
674 fts = xapian 730 fts = xapian
675 fts_xapian = partial=2 full=20 attachments=1 verbose=1 731 fts_xapian = partial=3 full=20 attachments=1 verbose=1
676 732
677 fts_autoindex = yes 733 fts_autoindex = yes
678 734
@@ -687,12 +743,12 @@ in {
687 743
688 systemd.services.dovecot-fts-xapian-optimize = { 744 systemd.services.dovecot-fts-xapian-optimize = {
689 description = "Optimize dovecot indices for fts_xapian"; 745 description = "Optimize dovecot indices for fts_xapian";
690 requisite = [ "dovecot2.service" ]; 746 requisite = [ "dovecot.service" ];
691 after = [ "dovecot2.service" ]; 747 after = [ "dovecot.service" ];
692 startAt = "*-*-* 22:00:00 Europe/Berlin"; 748 startAt = "*-*-* 22:00:00 Europe/Berlin";
693 serviceConfig = { 749 serviceConfig = {
694 Type = "oneshot"; 750 Type = "oneshot";
695 ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A"; 751 ExecStart = "${getExe' pkgs.dovecot "doveadm"} fts optimize -A";
696 PrivateDevices = true; 752 PrivateDevices = true;
697 PrivateNetwork = true; 753 PrivateNetwork = true;
698 ProtectKernelTunables = true; 754 ProtectKernelTunables = true;
@@ -753,31 +809,29 @@ in {
753 809
754 security.acme.rfc2136Domains = { 810 security.acme.rfc2136Domains = {
755 "surtr.yggdrasil.li" = { 811 "surtr.yggdrasil.li" = {
756 restartUnits = [ "postfix.service" "dovecot2.service" ]; 812 restartUnits = [ "postfix.service" "dovecot.service" ];
757 }; 813 };
758 } // listToAttrs (map (domain: nameValuePair "spm.${domain}" { restartUnits = ["nginx.service"]; }) spmDomains) 814 } // listToAttrs (map (domain: nameValuePair "spm.${domain}" { restartUnits = ["nginx.service"]; }) spmDomains)
759 // listToAttrs (concatMap (domain: [ 815 // listToAttrs (concatMap (domain: [
760 (nameValuePair domain { restartUnits = ["postfix.service" "dovecot2.service"]; }) 816 (nameValuePair domain { restartUnits = ["postfix.service" "dovecot.service"]; })
761 (nameValuePair "mailin.${domain}" { restartUnits = ["postfix.service"]; }) 817 (nameValuePair "mailin.${domain}" { restartUnits = ["postfix.service"]; })
762 (nameValuePair "mailsub.${domain}" { restartUnits = ["postfix.service"]; }) 818 (nameValuePair "mailsub.${domain}" { restartUnits = ["postfix.service"]; })
763 (nameValuePair "imap.${domain}" { restartUnits = ["dovecot2.service"]; }) 819 (nameValuePair "imap.${domain}" { restartUnits = ["dovecot.service"]; })
764 (nameValuePair "mta-sts.${domain}" { restartUnits = ["nginx.service"]; }) 820 (nameValuePair "mta-sts.${domain}" { restartUnits = ["nginx.service"]; })
765 ]) emailDomains); 821 ]) emailDomains);
766 822
767 systemd.services.postfix = { 823 systemd.services.postfix = {
768 serviceConfig.LoadCredential = [ 824 serviceConfig.LoadCredential = let
769 "surtr.yggdrasil.li.key.pem:${config.security.acme.certs."surtr.yggdrasil.li".directory}/key.pem" 825 tlsCredential = domain: "${domain}.full.pem:${config.security.acme.certs.${domain}.directory}/full.pem";
770 "surtr.yggdrasil.li.pem:${config.security.acme.certs."surtr.yggdrasil.li".directory}/fullchain.pem" 826 in [
771 ] ++ concatMap (domain: 827 (tlsCredential "surtr.yggdrasil.li")
772 map (subdomain: "${subdomain}.full.pem:${config.security.acme.certs.${subdomain}.directory}/full.pem") 828 ] ++ concatMap (domain: map tlsCredential [domain "mailin.${domain}" "mailsub.${domain}"]) emailDomains;
773 [domain "mailin.${domain}" "mailsub.${domain}"]
774 ) emailDomains;
775 }; 829 };
776 830
777 systemd.services.dovecot2 = { 831 systemd.services.dovecot = {
778 preStart = '' 832 preStart = ''
779 for f in /etc/dovecot/sieve_flag.d/*.sieve /etc/dovecot/sieve_before.d/*.sieve; do 833 for f in /etc/dovecot/sieve_flag.d/*.sieve /etc/dovecot/sieve_before.d/*.sieve; do
780 ${pkgs.dovecot_pigeonhole}/bin/sievec $f 834 ${getExe' pkgs.dovecot_pigeonhole "sievec"} $f
781 done 835 done
782 ''; 836 '';
783 837
@@ -844,15 +898,16 @@ in {
844 charset utf-8; 898 charset utf-8;
845 source_charset utf-8; 899 source_charset utf-8;
846 ''; 900 '';
847 root = pkgs.runCommand "mta-sts.${domain}" {} '' 901 root = pkgs.writeTextFile {
848 mkdir -p $out/.well-known 902 name = "mta-sts.${domain}";
849 cp ${pkgs.writeText "mta-sts.${domain}.txt" '' 903 destination = "/.well-known/mta-sts.txt";
904 text = ''
850 version: STSv1 905 version: STSv1
851 mode: enforce 906 mode: enforce
852 max_age: 2419200 907 max_age: 2419200
853 mx: mailin.${domain} 908 mx: mailin.${domain}
854 ''} $out/.well-known/mta-sts.txt 909 '';
855 ''; 910 };
856 }; 911 };
857 }) emailDomains); 912 }) emailDomains);
858 }; 913 };
@@ -869,7 +924,7 @@ in {
869 systemd.services.spm = { 924 systemd.services.spm = {
870 serviceConfig = { 925 serviceConfig = {
871 Type = "notify"; 926 Type = "notify";
872 ExecStart = "${pkgs.spm}/bin/spm-server"; 927 ExecStart = getExe' pkgs.spm "spm-server";
873 User = "spm"; 928 User = "spm";
874 Group = "spm"; 929 Group = "spm";
875 930
@@ -927,7 +982,7 @@ in {
927 serviceConfig = { 982 serviceConfig = {
928 Type = "notify"; 983 Type = "notify";
929 984
930 ExecStart = "${ccert-policy-server}/bin/ccert-policy-server"; 985 ExecStart = getExe' ccert-policy-server "ccert-policy-server";
931 986
932 Environment = [ 987 Environment = [
933 "PGDATABASE=email" 988 "PGDATABASE=email"
@@ -960,6 +1015,53 @@ in {
960 }; 1015 };
961 users.groups."postfix-ccert-sender-policy" = {}; 1016 users.groups."postfix-ccert-sender-policy" = {};
962 1017
1018 systemd.sockets."postfix-internal-policy" = {
1019 requiredBy = ["postfix.service"];
1020 wants = ["postfix-internal-policy.service"];
1021 socketConfig = {
1022 ListenStream = "/run/postfix-internal-policy.sock";
1023 };
1024 };
1025 systemd.services."postfix-internal-policy" = {
1026 after = [ "postgresql.service" ];
1027 bindsTo = [ "postgresql.service" ];
1028
1029 serviceConfig = {
1030 Type = "notify";
1031
1032 ExecStart = lib.getExe internal-policy-server;
1033
1034 Environment = [
1035 "PGDATABASE=email"
1036 ];
1037
1038 DynamicUser = false;
1039 User = "postfix-internal-policy";
1040 Group = "postfix-internal-policy";
1041 ProtectSystem = "strict";
1042 SystemCallFilter = "@system-service";
1043 NoNewPrivileges = true;
1044 ProtectKernelTunables = true;
1045 ProtectKernelModules = true;
1046 ProtectKernelLogs = true;
1047 ProtectControlGroups = true;
1048 MemoryDenyWriteExecute = true;
1049 RestrictSUIDSGID = true;
1050 KeyringMode = "private";
1051 ProtectClock = true;
1052 RestrictRealtime = true;
1053 PrivateDevices = true;
1054 PrivateTmp = true;
1055 ProtectHostname = true;
1056 ReadWritePaths = ["/run/postgresql"];
1057 };
1058 };
1059 users.users."postfix-internal-policy" = {
1060 isSystemUser = true;
1061 group = "postfix-internal-policy";
1062 };
1063 users.groups."postfix-internal-policy" = {};
1064
963 services.postfwd = { 1065 services.postfwd = {
964 enable = true; 1066 enable = true;
965 cache = false; 1067 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]