Compare commits

...

19 Commits

Author SHA1 Message Date
missytake
0bea4fad3f plan: persistence is achieved 2023-10-14 00:22:47 +02:00
missytake
446bb3483b dovecot: run auth-worker as vmail user 2023-10-14 00:11:03 +02:00
missytake
cb5c5de154 doveauth: adjust pytest for persistent database 2023-10-14 00:07:00 +02:00
missytake
6be51aa4df doveauth: integrate sqlite database 2023-10-14 00:04:57 +02:00
missytake
f76ddf0e22 doveauth: add sqlite database to persist accounts 2023-10-14 00:04:27 +02:00
missytake
ae2ee84db2 part of plan was resolved 2023-10-13 21:13:53 +02:00
missytake
69b9df9480 add comment about installing doveauth system-wide 2023-10-13 21:12:56 +02:00
missytake
4ebec75d95 apply suggestion about pathlib 2023-10-13 21:12:56 +02:00
link2xt
453910c57e Remove hardcoded domain from doveauth.py 2023-10-13 21:12:56 +02:00
link2xt
dd9b33907a Log the lookup command in doveauth.lua 2023-10-13 21:12:56 +02:00
missytake
716b8169f8 fix lint issues 2023-10-13 21:12:56 +02:00
missytake
6a6255b6d0 script to run all tests from repository root 2023-10-13 21:12:56 +02:00
missytake
fbda0fb53c install doveauth system-wide via pip 2023-10-13 21:12:56 +02:00
missytake
01f350fa0b make doveauth tests pass again 2023-10-13 21:12:56 +02:00
missytake
93a84617a8 add doveauth entrypoint for lua 2023-10-13 21:12:56 +02:00
link2xt
3b0037dc3a scripts/deploy.sh: allow to set $CHATMAIL_DOMAIN externally 2023-10-13 17:29:41 +00:00
missytake
9dfd0ee979 don't run deploy on import 2023-10-13 18:36:15 +02:00
missytake
344e799a51 move doveauth scripts to its own python project 2023-10-13 18:36:15 +02:00
missytake
556d9d37a4 added doveauth python project and README 2023-10-13 18:36:15 +02:00
17 changed files with 289 additions and 62 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ __pycache__/
# Distribution / packaging # Distribution / packaging
.Python .Python
build/ build/
doveauth/dist/
develop-eggs/ develop-eggs/
dist/ dist/
downloads/ downloads/

View File

@@ -2,6 +2,7 @@
Chat Mail pyinfra deploy. Chat Mail pyinfra deploy.
""" """
import importlib.resources import importlib.resources
from pathlib import Path
from pyinfra import host, logger from pyinfra import host, logger
from pyinfra.operations import apt, files, server, systemd, python from pyinfra.operations import apt, files, server, systemd, python
@@ -9,17 +10,29 @@ from pyinfra.facts.files import File
from .acmetool import deploy_acmetool from .acmetool import deploy_acmetool
def _install_chatctl() -> None: def _install_doveauth() -> None:
"""Setup chatctl.""" """Setup chatctl."""
files.put( doveauth_filename = "doveauth-0.1.tar.gz"
src=importlib.resources.files(__package__) doveauth_path = importlib.resources.files(__package__).joinpath(
.joinpath("dovecot/doveauth.py") f"../../../doveauth/dist/{doveauth_filename}"
.open("rb"),
dest="/home/vmail/chatctl",
user="vmail",
group="vmail",
mode="755",
) )
remote_path = f"/tmp/{doveauth_filename}"
if Path(str(doveauth_path)).exists():
files.put(
name="upload local doveauth build",
src=doveauth_path.open("rb"),
dest=remote_path,
)
apt.packages(
name="apt install python3-pip",
packages="python3-pip",
)
# Maybe if we introduce dependencies to the doveauth package at some point, we should not install doveauth
# system-wide anymore. For now it's fine though.
server.shell(
name="install local doveauth build with pip",
commands=[f"pip install --break-system-packages {remote_path}"],
)
def _configure_opendkim(domain: str, dkim_selector: str) -> bool: def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
@@ -103,7 +116,7 @@ def _configure_dovecot(mail_server: str) -> bool:
# luarocks install http lpeg_patterns fifo # luarocks install http lpeg_patterns fifo
auth_script = files.put( auth_script = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/doveauth.lua"), src=importlib.resources.files("doveauth").joinpath("doveauth.lua"),
dest="/etc/dovecot/doveauth.lua", dest="/etc/dovecot/doveauth.lua",
user="root", user="root",
group="root", group="root",
@@ -159,7 +172,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
], ],
) )
_install_chatctl() _install_doveauth()
dovecot_need_restart = _configure_dovecot(mail_server) dovecot_need_restart = _configure_dovecot(mail_server)
postfix_need_restart = _configure_postfix(mail_domain) postfix_need_restart = _configure_postfix(mail_domain)
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector) opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)

View File

@@ -88,7 +88,7 @@ service auth {
service auth-worker { service auth-worker {
# Default is root. # Default is root.
# Drop privileges we don't need. # Drop privileges we don't need.
user = $default_internal_user user = vmail
} }
ssl = required ssl = required

View File

@@ -1,23 +0,0 @@
import subprocess
import pytest
from doveauth import get_user_data, verify_user
def test_basic():
data = get_user_data("link2xt@c1.testrun.org")
assert data
@pytest.mark.xfail(reason="no persistence yet")
def test_verify_or_create():
res = verify_user("newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["status"] == "ok"
res = verify_user("newuser1@something.org", "kajdlqweqwe")
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

View File

@@ -1,5 +1,5 @@
import os import os
from pyinfra import host, facts import pyinfra
from chatmail import deploy_chatmail from chatmail import deploy_chatmail
@@ -15,4 +15,5 @@ def main():
deploy_chatmail(mail_domain, mail_server, dkim_selector) deploy_chatmail(mail_domain, mail_server, dkim_selector)
main() if pyinfra.is_cli:
main()

7
doveauth/README.md Normal file
View File

@@ -0,0 +1,7 @@
# doveauth
doveauth is a python tool
to create dovecot users on login.
It is called by the
[dovecot lua authentication module](https://doc.dovecot.org/configuration_manual/authentication/lua_based_authentication/)

30
doveauth/pyproject.toml Normal file
View File

@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=45"]
build-backend = "setuptools.build_meta"
[project]
name = "doveauth"
version = "0.1"
[project.scripts]
doveauth = "doveauth.doveauth:main"
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers"
[tool.tox]
legacy_tox_ini = """
[tox]
isolated_build = true
envlist = lint
[testenv:lint]
skipdist = True
skip_install = True
deps =
ruff
black
commands =
black --quiet --check --diff src/
ruff src/
"""

View File

View File

@@ -0,0 +1,153 @@
import sqlite3
import contextlib
import time
from pathlib import Path
class DBError(Exception):
"""error during an operation on the database."""
class Connection:
def __init__(self, sqlconn, write):
self._sqlconn = sqlconn
self._write = write
def close(self):
self._sqlconn.close()
def commit(self):
self._sqlconn.commit()
def rollback(self):
self._sqlconn.rollback()
def execute(self, query, params=()):
cur = self.cursor()
try:
cur.execute(query, params)
except sqlite3.IntegrityError as e:
raise DBError(e)
return cur
def cursor(self):
return self._sqlconn.cursor()
def create_user(self, addr: str, password: str):
"""Create a row in the users table."""
self.execute("PRAGMA foreign_keys=on;")
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
self.execute(q, (addr, password, int(time.time())))
def get_user(self, addr: str) -> {}:
"""Get a row from the users table."""
q = "SELECT addr, password, last_login from users WHERE addr = ?"
row = self._sqlconn.execute(q, (addr,)).fetchone()
result = {}
if row:
result = dict(
user=row[0],
password=row[1],
last_login=row[2],
)
return result
def set_config(self, name: str, value: str) -> str:
ok = [
"dbversion",
]
assert name in ok, name
q = "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"
self.cursor().execute(q, (name, value)).fetchone()
return value
def get_config(self, key: str) -> str:
q = "SELECT key, value from config WHERE name = ?"
c = self._sqlconn.cursor()
try:
return c.execute(q, key).fetchone()
except sqlite3.OperationalError:
return None
class Database:
def __init__(self, path: str):
self.path = Path(path)
self.ensure_tables()
def _get_connection(self, write=False, transaction=False, closing=False) -> Connection:
# we let the database serialize all writers at connection time
# to play it very safe (we don't have massive amounts of writes).
mode = "ro"
if write:
mode = "rw"
if not self.path.exists():
mode = "rwc"
uri = "file:%s?mode=%s" % (self.path, mode)
sqlconn = sqlite3.connect(
uri,
timeout=60,
isolation_level=None if transaction else "DEFERRED",
uri=True,
)
# Enable Write-Ahead Logging to avoid readers blocking writers and vice versa.
if write:
sqlconn.execute("PRAGMA journal_mode=wal")
if transaction:
start_time = time.time()
while 1:
try:
sqlconn.execute("begin immediate")
break
except sqlite3.OperationalError:
# another thread may be writing, give it a chance to finish
time.sleep(0.1)
if time.time() - start_time > 5:
# if it takes this long, something is wrong
raise
conn = Connection(sqlconn, write=write)
if closing:
conn = contextlib.closing(conn)
return conn
@contextlib.contextmanager
def write_transaction(self):
conn = self._get_connection(closing=False, write=True, transaction=True)
try:
yield conn
except Exception:
conn.rollback()
conn.close()
raise
else:
conn.commit()
conn.close()
def read_connection(self, closing=True) -> Connection:
return self._get_connection(closing=closing, write=False)
CURRENT_DBVERSION = 1
def ensure_tables(self):
with self.write_transaction() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
addr TEXT PRIMARY KEY,
password TEXT,
last_login INTEGER
)
""",
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
)
""",
)
conn.set_config("dbversion", self.CURRENT_DBVERSION)

View File

@@ -8,7 +8,7 @@ end
-- call out to python program to actually manage authentication for dovecot -- call out to python program to actually manage authentication for dovecot
function chatctl_verify(user, password) function chatctl_verify(user, password)
local cmd = "python3 /home/vmail/chatctl hexauth "..escape(user).." "..escape(password) local cmd = "doveauth hexauth "..escape(user).." "..escape(password)
print("executing: "..cmd) print("executing: "..cmd)
local handle = io.popen(cmd) local handle = io.popen(cmd)
local result = handle:read("*a") local result = handle:read("*a")
@@ -17,8 +17,10 @@ function chatctl_verify(user, password)
end end
function chatctl_lookup(user) function chatctl_lookup(user)
local cmd = "doveauth hexlookup "..escape(user)
assert(user) assert(user)
local handle = io.popen("python3 /home/vmail/chatctl hexlookup "..escape(user)) print("executing: "..cmd)
local handle = io.popen(cmd)
local result = handle:read("*a") local result = handle:read("*a")
handle:close() handle:close()
return split_chatctl(result) return split_chatctl(result)

View File

@@ -2,37 +2,40 @@
import base64 import base64
import sys import sys
from .database import Database
def get_user_data(user):
if user == "link2xt@c1.testrun.org":
return dict(
uid="vmail",
gid="vmail",
password="Ahyei6ie",
)
return {}
def create_user(user, 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 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) return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
def verify_user(user, password): def verify_user(db, user, password):
userdata = get_user_data(user) userdata = get_user_data(db, user)
if userdata: if userdata:
if userdata.get("password") == password: if userdata.get("password") == password:
userdata["status"] = "ok" userdata["status"] = "ok"
else: else:
userdata["status"] = "fail" userdata["status"] = "fail"
else: else:
userdata = create_user(user, password) userdata = create_user(db, user, password)
userdata["status"] = "ok" userdata["status"] = "ok"
return userdata return userdata
def lookup_user(user): def lookup_user(db, user):
userdata = get_user_data(user) userdata = get_user_data(db, user)
if userdata: if userdata:
userdata["status"] = "ok" userdata["status"] = "ok"
else: else:
@@ -45,13 +48,18 @@ def dump_result(res):
print(f"{key}={value}") print(f"{key}={value}")
if __name__ == "__main__": def main():
db = Database("/home/vmail/passdb.sqlite")
if sys.argv[1] == "hexauth": if sys.argv[1] == "hexauth":
login = base64.b16decode(sys.argv[2]).decode() login = base64.b16decode(sys.argv[2]).decode()
password = base64.b16decode(sys.argv[3]).decode() password = base64.b16decode(sys.argv[3]).decode()
res = verify_user(login, password) res = verify_user(db, login, password)
dump_result(res) dump_result(res)
elif sys.argv[1] == "hexlookup": elif sys.argv[1] == "hexlookup":
login = base64.b16decode(sys.argv[2]).decode() login = base64.b16decode(sys.argv[2]).decode()
res = lookup_user(login) res = lookup_user(db, login)
dump_result(res) dump_result(res)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,25 @@
import subprocess
import pytest
from doveauth.doveauth import get_user_data, verify_user, Database
def test_basic(tmpdir):
db = Database(tmpdir / "passdb.sqlite")
verify_user(db, "link2xt@c1.testrun.org", "asdf")
data = get_user_data(db, "link2xt@c1.testrun.org")
assert data
def test_verify_or_create(tmpdir):
db = Database(tmpdir / "passdb.sqlite")
res = verify_user(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["status"] == "ok"
res = verify_user(db, "newuser1@something.org", "kajdlqweqwe")
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

View File

@@ -3,8 +3,6 @@
## Dovecot goals/steps ## Dovecot goals/steps
1. create-user-on-login ("doveauth") 1. create-user-on-login ("doveauth")
- repackage so that "doveauth" does not come from a hard-coded path
- persistence of accounts
2. per-user quota (adaptive) 2. per-user quota (adaptive)

View File

@@ -1,3 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export CHATMAIL_DOMAIN="${1:-c1.testrun.org}" : ${CHATMAIL_DOMAIN:=c1.testrun.org}
chatmail-pyinfra/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" deploy.py export CHATMAIL_DOMAIN
cd doveauth
venv/bin/python3 -m build
../chatmail-pyinfra/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" ../deploy.py
rm -r dist/

View File

@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
python3 -m venv chatmail-pyinfra/venv python3 -m venv chatmail-pyinfra/venv
chatmail-pyinfra/venv/bin/pip install pyinfra chatmail-pyinfra/venv/bin/pip install pyinfra pytest
chatmail-pyinfra/venv/bin/pip install -e chatmail-pyinfra chatmail-pyinfra/venv/bin/pip install -e chatmail-pyinfra
chatmail-pyinfra/venv/bin/pip install -e doveauth
python3 -m venv doveauth/venv
doveauth/venv/bin/pip install pytest build
doveauth/venv/bin/pip install -e doveauth

4
scripts/test.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
chatmail-pyinfra/venv/bin/pytest chatmail-pyinfra/tests
cd doveauth/src/doveauth
../../venv/bin/pytest