BootstrapDovecot.py 8.9 KB

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