Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt 21d105d41f Store raw tokens instead of dictionaries in metadata 2024-03-24 02:12:16 +00:00
18 changed files with 162 additions and 439 deletions
+20 -26
View File
@@ -4,17 +4,12 @@ on:
push: push:
branches: branches:
- main - main
pull_request: - staging-ci
paths-ignore:
- 'scripts/**'
jobs: jobs:
deploy: deploy:
name: deploy on staging.testrun.org, and run tests name: deploy on staging.testrun.org, and run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: staging-deploy
cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -24,45 +19,44 @@ jobs:
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519 echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts
rsync -avz root@staging.testrun.org:/var/lib/acme . || true # rsync -avz root@staging.testrun.org:/var/lib/acme . || true
rsync -avz root@staging.testrun.org:/etc/dkimkeys . || true # rsync -avz root@staging.testrun.org:/var/lib/rspamd/dkim . || true
- name: rebuild staging.testrun.org to have a clean VPS #- name: rebuild staging.testrun.org to have a clean VPS
run: | # run: |
curl -X POST \ # curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \ # -H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \ # -H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \ # -d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild" # "https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh - run: scripts/initenv.sh
- name: append venv/bin to PATH - name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
rsync -avz acme/ root@staging.testrun.org:/var/lib/acme || true
rsync -avz dkimkeys/ root@staging.testrun.org:/etc/dkimkeys || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown root:root -R /var/lib/acme
- name: run formatting checks - name: run formatting checks
run: cmdeploy fmt -v run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
#- name: upload TLS cert after rebuilding
# run: |
# echo " --- wait until staging.testrun.org VPS is rebuilt --- "
# rm ~/.ssh/known_hosts
# while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
# ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
# rsync -avz acme root@staging.testrun.org:/var/lib/ || true
# rsync -avz dkim root@staging.testrun.org:/var/lib/rspamd/ || true
- run: cmdeploy init staging.testrun.org - run: cmdeploy init staging.testrun.org
- run: cmdeploy run - run: cmdeploy run
- name: set DNS entries - name: set DNS entries
run: | run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown opendkim:opendkim -R /etc/dkimkeys #ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown _rspamd:_rspamd -R /var/lib/rspamd/dkim
cmdeploy dns --zonefile staging-generated.zone cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone cat .github/workflows/staging.testrun.org-default.zone
-40
View File
@@ -1,40 +0,0 @@
# Changelog for chatmail deployment
## untagged
- better preserve notification order
([#263](https://github.com/deltachat/chatmail/pull/263))
- re-enable running the CI in pull requests, but not concurrently
([#258](https://github.com/deltachat/chatmail/pull/258))
## 1.1.0 - 2024-03-28
### The changelog starts to record changes from March 15th, 2024
- Move systemd unit templates to cmdeploy package
([#255](https://github.com/deltachat/chatmail/pull/255))
- Persist push tokens and support multiple device per address
([#254](https://github.com/deltachat/chatmail/pull/254))
- Avoid warning for regular doveauth protocol's hello message.
([#250](https://github.com/deltachat/chatmail/pull/250))
- Fix various tests to pass again with "cmdeploy test".
([#245](https://github.com/deltachat/chatmail/pull/245),
[#242](https://github.com/deltachat/chatmail/pull/242)
- Ensure lets-encrypt certificates are reloaded after renewal
([#244]) https://github.com/deltachat/chatmail/pull/244
- Persist tokens to avoid iOS users loosing push-notifications when the
chatmail metadata service is restarted (happens regularly during deploys)
([#238](https://github.com/deltachat/chatmail/pull/239)
- Fix failing sieve-script compile errors on incoming messages
([#237](https://github.com/deltachat/chatmail/pull/239)
- Fix quota reporting after expunging of old mails
([#233](https://github.com/deltachat/chatmail/pull/239)
-1
View File
@@ -10,7 +10,6 @@ dependencies = [
"iniconfig", "iniconfig",
"deltachat-rpc-server", "deltachat-rpc-server",
"deltachat-rpc-client", "deltachat-rpc-client",
"filelock",
"requests", "requests",
] ]
@@ -2,7 +2,7 @@
Description=Chatmail dict proxy for IMAP METADATA Description=Chatmail dict proxy for IMAP METADATA
[Service] [Service]
ExecStart={execpath} /run/dovecot/metadata.socket vmail /home/vmail/mail/{mail_domain} ExecStart={execpath} /run/dovecot/metadata.socket vmail {config_path} /home/vmail/metadata
Restart=always Restart=always
RestartSec=30 RestartSec=30
+7 -17
View File
@@ -17,10 +17,6 @@ from .config import read_config, Config
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
class UnknownCommand(ValueError):
"""dictproxy handler received an unkown command"""
def encrypt_password(password: str): def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ # https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512) passhash = crypt.crypt(password, crypt.METHOD_SHA512)
@@ -131,12 +127,8 @@ def split_and_unescape(s):
def handle_dovecot_request(msg, db, config: Config): def handle_dovecot_request(msg, db, config: Config):
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
short_command = msg[0] short_command = msg[0]
if short_command == "H": # HELLO if short_command == "L": # LOOKUP
# we don't do any checking on versions and just return
return
elif short_command == "L": # LOOKUP
parts = msg[1:].split("\t") parts = msg[1:].split("\t")
# Dovecot <2.3.17 has only one part, # Dovecot <2.3.17 has only one part,
@@ -167,7 +159,7 @@ def handle_dovecot_request(msg, db, config: Config):
reply_command = "N" reply_command = "N"
json_res = json.dumps(res) if res else "" json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n" return f"{reply_command}{json_res}\n"
raise UnknownCommand(msg) return None
def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config): def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
@@ -175,14 +167,12 @@ def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
msg = rfile.readline().strip().decode() msg = rfile.readline().strip().decode()
if not msg: if not msg:
break break
try: res = handle_dovecot_request(msg, db, config)
res = handle_dovecot_request(msg, db, config) if res:
except UnknownCommand: wfile.write(res.encode("ascii"))
logging.warning("unknown command: %r", msg) wfile.flush()
else: else:
if res: logging.warning("request had no answer: %r", msg)
wfile.write(res.encode("ascii"))
wfile.flush()
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer): class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
+4 -7
View File
@@ -18,14 +18,14 @@ hooks = events.HookCollection()
@hooks.on(events.RawEvent) @hooks.on(events.RawEvent)
def log_event(event): def log_event(event):
if event.kind == EventType.INFO: if event.kind == EventType.INFO:
logging.info("%s", event.msg) logging.info(event.msg)
elif event.kind == EventType.WARNING: elif event.kind == EventType.WARNING:
logging.warning("%s", event.msg) logging.warning(event.msg)
@hooks.on(events.RawEvent(EventType.ERROR)) @hooks.on(events.RawEvent(EventType.ERROR))
def log_error(event): def log_error(event):
logging.error("%s", event.msg) logging.error(event.msg)
@hooks.on(events.MemberListChanged) @hooks.on(events.MemberListChanged)
@@ -48,9 +48,6 @@ def on_group_name_changed(event):
@hooks.on(events.NewMessage(func=lambda e: not e.command)) @hooks.on(events.NewMessage(func=lambda e: not e.command))
def echo(event): def echo(event):
snapshot = event.message_snapshot snapshot = event.message_snapshot
if snapshot.is_info:
# Ignore info messages
return
if snapshot.text or snapshot.file: if snapshot.text or snapshot.file:
snapshot.chat.send_message(text=snapshot.text, file=snapshot.file) snapshot.chat.send_message(text=snapshot.text, file=snapshot.file)
@@ -62,7 +59,6 @@ def help_command(event):
def main(): def main():
logging.basicConfig(level=logging.INFO)
path = os.environ.get("PATH") path = os.environ.get("PATH")
venv_path = sys.argv[0].strip("echobot") venv_path = sys.argv[0].strip("echobot")
os.environ["PATH"] = path + ":" + venv_path os.environ["PATH"] = path + ":" + venv_path
@@ -84,4 +80,5 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main() main()
-35
View File
@@ -1,35 +0,0 @@
import os
import logging
import json
import filelock
from contextlib import contextmanager
class FileDict:
"""Concurrency-safe multi-reader/single-writer persistent dict."""
def __init__(self, path):
self.path = path
self.lock_path = path.with_name(path.name + ".lock")
@contextmanager
def modify(self):
# the OS will release the lock if the process dies,
# and the contextmanager will otherwise guarantee release
with filelock.FileLock(self.lock_path):
data = self.read()
yield data
write_path = self.path.with_name(self.path.name + ".tmp")
with write_path.open("w") as f:
json.dump(data, f)
os.rename(write_path, self.path)
def read(self):
try:
with self.path.open("r") as f:
return json.load(f)
except FileNotFoundError:
return {}
except Exception:
logging.warning("corrupt serialization state at: %r", self.path)
return {}
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Chatmail Postfix before queue filter Description=Chatmail Postfix BeforeQeue filter
[Service] [Service]
ExecStart={execpath} {config_path} ExecStart={execpath} {config_path}
+53 -90
View File
@@ -1,87 +1,59 @@
import pwd import pwd
from pathlib import Path from pathlib import Path
from threading import Thread
from queue import Queue from queue import Queue
from threading import Thread
from socketserver import ( from socketserver import (
UnixStreamServer, UnixStreamServer,
StreamRequestHandler, StreamRequestHandler,
ThreadingMixIn, ThreadingMixIn,
) )
from .config import read_config
import sys import sys
import logging import logging
import os import os
import requests import requests
from .filedict import FileDict
DICTPROXY_HELLO_CHAR = "H"
DICTPROXY_LOOKUP_CHAR = "L" DICTPROXY_LOOKUP_CHAR = "L"
DICTPROXY_ITERATE_CHAR = "I" DICTPROXY_ITERATE_CHAR = "I"
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
DICTPROXY_SET_CHAR = "S" DICTPROXY_SET_CHAR = "S"
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C" DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
DICTPROXY_TRANSACTION_CHARS = "BSC" DICTPROXY_TRANSACTION_CHARS = "SBC"
# each SETMETADATA on this key appends to a list of unique device tokens
# which only ever get removed if the upstream indicates the token is invalid
METADATA_TOKEN_KEY = "devicetoken"
class Notifier: class Notifier:
def __init__(self, vmail_dir): def __init__(self, metadata_dir):
self.vmail_dir = vmail_dir self.metadata_dir = metadata_dir
self.notification_dir = vmail_dir / "pending_notifications" self.to_notify_queue = Queue()
if not self.notification_dir.exists():
self.notification_dir.mkdir()
self.notification_queue = Queue()
def get_metadata_dict(self, addr): def set_token(self, guid, token):
return FileDict(self.vmail_dir / addr / "metadata.json") guid_path = self.metadata_dir.joinpath(guid)
write_path = guid_path.with_suffix(".tmp")
write_path.write_text(token)
write_path.rename(guid_path)
def add_token(self, addr, token): def del_token(self, guid):
with self.get_metadata_dict(addr).modify() as data: self.metadata_dir.joinpath(guid).unlink(missing_ok=True)
tokens = data.get(METADATA_TOKEN_KEY)
if tokens is None:
data[METADATA_TOKEN_KEY] = [token]
elif token not in tokens:
tokens.append(token)
def remove_token(self, addr, token): def get_token(self, guid):
with self.get_metadata_dict(addr).modify() as data: guid_path = self.metadata_dir / guid
tokens = data.get(METADATA_TOKEN_KEY, []) if guid_path.exists():
try: return guid_path.read_text()
tokens.remove(token)
except ValueError:
pass
def get_tokens(self, addr): def new_message_for_guid(self, guid):
return self.get_metadata_dict(addr).read().get(METADATA_TOKEN_KEY, []) self.to_notify_queue.put(guid)
def new_message_for_addr(self, addr):
self.notification_dir.joinpath(addr).touch()
self.notification_queue.put(addr)
def thread_run_loop(self): def thread_run_loop(self):
requests_session = requests.Session() requests_session = requests.Session()
# on startup deliver all persisted notifications from last process run
self.notification_queue.put(None)
while 1: while 1:
self.thread_run_one(requests_session) self.thread_run_one(requests_session)
def thread_run_one(self, requests_session): def thread_run_one(self, requests_session):
addr = self.notification_queue.get() guid = self.to_notify_queue.get()
if addr is None: token = self.get_token(guid)
# startup, notify any "pending" notifications from last run if token:
for addr_path in self.notification_dir.iterdir():
if "@" in addr_path.name:
self.notify_tokens_for(requests_session, addr_path.name)
else:
self.notify_tokens_for(requests_session, addr)
def notify_tokens_for(self, requests_session, addr):
for token in self.get_tokens(addr):
response = requests_session.post( response = requests_session.post(
"https://notifications.delta.chat/notify", "https://notifications.delta.chat/notify",
data=token, data=token,
@@ -90,11 +62,13 @@ class Notifier:
if response.status_code == 410: if response.status_code == 410:
# 410 Gone status code # 410 Gone status code
# means the token is no longer valid. # means the token is no longer valid.
self.remove_token(addr, token) self.del_token(guid)
self.notification_dir.joinpath(addr).unlink(missing_ok=True)
def handle_dovecot_protocol(rfile, wfile, notifier): def handle_dovecot_protocol(rfile, wfile, notifier):
# HELLO message, ignored.
msg = rfile.readline().strip().decode()
transactions = {} transactions = {}
while True: while True:
msg = rfile.readline().strip().decode() msg = rfile.readline().strip().decode()
@@ -112,52 +86,44 @@ def handle_dovecot_request(msg, transactions, notifier):
short_command = msg[0] short_command = msg[0]
parts = msg[1:].split("\t") parts = msg[1:].split("\t")
if short_command == DICTPROXY_LOOKUP_CHAR: if short_command == DICTPROXY_LOOKUP_CHAR:
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
keyparts = parts[0].split("/")
if keyparts[0] == "priv":
keyname = keyparts[2]
addr = parts[1]
if keyname == METADATA_TOKEN_KEY:
res = " ".join(notifier.get_tokens(addr))
return f"O{res}\n"
logging.warning("lookup ignored: %r", msg)
return "N\n" return "N\n"
elif short_command == DICTPROXY_ITERATE_CHAR: elif short_command == DICTPROXY_ITERATE_CHAR:
# Empty line means ITER_FINISHED. # Empty line means ITER_FINISHED.
# If we don't return empty line Dovecot will timeout. # If we don't return empty line Dovecot will timeout.
return "\n" return "\n"
elif short_command == DICTPROXY_HELLO_CHAR:
return # no version checking
if short_command not in (DICTPROXY_TRANSACTION_CHARS): if short_command not in (DICTPROXY_TRANSACTION_CHARS):
logging.warning("unknown dictproxy request: %r", msg)
return return
transaction_id = parts[0] transaction_id = parts[0]
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR: if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
addr = parts[1] transactions[transaction_id] = "O\n"
transactions[transaction_id] = dict(addr=addr, res="O\n")
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR: elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
# each set devicetoken operation persists directly # returns whether it failed or succeeded.
# and does not wait until a "commit" comes return transactions.pop(transaction_id, "N\n")
# because our dovecot config does not involve
# multiple set-operations in a single commit
return transactions.pop(transaction_id)["res"]
elif short_command == DICTPROXY_SET_CHAR: elif short_command == DICTPROXY_SET_CHAR:
# For documentation on key structure see # See header of
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # <https://github.com/dovecot/core/blob/5e7965632395793d9355eb906b173bf28d2a10ca/src/lib-storage/mailbox-attribute.h>
# for the documentation on the structure of the key.
# Request GETMETADATA "INBOX" /private/chatmail
# results in a query for
# priv/dd72550f05eadc65542a1200cac67ad7/chatmail
#
# Request GETMETADATA "" /private/chatmail
# results in
# priv/dd72550f05eadc65542a1200cac67ad7/vendor/vendor.dovecot/pvt/server/chatmail
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
addr = transactions[transaction_id]["addr"] if keyname[0] == "priv" and keyname[2] == "devicetoken":
if keyname[0] == "priv" and keyname[2] == METADATA_TOKEN_KEY: notifier.set_token(keyname[1], value)
notifier.add_token(addr, value)
elif keyname[0] == "priv" and keyname[2] == "messagenew": elif keyname[0] == "priv" and keyname[2] == "messagenew":
notifier.new_message_for_addr(addr) notifier.new_message_for_guid(keyname[1])
else: else:
# Transaction failed. # Transaction failed.
transactions[transaction_id]["res"] = "F\n" transactions[transaction_id] = "F\n"
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer): class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
@@ -165,23 +131,22 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main(): def main():
socket, username, vmail_dir = sys.argv[1:] socket, username, config, metadata_dir = sys.argv[1:]
passwd_entry = pwd.getpwnam(username) passwd_entry = pwd.getpwnam(username)
vmail_dir = Path(vmail_dir) # XXX config is not currently used
config = read_config(config)
if not vmail_dir.exists(): metadata_dir = Path(metadata_dir)
logging.error("vmail dir does not exist: %r", vmail_dir) if not metadata_dir.exists():
return 1 metadata_dir.mkdir()
notifier = Notifier(metadata_dir)
notifier = Notifier(vmail_dir)
class Handler(StreamRequestHandler): class Handler(StreamRequestHandler):
def handle(self): def handle(self):
try: try:
handle_dovecot_protocol(self.rfile, self.wfile, notifier) handle_dovecot_protocol(self.rfile, self.wfile, notifier)
except Exception: except Exception:
logging.exception("Exception in the dovecot dictproxy handler") logging.exception("Exception in the handler")
raise raise
try: try:
@@ -195,8 +160,6 @@ def main():
t = Thread(target=notifier.thread_run_loop) t = Thread(target=notifier.thread_run_loop)
t.setDaemon(True) t.setDaemon(True)
t.start() t.start()
# let notifier thread run once for any pending notifications from last run
notifier.message_arrived_event.set()
with ThreadedUnixStreamServer(socket, Handler) as server: with ThreadedUnixStreamServer(socket, Handler) as server:
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid) os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
@@ -75,14 +75,6 @@ def test_handle_dovecot_request(db, example_config):
assert userdata["password"].startswith("{SHA512-CRYPT}") assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog):
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol(db, example_config): def test_handle_dovecot_protocol(db, example_config):
rfile = io.BytesIO( rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n" b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
@@ -1,19 +0,0 @@
from chatmaild.filedict import FileDict
def test_basic(tmp_path):
fdict = FileDict(tmp_path.joinpath("metadata"))
assert fdict.read() == {}
with fdict.modify() as d:
d["devicetoken"] = [1, 2, 3]
d["456"] = 4.2
new = fdict.read()
assert new["devicetoken"] == [1, 2, 3]
assert new["456"] == 4.2
def test_bad_marshal_file(tmp_path, caplog):
fdict1 = FileDict(tmp_path.joinpath("metadata"))
fdict1.path.write_bytes(b"l12k3l12k3l")
assert fdict1.read() == {}
assert "corrupt" in caplog.records[0].msg
+50 -117
View File
@@ -10,84 +10,67 @@ from chatmaild.metadata import (
@pytest.fixture @pytest.fixture
def notifier(tmp_path): def notifier(tmp_path):
vmail_dir = tmp_path.joinpath("vmaildir") metadata_dir = tmp_path.joinpath("metadata")
vmail_dir.mkdir() metadata_dir.mkdir()
return Notifier(vmail_dir) return Notifier(metadata_dir)
@pytest.fixture def test_notifier_persistence(tmp_path):
def testaddr(): metadata_dir = tmp_path.joinpath("metadata")
return "user.name@example.org" metadata_dir.mkdir()
notifier1 = Notifier(metadata_dir)
notifier2 = Notifier(metadata_dir)
assert notifier1.get_token(guid="guid00") is None
assert notifier2.get_token(guid="guid00") is None
notifier1.set_token("guid00", "01234")
notifier1.set_token("guid03", "456")
assert notifier2.get_token("guid00") == "01234"
assert notifier2.get_token("guid03") == "456"
notifier2.del_token("guid00")
assert notifier1.get_token("guid00") is None
@pytest.fixture def test_handle_dovecot_request_lookup_fails(notifier):
def testaddr2(): res = handle_dovecot_request("Lpriv/123/chatmail", {}, notifier)
return "user2@example.org"
def test_notifier_persistence(tmp_path, testaddr, testaddr2):
notifier1 = Notifier(tmp_path)
notifier2 = Notifier(tmp_path)
assert not notifier1.get_tokens(testaddr)
assert not notifier2.get_tokens(testaddr)
notifier1.add_token(testaddr, "01234")
notifier1.add_token(testaddr2, "456")
assert notifier2.get_tokens(testaddr) == ["01234"]
assert notifier2.get_tokens(testaddr2) == ["456"]
notifier2.remove_token(testaddr, "01234")
assert not notifier1.get_tokens(testaddr)
assert notifier1.get_tokens(testaddr2) == ["456"]
def test_remove_nonexisting(tmp_path, testaddr):
notifier1 = Notifier(tmp_path)
notifier1.add_token(testaddr, "123")
notifier1.remove_token(testaddr, "1l23k1l2k3")
assert notifier1.get_tokens(testaddr) == ["123"]
def test_notifier_delete_without_set(notifier, testaddr):
notifier.remove_token(testaddr, "123")
assert not notifier.get_tokens(testaddr)
def test_handle_dovecot_request_lookup_fails(notifier, testaddr):
res = handle_dovecot_request(f"Lpriv/123/chatmail\t{testaddr}", {}, notifier)
assert res == "N\n" assert res == "N\n"
def test_handle_dovecot_request_happy_path(notifier, testaddr): def test_handle_dovecot_request_happy_path(notifier):
transactions = {} transactions = {}
# lookups return the same NOTFOUND result
res = handle_dovecot_request("Lpriv/123/chatmail", transactions, notifier)
assert res == "N\n"
assert notifier.get_token("guid00") is None and not transactions
# set device token in a transaction # set device token in a transaction
tx = "1111" tx = "1111"
msg = f"B{tx}\t{testaddr}" msg = f"B{tx}\tuser"
res = handle_dovecot_request(msg, transactions, notifier) res = handle_dovecot_request(msg, transactions, notifier)
assert not res and not notifier.get_tokens(testaddr) assert not res and notifier.get_token("guid00") is None
assert transactions == {tx: dict(addr=testaddr, res="O\n")} assert transactions == {tx: "O\n"}
msg = f"S{tx}\tpriv/guid00/devicetoken\t01234" msg = f"S{tx}\tpriv/guid00/devicetoken\t01234"
res = handle_dovecot_request(msg, transactions, notifier) res = handle_dovecot_request(msg, transactions, notifier)
assert not res assert not res
assert len(transactions) == 1 assert len(transactions) == 1
assert notifier.get_tokens(testaddr) == ["01234"] assert notifier.get_token("guid00") == "01234"
msg = f"C{tx}" msg = f"C{tx}"
res = handle_dovecot_request(msg, transactions, notifier) res = handle_dovecot_request(msg, transactions, notifier)
assert res == "O\n" assert res == "O\n"
assert len(transactions) == 0 assert len(transactions) == 0
assert notifier.get_tokens(testaddr) == ["01234"] assert notifier.get_token("guid00") == "01234"
# trigger notification for incoming message # trigger notification for incoming message
tx2 = "2222" assert handle_dovecot_request(f"B{tx}\tuser", transactions, notifier) is None
assert handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier) is None msg = f"S{tx}\tpriv/guid00/messagenew"
msg = f"S{tx2}\tpriv/guid00/messagenew"
assert handle_dovecot_request(msg, transactions, notifier) is None assert handle_dovecot_request(msg, transactions, notifier) is None
assert notifier.notification_queue.get() == testaddr assert notifier.to_notify_queue.get() == "guid00"
assert handle_dovecot_request(f"C{tx2}", transactions, notifier) == "O\n" assert notifier.to_notify_queue.qsize() == 0
assert handle_dovecot_request(f"C{tx}\tuser", transactions, notifier) == "O\n"
assert not transactions assert not transactions
assert notifier.notification_dir.joinpath(testaddr).exists()
def test_handle_dovecot_protocol_set_devicetoken(notifier): def test_handle_dovecot_protocol_set_devicetoken(notifier):
@@ -95,7 +78,7 @@ def test_handle_dovecot_protocol_set_devicetoken(notifier):
b"\n".join( b"\n".join(
[ [
b"HELLO", b"HELLO",
b"Btx00\tuser@example.org", b"Btx00\tuser",
b"Stx00\tpriv/guid00/devicetoken\t01234", b"Stx00\tpriv/guid00/devicetoken\t01234",
b"Ctx00", b"Ctx00",
] ]
@@ -103,32 +86,8 @@ def test_handle_dovecot_protocol_set_devicetoken(notifier):
) )
wfile = io.BytesIO() wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier) handle_dovecot_protocol(rfile, wfile, notifier)
assert notifier.get_token("guid00") == "01234"
assert wfile.getvalue() == b"O\n" assert wfile.getvalue() == b"O\n"
assert notifier.get_tokens("user@example.org") == ["01234"]
def test_handle_dovecot_protocol_set_get_devicetoken(notifier):
rfile = io.BytesIO(
b"\n".join(
[
b"HELLO",
b"Btx00\tuser@example.org",
b"Stx00\tpriv/guid00/devicetoken\t01234",
b"Ctx00",
]
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier)
assert notifier.get_tokens("user@example.org") == ["01234"]
assert wfile.getvalue() == b"O\n"
rfile = io.BytesIO(
b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"])
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier)
assert wfile.getvalue() == b"O01234\n"
def test_handle_dovecot_protocol_iterate(notifier): def test_handle_dovecot_protocol_iterate(notifier):
@@ -150,7 +109,7 @@ def test_handle_dovecot_protocol_messagenew(notifier):
b"\n".join( b"\n".join(
[ [
b"HELLO", b"HELLO",
b"Btx01\tuser@example.org", b"Btx01\tuser",
b"Stx01\tpriv/guid00/messagenew", b"Stx01\tpriv/guid00/messagenew",
b"Ctx01", b"Ctx01",
] ]
@@ -159,11 +118,11 @@ def test_handle_dovecot_protocol_messagenew(notifier):
wfile = io.BytesIO() wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier) handle_dovecot_protocol(rfile, wfile, notifier)
assert wfile.getvalue() == b"O\n" assert wfile.getvalue() == b"O\n"
addr = notifier.notification_queue.get() assert notifier.to_notify_queue.get() == "guid00"
assert notifier.notification_dir.joinpath(addr).exists() assert notifier.to_notify_queue.qsize() == 0
def test_notifier_thread_run(notifier, testaddr): def test_notifier_thread_run(notifier):
requests = [] requests = []
class ReqMock: class ReqMock:
@@ -175,15 +134,15 @@ def test_notifier_thread_run(notifier, testaddr):
return Result() return Result()
notifier.add_token(testaddr, "01234") notifier.set_token("guid00", "01234")
notifier.new_message_for_addr(testaddr) notifier.new_message_for_guid("guid00")
notifier.thread_run_one(ReqMock()) notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0] url, data, timeout = requests[0]
assert data == "01234" assert data == "01234"
assert notifier.get_tokens(testaddr) == ["01234"] assert notifier.get_token("guid00") == "01234"
def test_multi_device_notifier(notifier, testaddr): def test_notifier_thread_run_gone_removes_token(notifier):
requests = [] requests = []
class ReqMock: class ReqMock:
@@ -191,40 +150,14 @@ def test_multi_device_notifier(notifier, testaddr):
requests.append((url, data, timeout)) requests.append((url, data, timeout))
class Result: class Result:
status_code = 200 status_code = 410
return Result() return Result()
notifier.add_token(testaddr, "01234") notifier.set_token("guid00", "01234")
notifier.add_token(testaddr, "56789") notifier.new_message_for_guid("guid00")
notifier.new_message_for_addr(testaddr) assert notifier.get_token("guid00") == "01234"
notifier.thread_run_one(ReqMock()) notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0] url, data, timeout = requests[0]
assert data == "01234" assert data == "01234"
url, data, timeout = requests[1] assert notifier.get_token("guid00") is None
assert data == "56789"
assert notifier.get_tokens(testaddr) == ["01234", "56789"]
def test_notifier_thread_run_gone_removes_token(notifier, testaddr):
requests = []
class ReqMock:
def post(self, url, data, timeout):
requests.append((url, data, timeout))
class Result:
status_code = 410 if data == "01234" else 200
return Result()
notifier.add_token(testaddr, "01234")
notifier.new_message_for_addr(testaddr)
assert notifier.get_tokens(testaddr) == ["01234"]
notifier.add_token(testaddr, "45678")
notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0]
assert data == "01234"
url, data, timeout = requests[1]
assert data == "45678"
assert notifier.get_tokens(testaddr) == ["45678"]
-1
View File
@@ -19,7 +19,6 @@ dependencies = [
"black", "black",
"pytest", "pytest",
"pytest-xdist", "pytest-xdist",
"imap_tools",
] ]
[project.scripts] [project.scripts]
+24 -31
View File
@@ -108,11 +108,8 @@ def _install_remote_venv_with_chatmaild(config) -> None:
execpath=f"{remote_venv_dir}/bin/{fn}", execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=config.mail_domain,
)
source_path = importlib.resources.files(__package__).joinpath(
"service", f"{fn}.service.f"
) )
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
content = source_path.read_text().format(**params).encode() content = source_path.read_text().format(**params).encode()
files.put( files.put(
@@ -135,6 +132,20 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
"""Configures OpenDKIM""" """Configures OpenDKIM"""
need_restart = False need_restart = False
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
user="opendkim",
groups=["opendkim"],
system=True,
)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
main_config = files.template( main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"), src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf", dest="/etc/opendkim.conf",
@@ -462,24 +473,9 @@ def deploy_chatmail(config_path: Path) -> None:
from .www import build_webpages from .www import build_webpages
apt.update(name="apt update", cache_time=24 * 3600)
server.group(name="Create vmail group", group="vmail", system=True) server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True) server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
user="opendkim",
groups=["opendkim"],
system=True,
)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
apt.update(name="apt update", cache_time=24 * 3600)
apt.packages( apt.packages(
name="Install rsync", name="Install rsync",
packages=["rsync"], packages=["rsync"],
@@ -566,17 +562,6 @@ def deploy_chatmail(config_path: Path) -> None:
restarted=mta_sts_need_restart, restarted=mta_sts_need_restart,
) )
# Dovecot should be started before Postfix
# because it creates authentication socket
# required by Postfix.
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
systemd.service( systemd.service(
name="Start and enable Postfix", name="Start and enable Postfix",
service="postfix.service", service="postfix.service",
@@ -585,6 +570,14 @@ def deploy_chatmail(config_path: Path) -> None:
restarted=postfix_need_restart, restarted=postfix_need_restart,
) )
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
systemd.service( systemd.service(
name="Start and enable nginx", name="Start and enable nginx",
service="nginx.service", service="nginx.service",
+1 -1
View File
@@ -1,4 +1,4 @@
SHELL=/bin/sh SHELL=/bin/sh
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
MAILTO=root MAILTO=root
20 16 * * * root /usr/bin/acmetool --batch reconcile && systemctl reload dovecot && systemctl reload postfix 20 16 * * * root /usr/bin/acmetool --batch reconcile
@@ -5,46 +5,6 @@ import random
import pytest import pytest
import requests import requests
import ipaddress import ipaddress
import imap_tools
@pytest.fixture
def imap_mailbox(cmfactory):
(ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr")
password = ac1.get_config("mail_pw")
mailbox = imap_tools.MailBox(user.split("@")[1])
mailbox.login(user, password)
return mailbox
class TestMetadataTokens:
"Tests that use Metadata extension for storing tokens"
def test_set_get_metadata(self, imap_mailbox):
"set and get metadata token for an account"
client = imap_mailbox.client
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
res = client.readline()
assert b"OK Setmetadata completed" in res
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
assert res == b"1111"
assert b"Getmetadata completed" in client.readline()
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "2222" )\n')
res = client.readline()
assert b"OK Setmetadata completed" in res
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline()
class TestEndToEndDeltaChat: class TestEndToEndDeltaChat:
@@ -115,10 +75,7 @@ class TestEndToEndDeltaChat:
) )
lp.indent("good, message sending failed because quota was exceeded") lp.indent("good, message sending failed because quota was exceeded")
return return
if ( if "saved mail to inbox" in line:
"stored mail into mailbox 'inbox'" in line
or "saved mail to inbox" in line
):
saved_ok += 1 saved_ok += 1
print(f"{saved_ok}: {line}") print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send: if saved_ok >= num_to_send: