Compare commits

..

5 Commits

Author SHA1 Message Date
link2xt
f896ce6c75 feat: add tool to analyze deferred queue
It prints all destinations with the number of recipients
and all the reasons. Operator can then try
to fix the problems for destinations,
e.g. by manually adding reverse proxy
addresses to /etc/hosts for failing domains
or routing IP addresses to another interface.
2026-05-12 19:24:20 +02:00
holger krekel
8db668c037 fix(logging): log all http requests to syslog 2026-05-10 23:32:42 +02:00
holger krekel
45fafa10a9 fix: legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all. 2026-05-08 21:39:40 +02:00
missytake
ee435a7ef7 fix(dns): query correct NS if MNAME server is hidden (#954)
replaces #870
fix #851

* fix(dns): address possible IndexError
* fix(dns): remove redundant docstring
* fix(dns): don't make NS explicit if None
* bump cmlxc to 0.13.5 which fixes a powerdns config issue
* remove the unneccessary SOA mocks, simplify mock tests, and run ruff format

Co-authored-by: holger krekel <holger@merlinux.eu>
2026-05-08 19:34:42 +02:00
missytake
8fafd4e79f fix(nginx): properly redirect www to mail_domain 2026-05-07 23:00:02 +02:00
8 changed files with 74 additions and 21 deletions

View File

@@ -24,6 +24,7 @@ chatmail-metadata = "chatmaild.metadata:main"
chatmail-expire = "chatmaild.expire:daily_expire_main" chatmail-expire = "chatmaild.expire:daily_expire_main"
chatmail-quota-expire = "chatmaild.expire:quota_expire_main" chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
chatmail-deferred = "chatmaild.deferred:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main" turnserver = "chatmaild.turnserver:main"

View File

@@ -0,0 +1,39 @@
"""
Analyze deferred mails and print most common failing destinations.
Example:
python -m chatmaild.deferred
"""
import json
import subprocess
from collections import Counter, defaultdict
def main():
p = subprocess.Popen(["postqueue", "-j"], text=True, stdout=subprocess.PIPE)
domain_reasons = defaultdict(Counter)
domain_total = Counter()
for line in p.stdout:
item = json.loads(line)
if item["queue_name"] != "deferred":
continue
for recipient in item["recipients"]:
_, domain = recipient["address"].rsplit("@", 1)
reason = recipient["delay_reason"].removeprefix(
"host 127.0.0.1[127.0.0.1] said: "
)
domain_total[domain] += 1
domain_reasons[domain][reason] += 1
for domain, total in reversed(domain_total.most_common()):
print(f"{domain} ({total} recipients)")
for reason, count in domain_reasons[domain].most_common():
print(f" {count}: {reason}")
if __name__ == "__main__":
main()

View File

@@ -70,6 +70,9 @@ class Metadata:
# Some tokens have expired, remove them. # Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens: with self._modify_tokens(addr) as _tokens:
pass pass
elif isinstance(tokens, list):
with self._modify_tokens(addr) as tokens:
token_list = list(tokens.keys())
else: else:
token_list = [] token_list = []
return token_list return token_list

View File

@@ -372,3 +372,14 @@ def test_iroh_relay(dictproxy):
dictproxy.iroh_relay = "https://example.org/" dictproxy.iroh_relay = "https://example.org/"
dictproxy.loop_forever(rfile, wfile) dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
assert metadata.get_tokens_for_addr(testaddr) == ["oldtoken1", "oldtoken2"]
mdict = metadata.get_metadata_dict(testaddr).read()
tokens = mdict[metadata.DEVICETOKEN_KEY]
assert isinstance(tokens, dict)
assert "oldtoken1" in tokens and "oldtoken2" in tokens

View File

@@ -42,6 +42,9 @@ stream {
} }
http { http {
# access_log setting is inherited by all server sections
access_log syslog:server=unix:/dev/log,facility=local7;
{% if config.tls_cert_mode == "self" %} {% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %} {% endif %}
@@ -69,9 +72,7 @@ http {
index index.html index.htm; index index.html index.htm;
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
access_log syslog:server=unix:/dev/log,facility=local7;
location /mxdeliv { location /mxdeliv {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }}; proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
@@ -143,7 +144,6 @@ http {
listen 127.0.0.1:8443 ssl; listen 127.0.0.1:8443 ssl;
server_name www.{{ config.mail_domain }}; server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri; return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
} }
server { server {

View File

@@ -71,7 +71,7 @@ def get_authoritative_ns(domain):
f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress
).split("\n") ).split("\n")
] ]
filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] in ("SOA", "NS")] filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] == "NS"]
if not filtered_replies: if not filtered_replies:
return return
return filtered_replies[0][4] return filtered_replies[0][4]

View File

@@ -281,3 +281,13 @@ def test_deployed_state(remote):
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output() # assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)): for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes." assert git_status[i] == remote_version[i], "You have undeployed changes."
def test_nginx_access_log_only_defined_once(sshdomain):
sshexec = get_sshexec(sshdomain)
conf = sshexec(
call=remote.rshell.shell,
kwargs=dict(command="nginx -T 2>/dev/null"),
)
access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")]
assert len(access_logs) == 1, f"expected 1 access_log, found {len(access_logs)}: {access_logs}"

View File

@@ -15,26 +15,15 @@ def mockdns_base(monkeypatch):
if command.startswith("dig"): if command.startswith("dig"):
if command == "dig": if command == "dig":
return "." return "."
if "with.public.soa" in command: if "with.public.soa" in command and "NS" in command:
return ( return "domain.with.public.soa. 2419 IN NS ns1.first-ns.de."
"domain.with.public.soa. 2419 IN SOA ns1.first-ns.de. dns.hetzner.com."
" 2026050300 7200 1800 604800 3600"
)
if "with.hidden.soa" in command and "SOA" in command:
return (
"domain.with.hidden.soa. 300 IN SOA get.desec.io. get.desec.io."
" 2026025451 86400 3600 2419200 3600"
)
if "with.hidden.soa" in command and "NS" in command: if "with.hidden.soa" in command and "NS" in command:
return ( return (
"domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n" "domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n"
"domain.with.hidden.soa. 2137 IN NS ns2.desec.org." "domain.with.hidden.soa. 2137 IN NS ns2.desec.org."
) )
if "NS" in command: if "NS" in command:
return ( return "delta.chat. 21600 IN NS ns1.first-ns.de."
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
" 2025102800 14400 1800 604800 3600"
)
command_chunks = command.split() command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6] domain, typ = command_chunks[4], command_chunks[6]
try: try:
@@ -145,8 +134,8 @@ class TestPerformInitialChecks:
("domain", "ns"), ("domain", "ns"),
[ [
("domain.with.public.soa", "ns1.first-ns.de."), ("domain.with.public.soa", "ns1.first-ns.de."),
("domain.with.hidden.soa", "ns1.desec.io.") ("domain.with.hidden.soa", "ns1.desec.io."),
] ],
) )
def test_get_authoritative_ns(domain, ns, mockdns): def test_get_authoritative_ns(domain, ns, mockdns):
assert get_authoritative_ns(domain) == ns assert get_authoritative_ns(domain) == ns