From 247eb5588647b72b37384ce5f4a622f4fcb3fcfa Mon Sep 17 00:00:00 2001 From: missytake Date: Sat, 14 Oct 2023 19:19:00 +0200 Subject: [PATCH] doveauth: switch from lua authentication to dict authentication Co-Authored-By: holger krekel Co-Authored-By: link2xt --- README.md | 1 - chatmail-pyinfra/src/chatmail/__init__.py | 36 ++++--- .../src/chatmail/dovecot/auth.conf | 5 + .../src/chatmail/dovecot/dovecot.conf.j2 | 9 +- doveauth/pyproject.toml | 3 +- doveauth/src/doveauth/__init__.py | 2 - doveauth/src/doveauth/database.py | 2 +- doveauth/src/doveauth/dictproxy.py | 102 ++++++++++++++++++ .../src/doveauth/doveauth-dictproxy.service | 10 ++ doveauth/src/doveauth/doveauth.lua | 59 ---------- doveauth/src/doveauth/test_doveauth.lua | 78 -------------- doveauth/src/doveauth/test_doveauth.py | 10 +- scripts/test.sh | 1 + 13 files changed, 152 insertions(+), 166 deletions(-) create mode 100644 chatmail-pyinfra/src/chatmail/dovecot/auth.conf create mode 100644 doveauth/src/doveauth/dictproxy.py create mode 100644 doveauth/src/doveauth/doveauth-dictproxy.service delete mode 100644 doveauth/src/doveauth/doveauth.lua delete mode 100644 doveauth/src/doveauth/test_doveauth.lua diff --git a/README.md b/README.md index 4bb9ab92..bf20eb7a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ doveauth README.md pyproject.toml doveauth.py - doveauth.lua test_doveauth.py # lmtp server to block (outgoing) unencrypted messages diff --git a/chatmail-pyinfra/src/chatmail/__init__.py b/chatmail-pyinfra/src/chatmail/__init__.py index e2e46faf..e7df2c99 100644 --- a/chatmail-pyinfra/src/chatmail/__init__.py +++ b/chatmail-pyinfra/src/chatmail/__init__.py @@ -12,7 +12,7 @@ from .acmetool import deploy_acmetool def _install_doveauth() -> None: """Setup chatctl.""" - doveauth_filename = "doveauth-0.1.tar.gz" + doveauth_filename = "doveauth-0.2.tar.gz" doveauth_path = importlib.resources.files(__package__).joinpath( f"../../../dist/{doveauth_filename}" ) @@ -30,6 +30,24 @@ def _install_doveauth() -> None: commands=[f"pip install --break-system-packages {remote_path}"], ) + files.put( + src=importlib.resources.files("doveauth") + .joinpath("doveauth-dictproxy.service") + .open("rb"), + dest="/etc/systemd/system/doveauth-dictproxy.service", + user="root", + group="root", + mode="644", + ) + systemd.service( + name="Setup doveauth-dictproxy service", + service="doveauth-dictproxy.service", + running=True, + enabled=True, + restarted=True, + daemon_reload=True, + ) + def _install_filtermail() -> None: """Setup filtermail.""" @@ -152,16 +170,14 @@ def _configure_dovecot(mail_server: str) -> bool: config={"hostname": mail_server}, ) need_restart |= main_config.changed - - # luarocks install http lpeg_patterns fifo - auth_script = files.put( - src=importlib.resources.files("doveauth").joinpath("doveauth.lua"), - dest="/etc/dovecot/doveauth.lua", + auth_config = files.put( + src=importlib.resources.files(__package__).joinpath("dovecot/auth.conf"), + dest="/etc/dovecot/auth.conf", user="root", group="root", mode="644", ) - need_restart |= auth_script.changed + need_restart |= auth_config.changed return need_restart @@ -196,11 +212,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N apt.packages( name="Install Dovecot", - packages=[ - "dovecot-imapd", - "dovecot-lmtpd", - "dovecot-auth-lua", - ], + packages=["dovecot-imapd", "dovecot-lmtpd"], ) apt.packages( diff --git a/chatmail-pyinfra/src/chatmail/dovecot/auth.conf b/chatmail-pyinfra/src/chatmail/dovecot/auth.conf new file mode 100644 index 00000000..ecc36e91 --- /dev/null +++ b/chatmail-pyinfra/src/chatmail/dovecot/auth.conf @@ -0,0 +1,5 @@ +uri = proxy:/run/dovecot/doveauth.socket:auth +iterate_disable = yes +default_pass_scheme = plain +password_key = passdb/%w +user_key = userdb/%u \ No newline at end of file diff --git a/chatmail-pyinfra/src/chatmail/dovecot/dovecot.conf.j2 b/chatmail-pyinfra/src/chatmail/dovecot/dovecot.conf.j2 index bbeaa8fa..ddb59e84 100644 --- a/chatmail-pyinfra/src/chatmail/dovecot/dovecot.conf.j2 +++ b/chatmail-pyinfra/src/chatmail/dovecot/dovecot.conf.j2 @@ -11,14 +11,13 @@ auth_verbose_passwords = plain # Authentication for system users. passdb { - driver = lua - args = file=/etc/dovecot/doveauth.lua + driver = dict + args = /etc/dovecot/auth.conf } userdb { - driver = lua - args = file=/etc/dovecot/doveauth.lua + driver = dict + args = /etc/dovecot/auth.conf } - ## ## Mailbox locations and namespaces ## diff --git a/doveauth/pyproject.toml b/doveauth/pyproject.toml index a51a1f28..48ff5dd9 100644 --- a/doveauth/pyproject.toml +++ b/doveauth/pyproject.toml @@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "doveauth" -version = "0.1" +version = "0.2" [project.scripts] doveauth = "doveauth.doveauth:main" +doveauth-dictproxy = "doveauth.dictproxy:main" [tool.pytest.ini_options] addopts = "-v -ra --strict-markers" diff --git a/doveauth/src/doveauth/__init__.py b/doveauth/src/doveauth/__init__.py index 2b113666..e69de29b 100644 --- a/doveauth/src/doveauth/__init__.py +++ b/doveauth/src/doveauth/__init__.py @@ -1,2 +0,0 @@ -from .doveauth import get_user_data, verify_user -from .database import DBError, Database diff --git a/doveauth/src/doveauth/database.py b/doveauth/src/doveauth/database.py index 0a4850cc..dbb8d9dc 100644 --- a/doveauth/src/doveauth/database.py +++ b/doveauth/src/doveauth/database.py @@ -125,7 +125,7 @@ class Database: with self.write_transaction() as conn: if self.get_schema_version() > 1: raise DBError( - "Database version is %s; downgrading database schema is not supported" + "version is %s; downgrading schema is not supported" % (self.get_schema_version(),) ) conn.execute( diff --git a/doveauth/src/doveauth/dictproxy.py b/doveauth/src/doveauth/dictproxy.py new file mode 100644 index 00000000..ac42e49d --- /dev/null +++ b/doveauth/src/doveauth/dictproxy.py @@ -0,0 +1,102 @@ +import os +import sys +import json +from socketserver import ( + UnixStreamServer, + StreamRequestHandler, + ThreadingMixIn, +) +import pwd + +from .database import Database + + +def create_user(db, user, password): + with db.write_transaction() as conn: + conn.create_user(user, password) + return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password) + + +def get_user_data(db, user): + with db.read_connection() as conn: + result = conn.get_user(user) + if result: + result["uid"] = "vmail" + result["gid"] = "vmail" + return result + + +def lookup_userdb(db, user): + return get_user_data(db, user) + + +def lookup_passdb(db, user, password): + userdata = get_user_data(db, user) + if not userdata: + return create_user(db, user, password) + if userdata.get("password") == password: + return userdata + else: + return None + + +def handle_dovecot_request(msg, db): + print(f"received msg: {msg!r}") + short_command = msg[0] + if short_command == "L": # LOOKUP + parts = msg[1:].split("\t") + keyname, user = parts[:2] + namespace, type, arg = keyname.split("/", 3) + reply_command = "F" + res = "" + if namespace == "shared": + if type == "userdb": + res = lookup_userdb(db, user) + if res: + reply_command = "O" + else: + reply_command = "N" + elif type == "passdb": + res = lookup_passdb(db, user, password=arg) + if res: + reply_command = "O" + else: + reply_command = "N" + print(f"res: {res!r}") + json_res = json.dumps(res) if res else "" + return f"{reply_command}{json_res}\n" + return None + + +class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer): + pass + + +def main(): + socket = sys.argv[1] + passwd_entry = pwd.getpwnam(sys.argv[2]) + db = Database(sys.argv[3]) + + class Handler(StreamRequestHandler): + def handle(self): + while True: + msg = self.rfile.readline().strip().decode() + if not msg: + continue + res = handle_dovecot_request(msg, db) + if res: + print(f"sending result: {res!r}") + self.wfile.write(res.encode("ascii")) + self.wfile.flush() + + try: + os.unlink(socket) + except FileNotFoundError: + pass + + with ThreadedUnixStreamServer(socket, Handler) as server: + os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid) + try: + server.serve_forever() + except KeyboardInterrupt: + pass diff --git a/doveauth/src/doveauth/doveauth-dictproxy.service b/doveauth/src/doveauth/doveauth-dictproxy.service new file mode 100644 index 00000000..d08fd610 --- /dev/null +++ b/doveauth/src/doveauth/doveauth-dictproxy.service @@ -0,0 +1,10 @@ +[Unit] +Description=Dict authentication proxy for dovecot + +[Service] +ExecStart=/usr/local/bin/doveauth-dictproxy /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/doveauth/src/doveauth/doveauth.lua b/doveauth/src/doveauth/doveauth.lua deleted file mode 100644 index b5083b8b..00000000 --- a/doveauth/src/doveauth/doveauth.lua +++ /dev/null @@ -1,59 +0,0 @@ - --- Escape shell argument by hex encoding it and wrapping in quotes. -function escape(data) - b16 = data:gsub(".", function(char) return string.format("%2X", char:byte()) end) - return ("'"..b16.."'") -end - --- call out to python program to actually manage authentication for dovecot - -function chatctl_verify(user, password) - local cmd = "doveauth hexauth "..escape(user).." "..escape(password) - print("executing: "..cmd) - local handle = io.popen(cmd) - local result = handle:read("*a") - handle:close() - return split_chatctl(result) -end - -function chatctl_lookup(user) - local cmd = "doveauth hexlookup "..escape(user) - assert(user) - print("executing: "..cmd) - local handle = io.popen(cmd) - local result = handle:read("*a") - handle:close() - return split_chatctl(result) -end - -function get_extra_dovecot_output(res) - return {home=res.home, uid=res.uid, gid=res.gid} -end - - -function auth_password_verify(request, password) - local res = chatctl_verify(request.user, password) - -- request:log_error("auth_password_verify "..request.user.." "..password) - if res.status == "ok" then - local extra = get_extra_dovecot_output(res) - return dovecot.auth.PASSDB_RESULT_OK, get_extra_dovecot_output(res) - end - return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "" -end - - -function auth_userdb_lookup(request) - local res = chatctl_lookup(request.user) - if res.status == "ok" then - return dovecot.auth.USERDB_RESULT_OK, get_extra_dovecot_output(res) - end - return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, "no such user" -end - -function split_chatctl(output) - local ret = {} - for key, value in output:gmatch "(%w+)%s*=%s*(%w+)" do - ret[key] = value - end - return ret -end diff --git a/doveauth/src/doveauth/test_doveauth.lua b/doveauth/src/doveauth/test_doveauth.lua deleted file mode 100644 index aeec88ef..00000000 --- a/doveauth/src/doveauth/test_doveauth.lua +++ /dev/null @@ -1,78 +0,0 @@ - -require "doveauth" - --- simulate dovecot defined result codes - -dovecot = { - auth = { - PASSDB_RESULT_OK="PASSWORD-OK", - PASSDB_RESULT_PASSWORD_MISMATCH="PASSWORD-MISMATCH", - USERDB_RESULT_OK="USERDB-OK", - USERDB_RESULT_USER_UNKNOWN="USERDB-UNKNOWN" - } -} - - --- Tests for testing the lua<->python interaction - -function test_password_verify_ok(user, password) - local res, extra = auth_password_verify({user=user}, password) - assert(res==dovecot.auth.PASSDB_RESULT_OK) - assert(extra.uid == "vmail") - assert(extra.gid == "vmail") - -- assert(extra.homedir == "/home/vmail/link2xt") - print("OK test_password_verify_ok "..user.." "..password) -end - -function test_password_verify_mismatch(user, password) - local res = auth_password_verify({user=user}, password) - assert(res == dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH) - print("OK test_password_verify_mismatch "..user.." "..password) -end - -function test_userdb_lookup_ok(user) - local res, extra = auth_userdb_lookup({user=user}) - assert(extra.uid == "vmail") - assert(extra.gid == "vmail") - assert(res == dovecot.auth.USERDB_RESULT_OK) - print("OK test_userdb_lookup_ok "..user) -end - -function test_userdb_lookup_mismatch(user) - local res, extra = auth_userdb_lookup({user=user}) - assert(res == dovecot.auth.USERDB_RESULT_USER_UNKNOWN) - print("OK test_userdb_lookup_mismatch "..user) -end - -function test_passdb_lookup_ok(user) - local res, extra = auth_passdb_lookup({user=user}) - assert(extra.uid == "vmail") - assert(extra.gid == "vmail") - assert(res == dovecot.auth.PASSDB_RESULT_OK) - print("OK test_passdb_lookup_ok "..user) -end - -function test_passdb_lookup_mismatch(user) - local res, extra = auth_passdb_lookup({user=user}) - assert(res == dovecot.auth.PASSDB_RESULT_USER_UNKNOWN) - print("OK test_passdb_lookup_mismatch "..user) -end - -function test_split_chatctl() - local res = split_chatctl("a=3 b=4\nc=5") - assert(res["a"] == "3") - assert(res["b"] == "4") - assert(res["c"] == "5") - print("OK test_split_chatctl") -end - -test_split_chatctl() -test_password_verify_ok("link2xt@c1.testrun.org", "Ahyei6ie") -test_password_verify_mismatch("link2xt@c1.testrun.org", "Aqwlek") -test_userdb_lookup_ok("link2xt@c1.testrun.org") -test_userdb_lookup_mismatch("wlekqjlew@xyz.org") - --- probably not needed by dovecot? --- test_passdb_lookup_ok("link2xt@c1.testrun.org") --- test_passdb_lookup_mismatch("llqkwjelqwe@xyz.org") - diff --git a/doveauth/src/doveauth/test_doveauth.py b/doveauth/src/doveauth/test_doveauth.py index 4ae4f926..89bf1657 100644 --- a/doveauth/src/doveauth/test_doveauth.py +++ b/doveauth/src/doveauth/test_doveauth.py @@ -1,7 +1,9 @@ import subprocess import pytest -from doveauth import get_user_data, verify_user, Database, DBError +from .dictproxy import get_user_data +from .doveauth import verify_user +from .database import Database, DBError @pytest.fixture() @@ -24,12 +26,6 @@ def test_verify_or_create(db): assert res["status"] == "fail" -def test_lua_integration(request): - p = request.fspath.dirpath("test_doveauth.lua") - proc = subprocess.run(["lua", str(p)]) - assert proc.returncode == 0 - - def test_db_version(db): assert db.get_schema_version() == 1 diff --git a/scripts/test.sh b/scripts/test.sh index ba949357..01ca14d9 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e pushd doveauth/src/doveauth ../../venv/bin/pytest popd