From 98fa66b5ad13c8f203fcfe40d42240417951f218 Mon Sep 17 00:00:00 2001 From: Georg Date: Mon, 13 Sep 2021 09:40:35 +0200 Subject: Init MC update + Dovecot/SOGo LDAP configuration Signed-off-by: Georg --- mailcow/data/Dockerfiles/ldap/Dockerfile | 16 ++ mailcow/data/Dockerfiles/ldap/api.py | 69 ++++++++ mailcow/data/Dockerfiles/ldap/filedb.py | 52 ++++++ mailcow/data/Dockerfiles/ldap/syncer.py | 186 +++++++++++++++++++++ mailcow/data/Dockerfiles/ldap/syscid-ca.crt | 32 ++++ .../Dockerfiles/ldap/templates/dovecot/extra.conf | 4 + .../ldap/templates/dovecot/ldap/passdb.conf | 8 + .../Dockerfiles/ldap/templates/sogo/plist_ldap | 43 +++++ mailcow/data/conf/dovecot/extra.conf | 5 + mailcow/data/conf/dovecot/ldap/passdb.conf | 9 + mailcow/data/conf/sogo/plist_ldap | 44 +++++ 11 files changed, 468 insertions(+) create mode 100644 mailcow/data/Dockerfiles/ldap/Dockerfile create mode 100644 mailcow/data/Dockerfiles/ldap/api.py create mode 100644 mailcow/data/Dockerfiles/ldap/filedb.py create mode 100644 mailcow/data/Dockerfiles/ldap/syncer.py create mode 100644 mailcow/data/Dockerfiles/ldap/syscid-ca.crt create mode 100644 mailcow/data/Dockerfiles/ldap/templates/dovecot/extra.conf create mode 100644 mailcow/data/Dockerfiles/ldap/templates/dovecot/ldap/passdb.conf create mode 100644 mailcow/data/Dockerfiles/ldap/templates/sogo/plist_ldap create mode 100644 mailcow/data/conf/dovecot/extra.conf create mode 100644 mailcow/data/conf/dovecot/ldap/passdb.conf create mode 100644 mailcow/data/conf/sogo/plist_ldap (limited to 'mailcow/data') diff --git a/mailcow/data/Dockerfiles/ldap/Dockerfile b/mailcow/data/Dockerfiles/ldap/Dockerfile new file mode 100644 index 0000000..9b7f507 --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3-alpine + +RUN apk --no-cache add build-base openldap-dev python2-dev python3-dev +RUN pip3 install python-ldap sqlalchemy requests + +COPY templates ./templates +COPY api.py filedb.py syncer.py ./ + +ADD syscid-ca.crt /usr/local/share/ca-certificates/syscid-ca.crt +RUN chmod 644 /usr/local/share/ca-certificates/syscid-ca.crt && update-ca-certificates + +VOLUME [ "/db" ] +VOLUME [ "/conf/dovecot" ] +VOLUME [ "/conf/sogo" ] + +ENTRYPOINT [ "python3", "syncer.py" ] diff --git a/mailcow/data/Dockerfiles/ldap/api.py b/mailcow/data/Dockerfiles/ldap/api.py new file mode 100644 index 0000000..de056d9 --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/api.py @@ -0,0 +1,69 @@ +import random, string, sys +import requests + +def __post_request(url, json_data): + api_url = f"{api_host}/{url}" + headers = {'X-API-Key': api_key, 'Content-type': 'application/json'} + + req = requests.post(api_url, headers=headers, json=json_data,verify=is_ssl_verify) + rsp = req.json() + req.close() + + if isinstance(rsp, list): + rsp = rsp[0] + + if not "type" in rsp or not "msg" in rsp: + sys.exit(f"API {url}: got response without type or msg from Mailcow API") + + if rsp['type'] != 'success': + sys.exit(f"API {url}: {rsp['type']} - {rsp['msg']}") + +def add_user(email, name, active): + password = ''.join(random.choices(string.ascii_letters + string.digits, k=20)) + json_data = { + 'local_part':email.split('@')[0], + 'domain':email.split('@')[1], + 'name':name, + 'password':password, + 'password2':password, + "active": 1 if active else 0 + } + + __post_request('api/v1/add/mailbox', json_data) + +def edit_user(email, active=None, name=None): + attr = {} + if (active is not None): + attr['active'] = 1 if active else 0 + if (name is not None): + attr['name'] = name + + json_data = { + 'items': [email], + 'attr': attr + } + + __post_request('api/v1/edit/mailbox', json_data) + +def __delete_user(email): + json_data = [email] + + __post_request('api/v1/delete/mailbox', json_data) + +def check_user(email): + url = f"{api_host}/api/v1/get/mailbox/{email}" + headers = {'X-API-Key': api_key, 'Content-type': 'application/json'} + req = requests.get(url, headers=headers,verify=is_ssl_verify) + rsp = req.json() + req.close() + + if not isinstance(rsp, dict): + sys.exit("API get/mailbox: got response of a wrong type") + + if (not rsp): + return (False, False, None) + + if 'active_int' not in rsp and rsp['type'] == 'error': + sys.exit(f"API {url}: {rsp['type']} - {rsp['msg']}") + + return (True, bool(rsp['active_int']), rsp['name']) diff --git a/mailcow/data/Dockerfiles/ldap/filedb.py b/mailcow/data/Dockerfiles/ldap/filedb.py new file mode 100644 index 0000000..10cadb9 --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/filedb.py @@ -0,0 +1,52 @@ +import datetime, os + +import logging +logging.basicConfig(format='%(asctime)s %(message)s', datefmt='%d.%m.%y %H:%M:%S', level=logging.INFO) + +import sqlalchemy +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import create_engine, Column, String, Boolean, DateTime +from sqlalchemy.orm import sessionmaker + +db_file = 'db/ldap-mailcow.sqlite3' + +Base = declarative_base() + +class DbUser(Base): # type: ignore + __tablename__ = 'users' + email = Column(String, primary_key=True) + active = Column(Boolean, nullable=False) + last_seen = Column(DateTime, nullable=False) + +Session = sessionmaker() + +if not os.path.isfile(db_file): + logging.info (f"New database file created: {db_file}") + +db_engine = create_engine(f"sqlite:///{db_file}") # echo=True +Base.metadata.create_all(db_engine) +Session.configure(bind=db_engine) +session = Session() +session_time = datetime.datetime.now() + +def get_unchecked_active_users(): + query = session.query(DbUser.email).filter(DbUser.last_seen != session_time).filter(DbUser.active == True) + + return [x.email for x in query] + +def add_user(email, active=True): + session.add(DbUser(email=email, active=active, last_seen=session_time)) + session.commit() + +def check_user(email): + user = session.query(DbUser).filter_by(email=email).first() + if user is None: + return (False, False) + user.last_seen = session_time + session.commit() + return (True, user.active) + +def user_set_active_to(email, active): + user = session.query(DbUser).filter_by(email=email).first() + user.active = active + session.commit() \ No newline at end of file diff --git a/mailcow/data/Dockerfiles/ldap/syncer.py b/mailcow/data/Dockerfiles/ldap/syncer.py new file mode 100644 index 0000000..e9a3f02 --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/syncer.py @@ -0,0 +1,186 @@ +import sys, os, string, time, datetime +import ldap + +import filedb, api + +from string import Template +from pathlib import Path + +import logging +logging.basicConfig(format='%(asctime)s %(message)s', datefmt='%d.%m.%y %H:%M:%S', level=logging.INFO) + +def main(): + global config + config = read_config() + + passdb_conf = read_dovecot_passdb_conf_template() + plist_ldap = read_sogo_plist_ldap_template() + extra_conf = read_dovecot_extra_conf() + + passdb_conf_changed = apply_config('conf/dovecot/ldap/passdb.conf', config_data = passdb_conf) + extra_conf_changed = apply_config('conf/dovecot/extra.conf', config_data = extra_conf) + plist_ldap_changed = apply_config('conf/sogo/plist_ldap', config_data = plist_ldap) + + if passdb_conf_changed or extra_conf_changed or plist_ldap_changed: + logging.info ("One or more config files have been changed, please make sure to restart dovecot-mailcow and sogo-mailcow!") + + api.api_host = config['API_HOST'] + api.api_key = config['API_KEY'] + api.is_ssl_verify = bool(int(config['API_SSL_VERIFY'])) + + while (True): + sync() + interval = int(config['SYNC_INTERVAL']) + logging.info(f"Sync finished, sleeping {interval} seconds before next cycle") + time.sleep(interval) + +def sync(): + ldap_connector = ldap.initialize(f"{config['LDAP_HOST']}") + ldap_connector.set_option(ldap.OPT_REFERRALS, 0) + ldap_connector.simple_bind_s(config['LDAP_BIND_DN'], config['LDAP_BIND_DN_PASSWORD']) + + #ldap_results = ldap_connector.search_s(config['LDAP_BASE_DN'], ldap.SCOPE_SUBTREE, + # '(&(objectClass=user)(objectCategory=person))', + # ['userPrincipalName', 'cn', 'userAccountControl']) + + ldap_results = ldap_connector.search_s(config['LDAP_BASE_DN'], ldap.SCOPE_SUBTREE, config['LDAP_FILTER'], + config['LDAP_FIELDS_MAIL'], config['LDAP_FIELDS_NAME']) + + ldap_results = map(lambda x: ( + [i.decode() for i in x[1][config['LDAP_FIELDS_MAIL']]], + x[1][config['LDAP_FIELDS_NAME']][0].decode(), + #False if int(x[1]['userAccountControl'][0].decode()) & 0b10 else True), ldap_results) + True), ldap_results) + + filedb.session_time = datetime.datetime.now() + + for (ldap_email, ldap_name, ldap_active) in ldap_results: + for email in ldap_email: + if email.split('@')[1] not in config['EMAIL_DOMAINS']: + continue + (db_user_exists, db_user_active) = filedb.check_user(email) + (api_user_exists, api_user_active, api_name) = api.check_user(email) + + unchanged = True + + if not db_user_exists: + filedb.add_user(email, ldap_active) + (db_user_exists, db_user_active) = (True, ldap_active) + logging.info (f"Added filedb user: {email} (Active: {ldap_active})") + unchanged = False + + if not api_user_exists: + api.add_user(email, ldap_name, ldap_active) + (api_user_exists, api_user_active, api_name) = (True, ldap_active, ldap_name) + logging.info (f"Added Mailcow user: {email} (Active: {ldap_active})") + unchanged = False + + if db_user_active != ldap_active: + filedb.user_set_active_to(email, ldap_active) + logging.info (f"{'Activated' if ldap_active else 'Deactived'} {email} in filedb") + unchanged = False + + if api_user_active != ldap_active: + api.edit_user(email, active=ldap_active) + logging.info (f"{'Activated' if ldap_active else 'Deactived'} {email} in Mailcow") + unchanged = False + + if api_name != ldap_name: + api.edit_user(email, name=ldap_name) + logging.info (f"Changed name of {email} in Mailcow to {ldap_name}") + unchanged = False + + if unchanged: + logging.info (f"Checked user {email}, unchanged") + + for email in filedb.get_unchecked_active_users(): + (api_user_exists, api_user_active, _) = api.check_user(email) + + if (api_user_active and api_user_active): + api.edit_user(email, active=False) + logging.info (f"Deactivated user {email} in Mailcow, not found in LDAP") + + filedb.user_set_active_to(email, False) + logging.info (f"Deactivated user {email} in filedb, not found in LDAP") + +def apply_config(config_file, config_data): + if os.path.isfile(config_file): + with open(config_file) as f: + old_data = f.read() + + if old_data.strip() == config_data.strip(): + logging.info(f"Config file {config_file} unchanged") + return False + + backup_index = 1 + backup_file = f"{config_file}.ldap_mailcow_bak" + while os.path.exists(backup_file): + backup_file = f"{config_file}.ldap_mailcow_bak.{backup_index}" + backup_index += 1 + + os.rename(config_file, backup_file) + logging.info(f"Backed up {config_file} to {backup_file}") + + Path(os.path.dirname(config_file)).mkdir(parents=True, exist_ok=True) + + print(config_data, file=open(config_file, 'w')) + + logging.info(f"Saved generated config file to {config_file}") + return True + +def read_config(): + required_config_keys = [ + 'LDAP-MAILCOW_LDAP_HOST', + 'LDAP-MAILCOW_LDAP_BASE_DN', + 'LDAP-MAILCOW_LDAP_BIND_DN', + 'LDAP-MAILCOW_LDAP_BIND_DN_PASSWORD', + 'LDAP-MAILCOW_LDAP_FILTER', + 'LDAP-MAILCOW_LDAP_FIELDS_MAIL', + 'LDAP-MAILCOW_LDAP_FIELDS_NAME', + 'LDAP-MAILCOW_API_HOST', + 'LDAP-MAILCOW_API_KEY', + 'LDAP-MAILCOW_API_SSL_VERIFY', + 'LDAP-MAILCOW_SYNC_INTERVAL', + 'LDAP-MAILCOW_EMAIL_DOMAINS' + ] + + config = {} + + for config_key in required_config_keys: + if config_key not in os.environ: + sys.exit (f"Required envrionment value {config_key} is not set") + + config[config_key.replace('LDAP-MAILCOW_', '')] = os.environ[config_key] + config['EMAIL_DOMAINS'] = config['EMAIL_DOMAINS'].split(',') + return config + +def read_dovecot_passdb_conf_template(): + with open('templates/dovecot/ldap/passdb.conf') as f: + data = Template(f.read()) + + return data.substitute( + ldap_host=config['LDAP_HOST'], + ldap_base_dn=config['LDAP_BASE_DN'], + ldap_bind_dn=config['LDAP_BIND_DN'], + ldap_bind_dn_password=config['LDAP_BIND_DN_PASSWORD'] + ) + +def read_sogo_plist_ldap_template(): + with open('templates/sogo/plist_ldap') as f: + data = Template(f.read()) + + return data.substitute( + ldap_host=config['LDAP_HOST'], + ldap_base_dn=config['LDAP_BASE_DN'], + ldap_bind_dn=config['LDAP_BIND_DN'], + ldap_bind_dn_password=config['LDAP_BIND_DN_PASSWORD'] + ) + +def read_dovecot_extra_conf(): + with open('templates/dovecot/extra.conf') as f: + data = f.read() + + return data + +if __name__ == '__main__': + main() diff --git a/mailcow/data/Dockerfiles/ldap/syscid-ca.crt b/mailcow/data/Dockerfiles/ldap/syscid-ca.crt new file mode 100644 index 0000000..3ac52fa --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/syscid-ca.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFfzCCA2egAwIBAgIUUtCa6jguJLt20qzq/MR82JX/6JQwDQYJKoZIhvcNAQEL +BQAwTzEUMBIGA1UECgwLTGliZXJ0YUNhc2ExEzARBgNVBAMMCnN5c2NpZC5jb20x +IjAgBgkqhkiG9w0BCQEWE3N5c3RlbUBseXNlcmdpYy5kZXYwHhcNMjEwODEwMDAy +MjM2WhcNMjIwODEwMDAyMjM2WjBPMRQwEgYDVQQKDAtMaWJlcnRhQ2FzYTETMBEG +A1UEAwwKc3lzY2lkLmNvbTEiMCAGCSqGSIb3DQEJARYTc3lzdGVtQGx5c2VyZ2lj +LmRldjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO0m7lpEZwGB1pxo +24RESADjbnk6iGmH783cuMo7jzio6P5vZk8FRD0/8Gmli/sOe8oZ2hGE5sNx4RKK +4g7kDVnYznHS6k5zpBzU4FP9wVMho/TcfaXlCdSwj6Ih0mLxDYzvX0l12Gi1K9gg +0HxdG2XPfslQbk6py1jQYVkRjwZIj2ya7t7/fNyn6S7flVUIvvcvZd3eNvAlg4ZU +wDV1H5mF3s42Iv5TOEYi88n7yXUex5I9xi5NqG/qOuYuC69yYobI/WjfId7bUDPT +UjZJFD5wHUHwtBmjp2bdyzdl9Z9iJp24jhR3Syi4h/BjYFwUG793PjP8DZBWtrOC +jHoOwkCyYFfOTa8n+Knb8i2FSuX4TgMZeFwcLpSsecBIjknKHPNYW2NTcP7S/Xbl +KpP2fpN7JBlR8WTi9+WQZVHuMfU1rjp3Kjwj9dmjcWsOMuMEqJUZMSsMpUBKULbq +6QFhMPJL+yYDHg1S0E3ymHRU94mlQ57mQwAg0AraCDtdPR3zw+gh2k1hUEkva26G +zHYigssbhCT81Dp4Ez65tKHZoKYKvgywJ6gb7PURS8Ued8PSDEhJd18WN22l4xQP +k08fg+mB5gHXNNGzYVETPalrWmk9IHczQnDITWM1hj14VhIcwa9oMNesIllviL7/ +BRjiz6jHZfc3Htp4NZ/5sBgFDUelAgMBAAGjUzBRMB0GA1UdDgQWBBSU/TIhYSYN +HQ3+7ueHMmkVD8BZRzAfBgNVHSMEGDAWgBSU/TIhYSYNHQ3+7ueHMmkVD8BZRzAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQCLf0GU+mrZo4l7qMJz +WfNFxZv3E5GFRgl1NlUfJM2hnpgqrT7ukXcRnY4n9KtLx14QWHpPdX7KyLsSqx6X +UoPsJNywYQNVyAQ0qddY4glGV8u6+QE1zN1yUw6CMbsqWz50T76r1Y1CRZuMyffU +OeVhBM17sWibgDbev0SmG12uYTkq7qmbCKOWUhbaL1jCE1yvu9ZFFXCQ/OaAMyn9 +fYyyn48z7MHsyISuBdAcJkR2JkIgL4oZufw5hcecZ1wcnmYTRm6owuhUsZ2FYXkU +5o2Pn6nce1QEaUKsik8xBNA0/jIBCkiPDb5/eIA8Yys7pb/DeFEE/X0JM9rhoOk7 +tXOvxV2S7Y1xRqVwa3mrlp/0yXHuBb+u3/1+jsxkaeSPQ8FRejPIZzeOGhVHPaub +RdvzSO0TBK54vLA0CrkDRLgFdyuzsvm6VMVqGpcKn/aaju7pLI/knJzITaHHxzqV +wxmA0kf/68+wVvdICZt4R3iqSU9KVmCmh6owTixNTgh1wLmFIyMl2VhOwibFVg4L +600gIb59wEV4tWnYEX3Ugsw8g0ZXoqPDA27CPlpmgaVXwBv1qssRYnUreZEXxEN3 +4A1UOTdjMPn9v1wqmBwTCb9MULX60byX72YPMOnuSAQyptbXx8oMvInK91T5ivZ0 +JpmySX/Gfpgrr7HSI9+cD3eUcQ== +-----END CERTIFICATE----- diff --git a/mailcow/data/Dockerfiles/ldap/templates/dovecot/extra.conf b/mailcow/data/Dockerfiles/ldap/templates/dovecot/extra.conf new file mode 100644 index 0000000..7e97513 --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/templates/dovecot/extra.conf @@ -0,0 +1,4 @@ +passdb { + args = /etc/dovecot/ldap/passdb.conf + driver = ldap +} diff --git a/mailcow/data/Dockerfiles/ldap/templates/dovecot/ldap/passdb.conf b/mailcow/data/Dockerfiles/ldap/templates/dovecot/ldap/passdb.conf new file mode 100644 index 0000000..f81a57a --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/templates/dovecot/ldap/passdb.conf @@ -0,0 +1,8 @@ +uris = $ldap_host +ldap_version = 3 +base = $ldap_base_dn +auth_bind = yes +dn = $ldap_bind_dn +dnpass = $ldap_bind_dn_password +pass_attrs = userPassword=password +pass_filter = (&(memberof=cn=syscid_email_mailcow,ou=syscid-groups,dc=syscid,dc=com)(|(uid=%n)(mail=%u))) diff --git a/mailcow/data/Dockerfiles/ldap/templates/sogo/plist_ldap b/mailcow/data/Dockerfiles/ldap/templates/sogo/plist_ldap new file mode 100644 index 0000000..87a0367 --- /dev/null +++ b/mailcow/data/Dockerfiles/ldap/templates/sogo/plist_ldap @@ -0,0 +1,43 @@ + + + type + ldap + id + $${line}_ldap + + CNFieldName + cn + IDFieldName + uid + UIDFieldName + uid + + baseDN + $ldap_base_dn + + bindDN + $ldap_bind_dn + bindPassword + $ldap_bind_dn_password + bindFields + + uid + mail + + + bindAsCurrentUser + YES + + hostname + $ldap_host + canAuthenticate + YES + + isAddressBook + NO + displayName + LibertaCasa + + scope + SUB + diff --git a/mailcow/data/conf/dovecot/extra.conf b/mailcow/data/conf/dovecot/extra.conf new file mode 100644 index 0000000..2ec91d8 --- /dev/null +++ b/mailcow/data/conf/dovecot/extra.conf @@ -0,0 +1,5 @@ +passdb { + args = /etc/dovecot/ldap/passdb.conf + driver = ldap +} + diff --git a/mailcow/data/conf/dovecot/ldap/passdb.conf b/mailcow/data/conf/dovecot/ldap/passdb.conf new file mode 100644 index 0000000..28feedd --- /dev/null +++ b/mailcow/data/conf/dovecot/ldap/passdb.conf @@ -0,0 +1,9 @@ +uris = ldaps://orpheus.syscid.com +ldap_version = 3 +base = OU=syscid-users,DC=syscid,DC=com +auth_bind = yes +dn = $BINDDN +dnpass = $BINDSEC +pass_attrs = userPassword=password +pass_filter = (&(memberof=cn=syscid_email_mailcow,ou=syscid-groups,dc=syscid,dc=com)(|(uid=%n)(mail=%u))) + diff --git a/mailcow/data/conf/sogo/plist_ldap b/mailcow/data/conf/sogo/plist_ldap new file mode 100644 index 0000000..dfa203e --- /dev/null +++ b/mailcow/data/conf/sogo/plist_ldap @@ -0,0 +1,44 @@ + + + type + ldap + id + ${line}_ldap + + CNFieldName + cn + IDFieldName + uid + UIDFieldName + uid + + baseDN + OU=syscid-users,DC=syscid,DC=com + + bindDN + $BINDDN + bindPassword + $BINDSEC + bindFields + + uid + mail + + + bindAsCurrentUser + YES + + hostname + ldaps://orpheus.syscid.com + canAuthenticate + YES + + isAddressBook + NO + displayName + LibertaCasa + + scope + SUB + + -- cgit v1.2.3