├── .gitignore ├── templates ├── dovecot │ ├── extra.conf │ └── ldap │ │ └── passdb.conf └── sogo │ └── plist_ldap ├── Dockerfile ├── filedb.py ├── api.py ├── README.md └── syncer.py /.gitignore: -------------------------------------------------------------------------------- 1 | db 2 | conf 3 | __pycache__ 4 | .vscode -------------------------------------------------------------------------------- /templates/dovecot/extra.conf: -------------------------------------------------------------------------------- 1 | passdb { 2 | args = /etc/dovecot/ldap/passdb.conf 3 | driver = ldap 4 | } 5 | -------------------------------------------------------------------------------- /templates/dovecot/ldap/passdb.conf: -------------------------------------------------------------------------------- 1 | uris = $ldap_uri 2 | ldap_version = 3 3 | base = $ldap_base_dn 4 | auth_bind = yes 5 | auth_bind_userdn = %u 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-alpine 2 | 3 | RUN apk --no-cache add build-base openldap-dev python2-dev python3-dev 4 | RUN pip3 install python-ldap sqlalchemy requests 5 | 6 | COPY templates ./templates 7 | COPY api.py filedb.py syncer.py ./ 8 | 9 | VOLUME [ "/db" ] 10 | VOLUME [ "/conf/dovecot" ] 11 | VOLUME [ "/conf/sogo" ] 12 | 13 | ENTRYPOINT [ "python3", "syncer.py" ] -------------------------------------------------------------------------------- /templates/sogo/plist_ldap: -------------------------------------------------------------------------------- 1 | 2 | 3 | type 4 | ldap 5 | id 6 | $${line}_ldap 7 | 8 | CNFieldName 9 | cn 10 | IDFieldName 11 | cn 12 | UIDFieldName 13 | userPrincipalName 14 | 15 | baseDN 16 | $ldap_base_dn 17 | 18 | bindDN 19 | $ldap_bind_dn 20 | bindPassword 21 | $ldap_bind_dn_password 22 | bindFields 23 | 24 | userPrincipalName 25 | 26 | 27 | bindAsCurrentUser 28 | YES 29 | 30 | hostname 31 | $ldap_uri 32 | canAuthenticate 33 | YES 34 | 35 | filter 36 | $sogo_ldap_filter 37 | 38 | isAddressBook 39 | NO 40 | displayName 41 | Active Directory 42 | 43 | scope 44 | SUB 45 | -------------------------------------------------------------------------------- /filedb.py: -------------------------------------------------------------------------------- 1 | import datetime, os 2 | 3 | import logging 4 | logging.basicConfig(format='%(asctime)s %(message)s', datefmt='%d.%m.%y %H:%M:%S', level=logging.INFO) 5 | 6 | import sqlalchemy 7 | from sqlalchemy.ext.declarative import declarative_base 8 | from sqlalchemy import create_engine, Column, String, Boolean, DateTime 9 | from sqlalchemy.orm import sessionmaker 10 | 11 | db_file = 'db/ldap-mailcow.sqlite3' 12 | 13 | Base = declarative_base() 14 | 15 | class DbUser(Base): # type: ignore 16 | __tablename__ = 'users' 17 | email = Column(String, primary_key=True) 18 | active = Column(Boolean, nullable=False) 19 | last_seen = Column(DateTime, nullable=False) 20 | 21 | Session = sessionmaker() 22 | 23 | if not os.path.isfile(db_file): 24 | logging.info (f"New database file created: {db_file}") 25 | 26 | db_engine = create_engine(f"sqlite:///{db_file}") # echo=True 27 | Base.metadata.create_all(db_engine) 28 | Session.configure(bind=db_engine) 29 | session = Session() 30 | session_time = datetime.datetime.now() 31 | 32 | def get_unchecked_active_users(): 33 | query = session.query(DbUser.email).filter(DbUser.last_seen != session_time).filter(DbUser.active == True) 34 | 35 | return [x.email for x in query] 36 | 37 | def add_user(email, active=True): 38 | session.add(DbUser(email=email, active=active, last_seen=session_time)) 39 | session.commit() 40 | 41 | def check_user(email): 42 | user = session.query(DbUser).filter_by(email=email).first() 43 | if user is None: 44 | return (False, False) 45 | user.last_seen = session_time 46 | session.commit() 47 | return (True, user.active) 48 | 49 | def user_set_active_to(email, active): 50 | user = session.query(DbUser).filter_by(email=email).first() 51 | user.active = active 52 | session.commit() -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import random, string, sys 2 | import requests 3 | 4 | def __post_request(url, json_data): 5 | api_url = f"{api_host}/{url}" 6 | headers = {'X-API-Key': api_key, 'Content-type': 'application/json'} 7 | 8 | req = requests.post(api_url, headers=headers, json=json_data) 9 | rsp = req.json() 10 | req.close() 11 | 12 | if isinstance(rsp, list): 13 | rsp = rsp[0] 14 | 15 | if not "type" in rsp or not "msg" in rsp: 16 | sys.exit(f"API {url}: got response without type or msg from Mailcow API") 17 | 18 | if rsp['type'] != 'success': 19 | sys.exit(f"API {url}: {rsp['type']} - {rsp['msg']}") 20 | 21 | def add_user(email, name, active): 22 | password = ''.join(random.choices(string.ascii_letters + string.digits, k=20)) 23 | json_data = { 24 | 'local_part':email.split('@')[0], 25 | 'domain':email.split('@')[1], 26 | 'name':name, 27 | 'password':password, 28 | 'password2':password, 29 | "active": 1 if active else 0 30 | } 31 | 32 | __post_request('api/v1/add/mailbox', json_data) 33 | 34 | def edit_user(email, active=None, name=None): 35 | attr = {} 36 | if (active is not None): 37 | attr['active'] = 1 if active else 0 38 | if (name is not None): 39 | attr['name'] = name 40 | 41 | json_data = { 42 | 'items': [email], 43 | 'attr': attr 44 | } 45 | 46 | __post_request('api/v1/edit/mailbox', json_data) 47 | 48 | def __delete_user(email): 49 | json_data = [email] 50 | 51 | __post_request('api/v1/delete/mailbox', json_data) 52 | 53 | def check_user(email): 54 | url = f"{api_host}/api/v1/get/mailbox/{email}" 55 | headers = {'X-API-Key': api_key, 'Content-type': 'application/json'} 56 | req = requests.get(url, headers=headers) 57 | rsp = req.json() 58 | req.close() 59 | 60 | if not isinstance(rsp, dict): 61 | sys.exit("API get/mailbox: got response of a wrong type") 62 | 63 | if (not rsp): 64 | return (False, False, None) 65 | 66 | if 'active_int' not in rsp and rsp['type'] == 'error': 67 | sys.exit(f"API {url}: {rsp['type']} - {rsp['msg']}") 68 | 69 | return (True, bool(rsp['active_int']), rsp['name']) 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ldap-mailcow 2 | 3 | Adds LDAP accounts to mailcow-dockerized and enables LDAP (e.g., Active Directory) authentication. 4 | 5 | * [How does it work](#how-does-it-work) 6 | * [Usage](#usage) 7 | * [LDAP Fine-tuning](#ldap-fine-tuning) 8 | * [Limitations](#limitations) 9 | * [WebUI and EAS authentication](#webui-and-eas-authentication) 10 | * [Two-way sync](#two-way-sync) 11 | * [Customizations and Integration support](#customizations-and-integration-support) 12 | 13 | ## How does it work 14 | 15 | A python script periodically checks and creates new LDAP accounts and deactivates deleted and disabled ones with mailcow API. It also enables LDAP authentication in SOGo and dovecot. 16 | 17 | ## Usage 18 | 19 | 1. Create a `data/ldap` directory. SQLite database for synchronization will be stored there. 20 | 2. Extend your `docker-compose.override.yml` with an additional container: 21 | 22 | ```yaml 23 | ldap-mailcow: 24 | image: programmierus/ldap-mailcow 25 | network_mode: host 26 | container_name: mailcowcustomized_ldap-mailcow 27 | depends_on: 28 | - nginx-mailcow 29 | volumes: 30 | - ./data/ldap:/db:rw 31 | - ./data/conf/dovecot:/conf/dovecot:rw 32 | - ./data/conf/sogo:/conf/sogo:rw 33 | environment: 34 | - LDAP-MAILCOW_LDAP_URI=ldap(s)://dc.example.local 35 | - LDAP-MAILCOW_LDAP_BASE_DN=OU=Mail Users,DC=example,DC=local 36 | - LDAP-MAILCOW_LDAP_BIND_DN=CN=Bind DN,CN=Users,DC=example,DC=local 37 | - LDAP-MAILCOW_LDAP_BIND_DN_PASSWORD=BindPassword 38 | - LDAP-MAILCOW_API_HOST=https://mailcow.example.local 39 | - LDAP-MAILCOW_API_KEY=XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX 40 | - LDAP-MAILCOW_SYNC_INTERVAL=300 41 | - LDAP-MAILCOW_LDAP_FILTER=(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=CN=Group,CN=Users,DC=example DC=local)) 42 | - LDAP-MAILCOW_SOGO_LDAP_FILTER=objectClass='user' AND objectCategory='person' AND memberOf:1.2.840.113556.1.4.1941:='CN=Group,CN=Users,DC=example DC=local' 43 | ``` 44 | 45 | 3. Configure environmental variables: 46 | 47 | * `LDAP-MAILCOW_LDAP_URI` - LDAP (e.g., Active Directory) URI (must be reachable from within the container). The URIs are in syntax `protocol://host:port`. For example `ldap://localhost` or `ldaps://secure.domain.org` 48 | * `LDAP-MAILCOW_LDAP_BASE_DN` - base DN where user accounts can be found 49 | * `LDAP-MAILCOW_LDAP_BIND_DN` - bind DN of a special LDAP account that will be used to browse for users 50 | * `LDAP-MAILCOW_LDAP_BIND_DN_PASSWORD` - password for bind DN account 51 | * `LDAP-MAILCOW_API_HOST` - mailcow API url. Make sure it's enabled and accessible from within the container for both reads and writes 52 | * `LDAP-MAILCOW_API_KEY` - mailcow API key (read/write) 53 | * `LDAP-MAILCOW_SYNC_INTERVAL` - interval in seconds between LDAP synchronizations 54 | * **Optional** LDAP filters (see example above). SOGo uses special syntax, so you either have to **specify both or none**: 55 | * `LDAP-MAILCOW_LDAP_FILTER` - LDAP filter to apply, defaults to `(&(objectClass=user)(objectCategory=person))` 56 | * `LDAP-MAILCOW_SOGO_LDAP_FILTER` - LDAP filter to apply for SOGo ([special syntax](https://sogo.nu/files/docs/SOGoInstallationGuide.html#_authentication_using_ldap)), defaults to `objectClass='user' AND objectCategory='person'` 57 | 58 | 4. Start additional container: `docker-compose up -d ldap-mailcow` 59 | 5. Check logs `docker-compose logs ldap-mailcow` 60 | 6. Restart dovecot and SOGo if necessary `docker-compose restart sogo-mailcow dovecot-mailcow` 61 | 62 | ### LDAP Fine-tuning 63 | 64 | Container internally uses the following configuration templates: 65 | 66 | * SOGo: `/templates/sogo/plist_ldap` 67 | * dovecot: `/templates/dovecot/ldap/passdb.conf` 68 | 69 | These files have been tested against Active Directory running on Windows Server 2019 domain controller. If necessary, you can edit and remount them through docker volumes. Some documentation on these files can be found here: [dovecot](https://doc.dovecot.org/configuration_manual/authentication/ldap/), [SOGo](https://sogo.nu/files/docs/SOGoInstallationGuide.html#_authentication_using_ldap) 70 | 71 | ## Limitations 72 | 73 | ### WebUI and EAS authentication 74 | 75 | This tool enables authentication for Dovecot and SOGo, which means you will be able to log into POP3, SMTP, IMAP, and SOGo Web-Interface. **You will not be able to log into mailcow UI or EAS using your LDAP credentials by default.** 76 | 77 | As a workaround, you can hook IMAP authentication directly to mailcow by adding the following code above [this line](https://github.com/mailcow/mailcow-dockerized/blob/48b74d77a0c39bcb3399ce6603e1ad424f01fc3e/data/web/inc/functions.inc.php#L608): 78 | 79 | ```php 80 | $mbox = imap_open ("{dovecot:993/imap/ssl/novalidate-cert}INBOX", $user, $pass); 81 | if ($mbox != false) { 82 | imap_close($mbox); 83 | return "user"; 84 | } 85 | ``` 86 | 87 | As a side-effect, It will also allow logging into mailcow UI using mailcow app passwords (since they are valid for IMAP). **It is not a supported solution with mailcow and has to be done only at your own risk!** 88 | 89 | ### Two-way sync 90 | 91 | Users from your LDAP directory will be added (and deactivated if disabled/not found) to your mailcow database. Not vice-versa, and this is by design. 92 | 93 | ## Customizations and Integration support 94 | 95 | External authentication (identity federation) is an enterprise feature [for mailcow](https://github.com/mailcow/mailcow-dockerized/issues/2316#issuecomment-491212921). That’s why I developed an external solution, and it is unlikely that it’ll be ever directly integrated into mailcow. 96 | 97 | I’ve created this tool because I needed it for my regular work. You are free to use it for commercial needs. Please understand that I can work on issues only if they fall within the scope of my current work interests or if I’ll have some available free time (never happened for many years). I’ll do my best to review submitted PRs ASAP, though. 98 | 99 | **You can always [contact me](mailto:programmierus@gmail.com) to help you with the integration or for custom modifications on a paid basis. My current hourly rate (ActivityWatch tracked) is 100,-€ with 3h minimum commitment.** 100 | -------------------------------------------------------------------------------- /syncer.py: -------------------------------------------------------------------------------- 1 | import sys, os, string, time, datetime 2 | import ldap 3 | 4 | import filedb, api 5 | 6 | from string import Template 7 | from pathlib import Path 8 | 9 | import logging 10 | logging.basicConfig(format='%(asctime)s %(message)s', datefmt='%d.%m.%y %H:%M:%S', level=logging.INFO) 11 | 12 | def main(): 13 | global config 14 | config = read_config() 15 | 16 | passdb_conf = read_dovecot_passdb_conf_template() 17 | plist_ldap = read_sogo_plist_ldap_template() 18 | extra_conf = read_dovecot_extra_conf() 19 | 20 | passdb_conf_changed = apply_config('conf/dovecot/ldap/passdb.conf', config_data = passdb_conf) 21 | extra_conf_changed = apply_config('conf/dovecot/extra.conf', config_data = extra_conf) 22 | plist_ldap_changed = apply_config('conf/sogo/plist_ldap', config_data = plist_ldap) 23 | 24 | if passdb_conf_changed or extra_conf_changed or plist_ldap_changed: 25 | logging.info ("One or more config files have been changed, please make sure to restart dovecot-mailcow and sogo-mailcow!") 26 | 27 | api.api_host = config['API_HOST'] 28 | api.api_key = config['API_KEY'] 29 | 30 | while (True): 31 | sync() 32 | interval = int(config['SYNC_INTERVAL']) 33 | logging.info(f"Sync finished, sleeping {interval} seconds before next cycle") 34 | time.sleep(interval) 35 | 36 | def sync(): 37 | ldap_connector = ldap.initialize(f"{config['LDAP_URI']}") 38 | ldap_connector.set_option(ldap.OPT_REFERRALS, 0) 39 | ldap_connector.simple_bind_s(config['LDAP_BIND_DN'], config['LDAP_BIND_DN_PASSWORD']) 40 | 41 | ldap_results = ldap_connector.search_s(config['LDAP_BASE_DN'], ldap.SCOPE_SUBTREE, 42 | config['LDAP_FILTER'], 43 | ['userPrincipalName', 'cn', 'userAccountControl']) 44 | 45 | ldap_results = map(lambda x: ( 46 | x[1]['userPrincipalName'][0].decode(), 47 | x[1]['cn'][0].decode(), 48 | False if int(x[1]['userAccountControl'][0].decode()) & 0b10 else True), ldap_results) 49 | 50 | filedb.session_time = datetime.datetime.now() 51 | 52 | for (email, ldap_name, ldap_active) in ldap_results: 53 | (db_user_exists, db_user_active) = filedb.check_user(email) 54 | (api_user_exists, api_user_active, api_name) = api.check_user(email) 55 | 56 | unchanged = True 57 | 58 | if not db_user_exists: 59 | filedb.add_user(email, ldap_active) 60 | (db_user_exists, db_user_active) = (True, ldap_active) 61 | logging.info (f"Added filedb user: {email} (Active: {ldap_active})") 62 | unchanged = False 63 | 64 | if not api_user_exists: 65 | api.add_user(email, ldap_name, ldap_active) 66 | (api_user_exists, api_user_active, api_name) = (True, ldap_active, ldap_name) 67 | logging.info (f"Added Mailcow user: {email} (Active: {ldap_active})") 68 | unchanged = False 69 | 70 | if db_user_active != ldap_active: 71 | filedb.user_set_active_to(email, ldap_active) 72 | logging.info (f"{'Activated' if ldap_active else 'Deactived'} {email} in filedb") 73 | unchanged = False 74 | 75 | if api_user_active != ldap_active: 76 | api.edit_user(email, active=ldap_active) 77 | logging.info (f"{'Activated' if ldap_active else 'Deactived'} {email} in Mailcow") 78 | unchanged = False 79 | 80 | if api_name != ldap_name: 81 | api.edit_user(email, name=ldap_name) 82 | logging.info (f"Changed name of {email} in Mailcow to {ldap_name}") 83 | unchanged = False 84 | 85 | if unchanged: 86 | logging.info (f"Checked user {email}, unchanged") 87 | 88 | for email in filedb.get_unchecked_active_users(): 89 | (api_user_exists, api_user_active, _) = api.check_user(email) 90 | 91 | if (api_user_active and api_user_active): 92 | api.edit_user(email, active=False) 93 | logging.info (f"Deactivated user {email} in Mailcow, not found in LDAP") 94 | 95 | filedb.user_set_active_to(email, False) 96 | logging.info (f"Deactivated user {email} in filedb, not found in LDAP") 97 | 98 | def apply_config(config_file, config_data): 99 | if os.path.isfile(config_file): 100 | with open(config_file) as f: 101 | old_data = f.read() 102 | 103 | if old_data.strip() == config_data.strip(): 104 | logging.info(f"Config file {config_file} unchanged") 105 | return False 106 | 107 | backup_index = 1 108 | backup_file = f"{config_file}.ldap_mailcow_bak" 109 | while os.path.exists(backup_file): 110 | backup_file = f"{config_file}.ldap_mailcow_bak.{backup_index}" 111 | backup_index += 1 112 | 113 | os.rename(config_file, backup_file) 114 | logging.info(f"Backed up {config_file} to {backup_file}") 115 | 116 | Path(os.path.dirname(config_file)).mkdir(parents=True, exist_ok=True) 117 | 118 | print(config_data, file=open(config_file, 'w')) 119 | 120 | logging.info(f"Saved generated config file to {config_file}") 121 | return True 122 | 123 | def read_config(): 124 | required_config_keys = [ 125 | 'LDAP-MAILCOW_LDAP_URI', 126 | 'LDAP-MAILCOW_LDAP_BASE_DN', 127 | 'LDAP-MAILCOW_LDAP_BIND_DN', 128 | 'LDAP-MAILCOW_LDAP_BIND_DN_PASSWORD', 129 | 'LDAP-MAILCOW_API_HOST', 130 | 'LDAP-MAILCOW_API_KEY', 131 | 'LDAP-MAILCOW_SYNC_INTERVAL' 132 | ] 133 | 134 | config = {} 135 | 136 | for config_key in required_config_keys: 137 | if config_key not in os.environ: 138 | sys.exit (f"Required environment value {config_key} is not set") 139 | 140 | config[config_key.replace('LDAP-MAILCOW_', '')] = os.environ[config_key] 141 | 142 | if 'LDAP-MAILCOW_LDAP_FILTER' in os.environ and 'LDAP-MAILCOW_SOGO_LDAP_FILTER' not in os.environ: 143 | sys.exit('LDAP-MAILCOW_SOGO_LDAP_FILTER is required when you specify LDAP-MAILCOW_LDAP_FILTER') 144 | 145 | if 'LDAP-MAILCOW_SOGO_LDAP_FILTER' in os.environ and 'LDAP-MAILCOW_LDAP_FILTER' not in os.environ: 146 | sys.exit('LDAP-MAILCOW_LDAP_FILTER is required when you specify LDAP-MAILCOW_SOGO_LDAP_FILTER') 147 | 148 | config['LDAP_FILTER'] = os.environ['LDAP-MAILCOW_LDAP_FILTER'] if 'LDAP-MAILCOW_LDAP_FILTER' in os.environ else '(&(objectClass=user)(objectCategory=person))' 149 | config['SOGO_LDAP_FILTER'] = os.environ['LDAP-MAILCOW_SOGO_LDAP_FILTER'] if 'LDAP-MAILCOW_SOGO_LDAP_FILTER' in os.environ else "objectClass='user' AND objectCategory='person'" 150 | 151 | return config 152 | 153 | def read_dovecot_passdb_conf_template(): 154 | with open('templates/dovecot/ldap/passdb.conf') as f: 155 | data = Template(f.read()) 156 | 157 | return data.substitute( 158 | ldap_uri=config['LDAP_URI'], 159 | ldap_base_dn=config['LDAP_BASE_DN'] 160 | ) 161 | 162 | def read_sogo_plist_ldap_template(): 163 | with open('templates/sogo/plist_ldap') as f: 164 | data = Template(f.read()) 165 | 166 | return data.substitute( 167 | ldap_uri=config['LDAP_URI'], 168 | ldap_base_dn=config['LDAP_BASE_DN'], 169 | ldap_bind_dn=config['LDAP_BIND_DN'], 170 | ldap_bind_dn_password=config['LDAP_BIND_DN_PASSWORD'], 171 | sogo_ldap_filter=config['SOGO_LDAP_FILTER'] 172 | ) 173 | 174 | def read_dovecot_extra_conf(): 175 | with open('templates/dovecot/extra.conf') as f: 176 | data = f.read() 177 | 178 | return data 179 | 180 | if __name__ == '__main__': 181 | main() 182 | --------------------------------------------------------------------------------