BootstrapDovecot.py 10 KB

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