BootstrapDovecot.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. from jinja2 import Environment, FileSystemLoader
  2. from modules.BootstrapBase import BootstrapBase
  3. from pathlib import Path
  4. import os
  5. import sys
  6. import time
  7. import pwd
  8. import hashlib
  9. class Bootstrap(BootstrapBase):
  10. def bootstrap(self):
  11. # Connect to MySQL
  12. self.connect_mysql()
  13. self.wait_for_schema_update()
  14. # Connect to Redis
  15. self.connect_redis()
  16. self.redis_conn.set("DOVECOT_REPL_HEALTH", 1)
  17. # Wait for DNS
  18. self.wait_for_dns("mailcow.email")
  19. # Create missing directories
  20. self.create_dir("/etc/dovecot/sql/")
  21. self.create_dir("/etc/dovecot/auth/")
  22. self.create_dir("/var/vmail/_garbage")
  23. self.create_dir("/var/vmail/sieve")
  24. self.create_dir("/etc/sogo")
  25. self.create_dir("/var/volatile")
  26. # Setup Jinja2 Environment and load vars
  27. self.env = Environment(
  28. loader=FileSystemLoader('/etc/dovecot/config_templates'),
  29. keep_trailing_newline=True,
  30. lstrip_blocks=True,
  31. trim_blocks=True
  32. )
  33. extra_vars = {
  34. "VALID_CERT_DIRS": self.get_valid_cert_dirs(),
  35. "RAND_USER": self.rand_pass(),
  36. "RAND_PASS": self.rand_pass(),
  37. "RAND_PASS2": self.rand_pass(),
  38. "ENV_VARS": dict(os.environ)
  39. }
  40. self.env_vars = self.prepare_template_vars('/overwrites.json', extra_vars)
  41. # Escape DBPASS
  42. self.env_vars['DBPASS'] = self.env_vars['DBPASS'].replace('"', r'\"')
  43. # Set custom filters
  44. self.env.filters['sha1'] = self.sha1_filter
  45. print("Set Timezone")
  46. self.set_timezone()
  47. print("Render config")
  48. self.render_config("dovecot-dict-sql-quota.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-quota.conf")
  49. self.render_config("dovecot-dict-sql-userdb.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-userdb.conf")
  50. self.render_config("dovecot-dict-sql-sieve_before.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf")
  51. self.render_config("dovecot-dict-sql-sieve_after.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf")
  52. self.render_config("mail_plugins.j2", "/etc/dovecot/mail_plugins")
  53. self.render_config("mail_plugins_imap.j2", "/etc/dovecot/mail_plugins_imap")
  54. self.render_config("mail_plugins_lmtp.j2", "/etc/dovecot/mail_plugins_lmtp")
  55. self.render_config("global_sieve_after.sieve.j2", "/var/vmail/sieve/global_sieve_after.sieve")
  56. self.render_config("global_sieve_before.sieve.j2", "/var/vmail/sieve/global_sieve_before.sieve")
  57. self.render_config("dovecot-master.passwd.j2", "/etc/dovecot/dovecot-master.passwd")
  58. self.render_config("dovecot-master.userdb.j2", "/etc/dovecot/dovecot-master.userdb")
  59. self.render_config("sieve.creds.j2", "/etc/sogo/sieve.creds")
  60. self.render_config("sogo-sso.pass.j2", "/etc/phpfpm/sogo-sso.pass")
  61. self.render_config("cron.creds.j2", "/etc/sogo/cron.creds")
  62. self.render_config("source_env.sh.j2", "/source_env.sh")
  63. self.render_config("maildir_gc.sh.j2", "/usr/local/bin/maildir_gc.sh")
  64. self.render_config("dovecot.conf.j2", "/etc/dovecot/dovecot.conf")
  65. files = [
  66. "/etc/dovecot/mail_plugins",
  67. "/etc/dovecot/mail_plugins_imap",
  68. "/etc/dovecot/mail_plugins_lmtp",
  69. "/templates/quarantine.tpl"
  70. ]
  71. for file in files:
  72. self.set_permissions(file, 0o644)
  73. try:
  74. # Migrate old sieve_after file
  75. self.move_file("/etc/dovecot/sieve_after", "/var/vmail/sieve/global_sieve_after.sieve")
  76. except Exception as e:
  77. pass
  78. try:
  79. # Cleanup random user maildirs
  80. self.remove("/var/vmail/mailcow.local", wipe_contents=True)
  81. except Exception as e:
  82. pass
  83. try:
  84. # Cleanup PIDs
  85. self.remove("/tmp/quarantine_notify.pid")
  86. except Exception as e:
  87. pass
  88. try:
  89. self.remove("/var/run/dovecot/master.pid")
  90. except Exception as e:
  91. pass
  92. # Check permissions of vmail/index/garbage directories.
  93. # Do not do this every start-up, it may take a very long time. So we use a stat check here.
  94. files = [
  95. "/var/vmail",
  96. "/var/vmail/_garbage",
  97. "/var/vmail_index"
  98. ]
  99. for file in files:
  100. path = Path(file)
  101. try:
  102. stat_info = path.stat()
  103. current_user = pwd.getpwuid(stat_info.st_uid).pw_name
  104. if current_user != "vmail":
  105. print(f"Ownership of {path} is {current_user}, fixing to vmail:vmail...")
  106. self.set_owner(path, user="vmail", group="vmail", recursive=True)
  107. else:
  108. print(f"Ownership of {path} is already correct (vmail)")
  109. except Exception as e:
  110. print(f"Error checking ownership of {path}: {e}")
  111. # Compile sieve scripts
  112. files = [
  113. "/var/vmail/sieve/global_sieve_before.sieve",
  114. "/var/vmail/sieve/global_sieve_after.sieve",
  115. "/usr/lib/dovecot/sieve/report-spam.sieve",
  116. "/usr/lib/dovecot/sieve/report-ham.sieve",
  117. ]
  118. for file in files:
  119. self.run_command(["sievec", file], check=False)
  120. # Fix permissions
  121. for path in Path("/etc/dovecot/sql").glob("*.conf"):
  122. self.set_owner(path, "root", "root")
  123. self.set_permissions(path, 0o640)
  124. files = [
  125. "/etc/dovecot/auth/passwd-verify.lua",
  126. *Path("/etc/dovecot/sql").glob("dovecot-dict-sql-sieve*"),
  127. *Path("/etc/dovecot/sql").glob("dovecot-dict-sql-quota*")
  128. ]
  129. for file in files:
  130. self.set_owner(file, "root", "dovecot")
  131. self.set_permissions("/etc/dovecot/auth/passwd-verify.lua", 0o640)
  132. for file in ["/var/vmail/sieve", "/var/volatile", "/var/vmail_index"]:
  133. self.set_owner(file, "vmail", "vmail", recursive=True)
  134. self.run_command(["adduser", "vmail", "tty"])
  135. self.run_command(["chmod", "g+rw", "/dev/console"])
  136. self.set_owner("/dev/console", "root", "tty")
  137. files = [
  138. "/usr/lib/dovecot/sieve/rspamd-pipe-ham",
  139. "/usr/lib/dovecot/sieve/rspamd-pipe-spam",
  140. "/usr/local/bin/imapsync_runner.pl",
  141. "/usr/local/bin/imapsync",
  142. "/usr/local/bin/trim_logs.sh",
  143. "/usr/local/bin/sa-rules.sh",
  144. "/usr/local/bin/clean_q_aged.sh",
  145. "/usr/local/bin/maildir_gc.sh",
  146. "/usr/local/sbin/stop-supervisor.sh",
  147. "/usr/local/bin/quota_notify.py",
  148. "/usr/local/bin/repl_health.sh",
  149. "/usr/local/bin/optimize-fts.sh"
  150. ]
  151. for file in files:
  152. self.set_permissions(file, 0o755)
  153. # Collect SA rules once now
  154. self.run_command(["/usr/local/bin/sa-rules.sh"], check=False)
  155. self.generate_mail_crypt_keys()
  156. self.cleanup_imapsync_jobs()
  157. self.generate_guid_version()
  158. def get_valid_cert_dirs(self):
  159. """
  160. Returns a mapping of domains to their certificate directory path.
  161. Example:
  162. {
  163. "example.com": "/etc/ssl/mail/example.com/",
  164. "www.example.com": "/etc/ssl/mail/example.com/"
  165. }
  166. """
  167. sni_map = {}
  168. base_path = Path("/etc/ssl/mail")
  169. if not base_path.exists():
  170. return sni_map
  171. for cert_dir in base_path.iterdir():
  172. if not cert_dir.is_dir():
  173. continue
  174. domains_file = cert_dir / "domains"
  175. cert_file = cert_dir / "cert.pem"
  176. key_file = cert_dir / "key.pem"
  177. if not (domains_file.exists() and cert_file.exists() and key_file.exists()):
  178. continue
  179. with open(domains_file, "r") as f:
  180. domains = [line.strip() for line in f if line.strip()]
  181. for domain in domains:
  182. sni_map[domain] = str(cert_dir)
  183. return sni_map
  184. def generate_mail_crypt_keys(self):
  185. """
  186. Ensures mail_crypt EC keypair exists. Generates if missing. Adjusts permissions.
  187. """
  188. key_dir = Path("/mail_crypt")
  189. priv_key = key_dir / "ecprivkey.pem"
  190. pub_key = key_dir / "ecpubkey.pem"
  191. # Generate keys if they don't exist or are empty
  192. if not priv_key.exists() or priv_key.stat().st_size == 0 or \
  193. not pub_key.exists() or pub_key.stat().st_size == 0:
  194. self.run_command(
  195. "openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem",
  196. shell=True
  197. )
  198. self.run_command(
  199. "openssl pkey -in /mail_crypt/ecprivkey.pem -pubout -out /mail_crypt/ecpubkey.pem",
  200. shell=True
  201. )
  202. # Set ownership to UID 401 (dovecot)
  203. self.set_owner(priv_key, user='401')
  204. self.set_owner(pub_key, user='401')
  205. def cleanup_imapsync_jobs(self):
  206. """
  207. Cleans up stale imapsync locks and resets running status in the database.
  208. Deletes the imapsync_busy.lock file if present and sets `is_running` to 0
  209. in the `imapsync` table, if it exists.
  210. Logs:
  211. Any issues with file operations or SQL execution.
  212. """
  213. lock_file = Path("/tmp/imapsync_busy.lock")
  214. if lock_file.exists():
  215. try:
  216. lock_file.unlink()
  217. except Exception as e:
  218. print(f"Failed to remove lock file: {e}")
  219. try:
  220. cursor = self.mysql_conn.cursor()
  221. cursor.execute("SHOW TABLES LIKE 'imapsync'")
  222. result = cursor.fetchone()
  223. if result:
  224. cursor.execute("UPDATE imapsync SET is_running='0'")
  225. self.mysql_conn.commit()
  226. cursor.close()
  227. except Exception as e:
  228. print(f"Error updating imapsync table: {e}")
  229. def generate_guid_version(self):
  230. """
  231. Waits for the `versions` table to be created, then generates a GUID
  232. based on the mail hostname and Dovecot's public key and inserts it
  233. into the `versions` table.
  234. If the key or hash is missing or malformed, marks it as INVALID.
  235. """
  236. try:
  237. result = self.run_command(["doveconf", "-P"], check=True, log_output=False)
  238. pubkey_path = None
  239. for line in result.stdout.splitlines():
  240. if "mail_crypt_global_public_key" in line:
  241. parts = line.split('<')
  242. if len(parts) > 1:
  243. pubkey_path = parts[1].strip()
  244. break
  245. if pubkey_path and Path(pubkey_path).exists():
  246. with open(pubkey_path, "rb") as key_file:
  247. pubkey_data = key_file.read()
  248. hostname = self.env_vars.get("MAILCOW_HOSTNAME", "mailcow.local").encode("utf-8")
  249. concat = hostname + pubkey_data
  250. guid = hashlib.sha256(concat).hexdigest()
  251. if len(guid) == 64:
  252. version_value = guid
  253. else:
  254. version_value = "INVALID"
  255. cursor = self.mysql_conn.cursor()
  256. cursor.execute(
  257. "REPLACE INTO versions (application, version) VALUES (%s, %s)",
  258. ("GUID", version_value)
  259. )
  260. self.mysql_conn.commit()
  261. cursor.close()
  262. else:
  263. print("Public key not found or unreadable. GUID not generated.")
  264. except Exception as e:
  265. print(f"Failed to generate or store GUID: {e}")