BootstrapDovecot.py 8.9 KB

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