BootstrapDovecot.py 8.8 KB

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