@rakay-technology/kraft 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (504) hide show
  1. package/README.md +263 -0
  2. package/dist/bare-HL2GUYE2.js +27 -0
  3. package/dist/bowser-HNOTPGJO.js +2825 -0
  4. package/dist/chunk-3CJLKL6E.js +124 -0
  5. package/dist/chunk-3NQQXMDR.js +2098 -0
  6. package/dist/chunk-42WQABXM.js +439 -0
  7. package/dist/chunk-5GPXMTE4.js +25 -0
  8. package/dist/chunk-AJ4QZLZ2.js +1069 -0
  9. package/dist/chunk-AO252K7Z.js +137 -0
  10. package/dist/chunk-BQMFIXHI.js +975 -0
  11. package/dist/chunk-DM3PKWDC.js +2453 -0
  12. package/dist/chunk-DOD2BXHQ.js +1364 -0
  13. package/dist/chunk-DRK3MEDS.js +2870 -0
  14. package/dist/chunk-EW4PHKB2.js +13914 -0
  15. package/dist/chunk-HMJUFZK7.js +340 -0
  16. package/dist/chunk-I2TOOF2A.js +517 -0
  17. package/dist/chunk-I3DPMTSF.js +29 -0
  18. package/dist/chunk-JSCZT76F.js +2588 -0
  19. package/dist/chunk-MNV2QFHW.js +138 -0
  20. package/dist/chunk-MOLPYPVH.js +2529 -0
  21. package/dist/chunk-N4OALTFK.js +295 -0
  22. package/dist/chunk-OFXBNFNL.js +458 -0
  23. package/dist/chunk-ONKEYSMY.js +1388 -0
  24. package/dist/chunk-PDW4L3NV.js +17 -0
  25. package/dist/chunk-PNNXWA2A.js +1947 -0
  26. package/dist/chunk-PNRPKRJU.js +11105 -0
  27. package/dist/chunk-PVOWHIND.js +26781 -0
  28. package/dist/chunk-PXKCZB2I.js +46 -0
  29. package/dist/chunk-PZVKPCKE.js +475 -0
  30. package/dist/chunk-QWHBRY2K.js +4945 -0
  31. package/dist/chunk-RFOBMHQ7.js +338 -0
  32. package/dist/chunk-RHLNVZMS.js +28 -0
  33. package/dist/chunk-RVER2EOO.js +577 -0
  34. package/dist/chunk-S3QBGGVP.js +81 -0
  35. package/dist/chunk-TAML5WMA.js +198 -0
  36. package/dist/chunk-THTEUDBC.js +3188 -0
  37. package/dist/chunk-WYNM7L22.js +329 -0
  38. package/dist/chunk-XGCG4MD5.js +102 -0
  39. package/dist/chunk-XVKUFMIB.js +148 -0
  40. package/dist/chunk-XZYFZ6A5.js +50 -0
  41. package/dist/chunk-Y7PVJUXJ.js +2275 -0
  42. package/dist/chunk-YRSZPVMB.js +306 -0
  43. package/dist/chunk-YZUKHG3S.js +362 -0
  44. package/dist/chunk-ZVFS4LUA.js +116 -0
  45. package/dist/chunk-ZWLDLTJP.js +1417 -0
  46. package/dist/detect-676SNZK4.js +48 -0
  47. package/dist/dist-es-2YRDXMUU.js +73 -0
  48. package/dist/dist-es-CCVZEFRZ.js +91 -0
  49. package/dist/dist-es-EDUSMF6Q.js +488 -0
  50. package/dist/dist-es-IOH6G4GM.js +380 -0
  51. package/dist/dist-es-KWSMDPIP.js +26 -0
  52. package/dist/dist-es-UYDDOVZ3.js +173 -0
  53. package/dist/dist-es-XUOR4NQN.js +321 -0
  54. package/dist/docker-GOT3Q7DJ.js +41 -0
  55. package/dist/docker-edge-executor-VO6JHHW2.js +19 -0
  56. package/dist/edge-import-NFCHIW7G.js +50 -0
  57. package/dist/edge-preflight-QUJ757C6.js +45 -0
  58. package/dist/ensure-container-edge-Q7IZXHGM.js +38 -0
  59. package/dist/event-streams-TR4GAC53.js +46 -0
  60. package/dist/executor-GKXKN52K.js +42 -0
  61. package/dist/host-channel-preflight-Z6HYSPKO.js +48 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.js +9469 -0
  64. package/dist/loadSso-UGF4JMRK.js +581 -0
  65. package/dist/local-executor-OMMDKXCM.js +13 -0
  66. package/dist/nginx-VITTWZZU.js +42 -0
  67. package/dist/node-bootstrap.js +277 -0
  68. package/dist/node-entry.js +55 -0
  69. package/dist/noop-CLLJHCPL.js +11 -0
  70. package/dist/openresty-lua-6I3Z6PEQ.js +90 -0
  71. package/dist/privilege-UQAI2HV4.js +20 -0
  72. package/dist/server/engine/ChangeLog +2289 -0
  73. package/dist/server/engine/Documentations +5 -0
  74. package/dist/server/engine/LICENSE +674 -0
  75. package/dist/server/engine/README.md +34 -0
  76. package/dist/server/engine/conf/amavisd +150 -0
  77. package/dist/server/engine/conf/clamav +71 -0
  78. package/dist/server/engine/conf/core +632 -0
  79. package/dist/server/engine/conf/dovecot +151 -0
  80. package/dist/server/engine/conf/fail2ban +72 -0
  81. package/dist/server/engine/conf/global +698 -0
  82. package/dist/server/engine/conf/iredapd +52 -0
  83. package/dist/server/engine/conf/logwatch +28 -0
  84. package/dist/server/engine/conf/postfix +97 -0
  85. package/dist/server/engine/conf/postgresql +93 -0
  86. package/dist/server/engine/conf/spamassassin +39 -0
  87. package/dist/server/engine/dialog/config_via_dialog.sh +258 -0
  88. package/dist/server/engine/dialog/optional_components.sh +122 -0
  89. package/dist/server/engine/dialog/pgsql_config.sh +52 -0
  90. package/dist/server/engine/dialog/virtual_domain_config.sh +94 -0
  91. package/dist/server/engine/functions/amavisd.sh +267 -0
  92. package/dist/server/engine/functions/backend.sh +85 -0
  93. package/dist/server/engine/functions/clamav.sh +100 -0
  94. package/dist/server/engine/functions/cleanup.sh +393 -0
  95. package/dist/server/engine/functions/dovecot.sh +583 -0
  96. package/dist/server/engine/functions/fail2ban.sh +209 -0
  97. package/dist/server/engine/functions/iredapd.sh +356 -0
  98. package/dist/server/engine/functions/optional_components.sh +24 -0
  99. package/dist/server/engine/functions/packages.sh +522 -0
  100. package/dist/server/engine/functions/packages_freebsd.sh +179 -0
  101. package/dist/server/engine/functions/postfix.sh +457 -0
  102. package/dist/server/engine/functions/postgresql.sh +239 -0
  103. package/dist/server/engine/functions/spamassassin.sh +49 -0
  104. package/dist/server/engine/functions/system_accounts.sh +128 -0
  105. package/dist/server/engine/iRedMail.sh +144 -0
  106. package/dist/server/engine/pkgs/get_all.sh +338 -0
  107. package/dist/server/engine/pkgs/pkgs.freebsd.sha256 +3 -0
  108. package/dist/server/engine/pkgs/pkgs.openbsd.sha256 +6 -0
  109. package/dist/server/engine/pkgs/pkgs.sha256 +1 -0
  110. package/dist/server/engine/samples/amavisd/amavisd.conf +702 -0
  111. package/dist/server/engine/samples/amavisd/amavisd.mysql +278 -0
  112. package/dist/server/engine/samples/amavisd/amavisd.pgsql +253 -0
  113. package/dist/server/engine/samples/amavisd/amavisd.schema +942 -0
  114. package/dist/server/engine/samples/amavisd/default_spam_policy.sql +46 -0
  115. package/dist/server/engine/samples/dovecot/dovecot-2.4-mariadb.conf +758 -0
  116. package/dist/server/engine/samples/dovecot/dovecot-2.4-openldap.conf +767 -0
  117. package/dist/server/engine/samples/dovecot/dovecot-2.4-pgsql.conf +762 -0
  118. package/dist/server/engine/samples/dovecot/dovecot-last-login.conf +50 -0
  119. package/dist/server/engine/samples/dovecot/dovecot-ldap.conf +21 -0
  120. package/dist/server/engine/samples/dovecot/dovecot-share-folder.conf +22 -0
  121. package/dist/server/engine/samples/dovecot/dovecot-sql.conf +28 -0
  122. package/dist/server/engine/samples/dovecot/dovecot-used-quota.conf +13 -0
  123. package/dist/server/engine/samples/dovecot/dovecot.conf +484 -0
  124. package/dist/server/engine/samples/dovecot/dovecot.sieve +7 -0
  125. package/dist/server/engine/samples/dovecot/dovecot2-quota-warning.sh +38 -0
  126. package/dist/server/engine/samples/dovecot/sql/imap_share_folder.mysql +16 -0
  127. package/dist/server/engine/samples/dovecot/sql/last_login.mysql +12 -0
  128. package/dist/server/engine/samples/dovecot/sql/used_quota.mysql +26 -0
  129. package/dist/server/engine/samples/dovecot/systemd/override.conf +3 -0
  130. package/dist/server/engine/samples/fail2ban/action.d/banned_db.conf +9 -0
  131. package/dist/server/engine/samples/fail2ban/bin/fail2ban_banned_db +268 -0
  132. package/dist/server/engine/samples/fail2ban/fail2ban.local +17 -0
  133. package/dist/server/engine/samples/fail2ban/filter.d/dovecot.iredmail.conf +5 -0
  134. package/dist/server/engine/samples/fail2ban/filter.d/postfix-pregreet.iredmail.conf +16 -0
  135. package/dist/server/engine/samples/fail2ban/filter.d/postfix.iredmail.conf +18 -0
  136. package/dist/server/engine/samples/fail2ban/filter.d/roundcube.iredmail.conf +9 -0
  137. package/dist/server/engine/samples/fail2ban/jail.d/dovecot.local +8 -0
  138. package/dist/server/engine/samples/fail2ban/jail.d/nginx-http-auth.local +8 -0
  139. package/dist/server/engine/samples/fail2ban/jail.d/postfix-pregreet.local +9 -0
  140. package/dist/server/engine/samples/fail2ban/jail.d/postfix.local +8 -0
  141. package/dist/server/engine/samples/fail2ban/jail.d/roundcube.local +9 -0
  142. package/dist/server/engine/samples/fail2ban/jail.d/sogo.local +8 -0
  143. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +11 -0
  144. package/dist/server/engine/samples/fail2ban/jail.local +6 -0
  145. package/dist/server/engine/samples/fail2ban/openbsd/rc +27 -0
  146. package/dist/server/engine/samples/fail2ban/sql/fail2ban.mysql +49 -0
  147. package/dist/server/engine/samples/fail2ban/sql/fail2ban.pgsql +48 -0
  148. package/dist/server/engine/samples/firewall/firewalld/services/ssh.xml +6 -0
  149. package/dist/server/engine/samples/firewall/firewalld/zones/iredmail.xml +15 -0
  150. package/dist/server/engine/samples/firewall/iptables/ip6tables.init.debian +362 -0
  151. package/dist/server/engine/samples/firewall/iptables/ip6tables.rules +53 -0
  152. package/dist/server/engine/samples/firewall/iptables/iptables.init.debian +363 -0
  153. package/dist/server/engine/samples/firewall/iptables/iptables.rules +54 -0
  154. package/dist/server/engine/samples/firewall/nftables.conf +58 -0
  155. package/dist/server/engine/samples/freebsd/newsyslog.conf.d/dovecot.conf +3 -0
  156. package/dist/server/engine/samples/freebsd/newsyslog.conf.d/iredapd.conf +1 -0
  157. package/dist/server/engine/samples/freebsd/newsyslog.conf.d/mlmmjadmin.conf +1 -0
  158. package/dist/server/engine/samples/freebsd/newsyslog.conf.d/php-fpm.conf +2 -0
  159. package/dist/server/engine/samples/freebsd/newsyslog.conf.d/slapd.conf +1 -0
  160. package/dist/server/engine/samples/freebsd/syslog.d/dovecot.conf +2 -0
  161. package/dist/server/engine/samples/freebsd/syslog.d/fail2ban.conf +2 -0
  162. package/dist/server/engine/samples/freebsd/syslog.d/iredapd.conf +2 -0
  163. package/dist/server/engine/samples/freebsd/syslog.d/mlmmjadmin.conf +2 -0
  164. package/dist/server/engine/samples/freebsd/syslog.d/php-fpm.conf +2 -0
  165. package/dist/server/engine/samples/freebsd/syslog.d/slapd.conf +2 -0
  166. package/dist/server/engine/samples/iredmail/iredmail.mysql +491 -0
  167. package/dist/server/engine/samples/iredmail/iredmail.pgsql +465 -0
  168. package/dist/server/engine/samples/iredmail/iredmail.schema +456 -0
  169. package/dist/server/engine/samples/iredmail/used_quota_triggers_dovecot_2.3.pgsql +40 -0
  170. package/dist/server/engine/samples/iredmail/used_quota_triggers_dovecot_2.4.pgsql +18 -0
  171. package/dist/server/engine/samples/logrotate/dovecot +13 -0
  172. package/dist/server/engine/samples/logrotate/iredapd +13 -0
  173. package/dist/server/engine/samples/logrotate/mlmmjadmin +13 -0
  174. package/dist/server/engine/samples/logrotate/openldap +19 -0
  175. package/dist/server/engine/samples/logrotate/php-fpm +13 -0
  176. package/dist/server/engine/samples/openbsd/ldapd.conf +58 -0
  177. package/dist/server/engine/samples/openbsd/pf.conf +28 -0
  178. package/dist/server/engine/samples/postfix/command_filter.pcre +3 -0
  179. package/dist/server/engine/samples/postfix/freebsd/mailer.conf +9 -0
  180. package/dist/server/engine/samples/postfix/helo_access.pcre +180 -0
  181. package/dist/server/engine/samples/postfix/ldap/catchall_maps.cf +11 -0
  182. package/dist/server/engine/samples/postfix/ldap/recipient_bcc_maps_domain.cf +11 -0
  183. package/dist/server/engine/samples/postfix/ldap/recipient_bcc_maps_user.cf +11 -0
  184. package/dist/server/engine/samples/postfix/ldap/relay_domains.cf +11 -0
  185. package/dist/server/engine/samples/postfix/ldap/sender_bcc_maps_domain.cf +11 -0
  186. package/dist/server/engine/samples/postfix/ldap/sender_bcc_maps_user.cf +11 -0
  187. package/dist/server/engine/samples/postfix/ldap/sender_dependent_relayhost_maps_domain.cf +11 -0
  188. package/dist/server/engine/samples/postfix/ldap/sender_dependent_relayhost_maps_user.cf +11 -0
  189. package/dist/server/engine/samples/postfix/ldap/sender_login_maps.cf +11 -0
  190. package/dist/server/engine/samples/postfix/ldap/transport_maps_domain.cf +11 -0
  191. package/dist/server/engine/samples/postfix/ldap/transport_maps_user.cf +11 -0
  192. package/dist/server/engine/samples/postfix/ldap/virtual_alias_maps.cf +11 -0
  193. package/dist/server/engine/samples/postfix/ldap/virtual_group_maps.cf +11 -0
  194. package/dist/server/engine/samples/postfix/ldap/virtual_group_members_maps.cf +11 -0
  195. package/dist/server/engine/samples/postfix/ldap/virtual_mailbox_domains.cf +11 -0
  196. package/dist/server/engine/samples/postfix/ldap/virtual_mailbox_maps.cf +12 -0
  197. package/dist/server/engine/samples/postfix/main.cf +282 -0
  198. package/dist/server/engine/samples/postfix/main.cf.amavisd +7 -0
  199. package/dist/server/engine/samples/postfix/main.cf.dovecot +8 -0
  200. package/dist/server/engine/samples/postfix/main.cf.ldap +39 -0
  201. package/dist/server/engine/samples/postfix/main.cf.mlmmj +5 -0
  202. package/dist/server/engine/samples/postfix/main.cf.mysql +39 -0
  203. package/dist/server/engine/samples/postfix/main.cf.pgsql +39 -0
  204. package/dist/server/engine/samples/postfix/main.cf.postscreen +30 -0
  205. package/dist/server/engine/samples/postfix/master.cf +101 -0
  206. package/dist/server/engine/samples/postfix/mysql/catchall_maps.cf +5 -0
  207. package/dist/server/engine/samples/postfix/mysql/domain_alias_catchall_maps.cf +5 -0
  208. package/dist/server/engine/samples/postfix/mysql/domain_alias_maps.cf +5 -0
  209. package/dist/server/engine/samples/postfix/mysql/recipient_bcc_maps_domain.cf +5 -0
  210. package/dist/server/engine/samples/postfix/mysql/recipient_bcc_maps_user.cf +5 -0
  211. package/dist/server/engine/samples/postfix/mysql/relay_domains.cf +5 -0
  212. package/dist/server/engine/samples/postfix/mysql/sender_bcc_maps_domain.cf +5 -0
  213. package/dist/server/engine/samples/postfix/mysql/sender_bcc_maps_user.cf +5 -0
  214. package/dist/server/engine/samples/postfix/mysql/sender_dependent_relayhost_maps.cf +6 -0
  215. package/dist/server/engine/samples/postfix/mysql/sender_login_maps.cf +5 -0
  216. package/dist/server/engine/samples/postfix/mysql/transport_maps_domain.cf +5 -0
  217. package/dist/server/engine/samples/postfix/mysql/transport_maps_maillist.cf +5 -0
  218. package/dist/server/engine/samples/postfix/mysql/transport_maps_user.cf +5 -0
  219. package/dist/server/engine/samples/postfix/mysql/virtual_alias_maps.cf +5 -0
  220. package/dist/server/engine/samples/postfix/mysql/virtual_mailbox_domains.cf +5 -0
  221. package/dist/server/engine/samples/postfix/mysql/virtual_mailbox_maps.cf +5 -0
  222. package/dist/server/engine/samples/postfix/pgsql/catchall_maps.cf +5 -0
  223. package/dist/server/engine/samples/postfix/pgsql/domain_alias_catchall_maps.cf +5 -0
  224. package/dist/server/engine/samples/postfix/pgsql/domain_alias_maps.cf +5 -0
  225. package/dist/server/engine/samples/postfix/pgsql/recipient_bcc_maps_domain.cf +5 -0
  226. package/dist/server/engine/samples/postfix/pgsql/recipient_bcc_maps_user.cf +5 -0
  227. package/dist/server/engine/samples/postfix/pgsql/relay_domains.cf +5 -0
  228. package/dist/server/engine/samples/postfix/pgsql/sender_bcc_maps_domain.cf +5 -0
  229. package/dist/server/engine/samples/postfix/pgsql/sender_bcc_maps_user.cf +5 -0
  230. package/dist/server/engine/samples/postfix/pgsql/sender_dependent_relayhost_maps.cf +6 -0
  231. package/dist/server/engine/samples/postfix/pgsql/sender_login_maps.cf +5 -0
  232. package/dist/server/engine/samples/postfix/pgsql/transport_maps_domain.cf +5 -0
  233. package/dist/server/engine/samples/postfix/pgsql/transport_maps_maillist.cf +5 -0
  234. package/dist/server/engine/samples/postfix/pgsql/transport_maps_user.cf +5 -0
  235. package/dist/server/engine/samples/postfix/pgsql/virtual_alias_maps.cf +5 -0
  236. package/dist/server/engine/samples/postfix/pgsql/virtual_mailbox_domains.cf +5 -0
  237. package/dist/server/engine/samples/postfix/pgsql/virtual_mailbox_maps.cf +5 -0
  238. package/dist/server/engine/samples/postfix/postscreen_access.cidr +6 -0
  239. package/dist/server/engine/samples/postgresql/sql/add_first_domain_and_user.sql +34 -0
  240. package/dist/server/engine/samples/postgresql/sql/grant_permissions.sql +14 -0
  241. package/dist/server/engine/samples/postgresql/sql/init_vmail_db.sql +15 -0
  242. package/dist/server/engine/samples/rsyslog.d/1-iredmail-dovecot.conf +23 -0
  243. package/dist/server/engine/samples/rsyslog.d/1-iredmail-fail2ban.conf +2 -0
  244. package/dist/server/engine/samples/rsyslog.d/1-iredmail-iredapd.conf +12 -0
  245. package/dist/server/engine/samples/rsyslog.d/1-iredmail-mlmmjadmin.conf +12 -0
  246. package/dist/server/engine/samples/rsyslog.d/1-iredmail-openldap.conf +2 -0
  247. package/dist/server/engine/samples/rsyslog.d/1-iredmail-phpfpm.conf +3 -0
  248. package/dist/server/engine/samples/spamassassin/local.cf +186 -0
  249. package/dist/server/engine/samples/spamassassin/razor.conf +1 -0
  250. package/dist/server/engine/samples/systemd/clamd.service.d/override.conf +2 -0
  251. package/dist/server/engine/samples/systemd/mariadb.service.d/override.conf +3 -0
  252. package/dist/server/engine/samples/systemd/slapd.service.d/override.conf +6 -0
  253. package/dist/server/engine/samples/yum/CentOS-Linux-AppStream.repo +6 -0
  254. package/dist/server/engine/samples/yum/CentOS-Linux-PowerTools.repo +6 -0
  255. package/dist/server/engine/samples/yum/CentOS-Stream-AppStream.repo +7 -0
  256. package/dist/server/engine/samples/yum/CentOS-Stream-PowerTools.repo +7 -0
  257. package/dist/server/engine/samples/yum/symas-openldap.repo +6 -0
  258. package/dist/server/engine/tools/add_enabled_service.py +60 -0
  259. package/dist/server/engine/tools/backup_mysql.sh +238 -0
  260. package/dist/server/engine/tools/backup_openldap.sh +230 -0
  261. package/dist/server/engine/tools/backup_pgsql.sh +217 -0
  262. package/dist/server/engine/tools/backup_sogo.sh +156 -0
  263. package/dist/server/engine/tools/create_mail_user_OpenLDAP.py +335 -0
  264. package/dist/server/engine/tools/create_mail_user_OpenLDAP.sh +287 -0
  265. package/dist/server/engine/tools/create_mail_user_SQL.sh +127 -0
  266. package/dist/server/engine/tools/enable_postscreen.sh +144 -0
  267. package/dist/server/engine/tools/fail2ban_unban_ip.sh +39 -0
  268. package/dist/server/engine/tools/find_sasl_login_ip.sh +20 -0
  269. package/dist/server/engine/tools/find_top_sasl_usernames.sh +28 -0
  270. package/dist/server/engine/tools/generate_ssl_keys.sh +85 -0
  271. package/dist/server/engine/tools/kill_high_cpu_sogo_process.sh +32 -0
  272. package/dist/server/engine/tools/ldap_assign_user_to_groups.py +151 -0
  273. package/dist/server/engine/tools/ldap_move_members_to_another_group.py +129 -0
  274. package/dist/server/engine/tools/migrate_sql_alias_table.py +177 -0
  275. package/dist/server/engine/update/0.9.8/amavisd.mysql +29 -0
  276. package/dist/server/engine/update/0.9.8/amavisd.pgsql +39 -0
  277. package/dist/server/engine/update/0.9.8/iredmail.mysql +53 -0
  278. package/dist/server/engine/update/0.9.8/iredmail.pgsql +61 -0
  279. package/dist/server/engine/update/0.9.9/iredmail.mysql +2 -0
  280. package/dist/server/engine/update/0.9.9/iredmail.pgsql +2 -0
  281. package/dist/server/engine/update/1.0/iredmail.mysql +2 -0
  282. package/dist/server/engine/update/1.0/iredmail.pgsql +2 -0
  283. package/dist/server/engine/update/1.2/last_login.mysql +16 -0
  284. package/dist/server/engine/update/1.4.0/iredmail.mysql +22 -0
  285. package/dist/server/engine/update/1.4.0/iredmail.pgsql +20 -0
  286. package/dist/server/engine/update/1.4.1/iredmail.mysql +3 -0
  287. package/dist/server/engine/update/1.4.1/iredmail.pgsql +3 -0
  288. package/dist/server/engine/update/1.4.1/sogo.mysql +11 -0
  289. package/dist/server/engine/update/1.4.1/sogo.pgsql +30 -0
  290. package/dist/server/engine/update/1.4.1/update-ldap.py +66 -0
  291. package/dist/server/engine/update/1.4.2/iredmail.mysql +9 -0
  292. package/dist/server/engine/update/1.4.2/iredmail.pgsql +9 -0
  293. package/dist/server/engine/update/1.4.2/sogo.pgsql +30 -0
  294. package/dist/server/engine/update/1.6.0/sogo_view.pgsql +27 -0
  295. package/dist/server/engine/update/1.6.3/iredmail.mysql +1 -0
  296. package/dist/server/engine/update/1.7.0/fail2ban.mysql +11 -0
  297. package/dist/server/engine/update/1.7.0/fail2ban.pgsql +12 -0
  298. package/dist/server/engine/update/1.7.1/amavisd.mysql +1 -0
  299. package/dist/server/engine/update/1.7.2/vmail.mysql +30 -0
  300. package/dist/server/engine/update/1.7.3/deleted_mailboxes.mysql +43 -0
  301. package/dist/server/engine/update/1.7.3/iredadmin.mysql +26 -0
  302. package/dist/server/engine/update/1.7.3/vmail.mysql +79 -0
  303. package/dist/server/engine/update/1.7.3/vmail.pgsql +9 -0
  304. package/dist/server/engine/update/README.md +1 -0
  305. package/dist/server/engine/update/ldap/README.md +39 -0
  306. package/dist/server/engine/update/ldap/update-ldap-dovecot-2.3.py +69 -0
  307. package/dist/server/index.js +349016 -0
  308. package/dist/server/lua/geo_country.lua +127 -0
  309. package/dist/server/lua/maxminddb.lua +403 -0
  310. package/dist/server/lua/mgmt_api.lua +654 -0
  311. package/dist/server/lua/pipe_stream.lua +103 -0
  312. package/dist/server/lua/rules_guard.lua +132 -0
  313. package/dist/server/lua/rules_lib.lua +176 -0
  314. package/dist/server/lua/site_logger.lua +405 -0
  315. package/dist/server/lua/webhook_handler.lua +126 -0
  316. package/dist/server/migrations/0000_init.sql +666 -0
  317. package/dist/server/migrations/0001_brief_tomorrow_man.sql +47 -0
  318. package/dist/server/migrations/0002_funny_dazzler.sql +2 -0
  319. package/dist/server/migrations/0003_third_killer_shrike.sql +1 -0
  320. package/dist/server/migrations/0004_session_active_org_not_null.sql +10 -0
  321. package/dist/server/migrations/0005_warm_amazoness.sql +3 -0
  322. package/dist/server/migrations/0006_lumpy_joystick.sql +1 -0
  323. package/dist/server/migrations/0007_outgoing_maginty.sql +2 -0
  324. package/dist/server/migrations/0008_frozen_cutover.sql +3 -0
  325. package/dist/server/migrations/0009_credits_billing.sql +142 -0
  326. package/dist/server/migrations/0010_credit_grant_dedupe.sql +14 -0
  327. package/dist/server/migrations/0011_remove_credit_ledger.sql +23 -0
  328. package/dist/server/migrations/0012_smart_deploys_and_git_rollback.sql +81 -0
  329. package/dist/server/migrations/0013_fix_check_run_rollup_unique.sql +38 -0
  330. package/dist/server/migrations/0014_default_rollback_git.sql +9 -0
  331. package/dist/server/migrations/0015_billing_idempotency_grants.sql +41 -0
  332. package/dist/server/migrations/0016_github_auth_high_findings.sql +30 -0
  333. package/dist/server/migrations/0017_spooky_rumiko_fujikawa.sql +6 -0
  334. package/dist/server/migrations/0018_calm_shatterstar.sql +1 -0
  335. package/dist/server/migrations/0019_rename_workspace_install_to_prepare.sql +1 -0
  336. package/dist/server/migrations/0020_uneven_the_renegades.sql +13 -0
  337. package/dist/server/migrations/0021_shiny_firestar.sql +2 -0
  338. package/dist/server/migrations/0022_version_on_success_backfill.sql +6 -0
  339. package/dist/server/migrations/0023_productive_gorilla_man.sql +17 -0
  340. package/dist/server/migrations/0024_sleepy_peter_quill.sql +2 -0
  341. package/dist/server/migrations/0025_smooth_blue_shield.sql +1 -0
  342. package/dist/server/migrations/0026_mail_backup_source.sql +11 -0
  343. package/dist/server/migrations/0027_opposite_anthem.sql +6 -0
  344. package/dist/server/migrations/0028_stale_rogue.sql +18 -0
  345. package/dist/server/migrations/0029_bouncy_loners.sql +16 -0
  346. package/dist/server/migrations/0030_bright_darkstar.sql +6 -0
  347. package/dist/server/migrations/0031_conscious_catseye.sql +13 -0
  348. package/dist/server/migrations/0032_wet_mimic.sql +51 -0
  349. package/dist/server/migrations/0033_eminent_molly_hayes.sql +2 -0
  350. package/dist/server/migrations/0034_nervous_surge.sql +1 -0
  351. package/dist/server/migrations/0035_thankful_morgan_stark.sql +1 -0
  352. package/dist/server/migrations/0036_gorgeous_scalphunter.sql +17 -0
  353. package/dist/server/migrations/0037_clever_makkari.sql +28 -0
  354. package/dist/server/migrations/0038_young_magneto.sql +1 -0
  355. package/dist/server/migrations/0039_secret_lady_ursula.sql +15 -0
  356. package/dist/server/migrations/0040_sharp_red_wolf.sql +16 -0
  357. package/dist/server/migrations/0041_round_spyke.sql +4 -0
  358. package/dist/server/migrations/0042_dapper_havok.sql +1 -0
  359. package/dist/server/migrations/0043_fast_sharon_ventura.sql +15 -0
  360. package/dist/server/migrations/0044_amused_shape.sql +8 -0
  361. package/dist/server/migrations/0045_amusing_korath.sql +1 -0
  362. package/dist/server/migrations/0046_white_harrier.sql +35 -0
  363. package/dist/server/migrations/0047_sloppy_strong_guy.sql +2 -0
  364. package/dist/server/migrations/0048_funny_emma_frost.sql +2 -0
  365. package/dist/server/migrations/0049_nappy_piledriver.sql +3 -0
  366. package/dist/server/migrations/0050_add_service_deployment_image_digest.sql +1 -0
  367. package/dist/server/migrations/0051_add_update_status.sql +19 -0
  368. package/dist/server/migrations/0052_add_notice_target.sql +2 -0
  369. package/dist/server/migrations/0053_colossal_sandman.sql +14 -0
  370. package/dist/server/migrations/0054_add_instance_smtp.sql +5 -0
  371. package/dist/server/migrations/0055_rainy_klaw.sql +22 -0
  372. package/dist/server/migrations/0056_moaning_daredevil.sql +2 -0
  373. package/dist/server/migrations/0057_complete_prism.sql +18 -0
  374. package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
  375. package/dist/server/migrations/0059_legal_fixer.sql +18 -0
  376. package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
  377. package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
  378. package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
  379. package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
  380. package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
  381. package/dist/server/migrations/0065_custom_app_template.sql +14 -0
  382. package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
  383. package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
  384. package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
  385. package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
  386. package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
  387. package/dist/server/migrations/0071_gh_device_token.sql +3 -0
  388. package/dist/server/migrations/0072_gh_device_token_method.sql +1 -0
  389. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  390. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  391. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  392. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  393. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  394. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  395. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  396. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  397. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  398. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  399. package/dist/server/migrations/0083_service_incident.sql +84 -0
  400. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  401. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  402. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  403. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  404. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  405. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  406. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  407. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  408. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  409. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  410. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  411. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  412. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  413. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  414. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  415. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  416. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  417. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  418. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  419. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  420. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  421. package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
  422. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  423. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  424. package/dist/server/migrations/0108_credential.sql +68 -0
  425. package/dist/server/migrations/0109_service_build_args.sql +9 -0
  426. package/dist/server/migrations/0110_service_deployment_host_ports.sql +18 -0
  427. package/dist/server/migrations/0111_host_port_claim.sql +381 -0
  428. package/dist/server/migrations/0112_backup_run_execution_lease.sql +12 -0
  429. package/dist/server/migrations/0113_docker_migration_execution_lease.sql +11 -0
  430. package/dist/server/migrations/0114_build_session_execution_lease_index.sql +6 -0
  431. package/dist/server/migrations/0115_orphan_target_key.sql +3 -0
  432. package/dist/server/migrations/0116_domain_ssl_challenge.sql +2 -0
  433. package/dist/server/migrations/0117_github_installation_workspace_scope.sql +24 -0
  434. package/dist/server/migrations/0118_git_sources.sql +66 -0
  435. package/dist/server/migrations/0119_backup_run_batch.sql +8 -0
  436. package/dist/server/migrations/0120_domain_primary_unique.sql +24 -0
  437. package/dist/server/migrations/0121_host_port_claim_overlap.sql +14 -0
  438. package/dist/server/migrations/0122_data_transfer_staging.sql +45 -0
  439. package/dist/server/migrations/0123_drop_cloud_infra.sql +7 -0
  440. package/dist/server/migrations/0124_mail_dmarc_report.sql +23 -0
  441. package/dist/server/migrations/0125_server_security_snapshot.sql +22 -0
  442. package/dist/server/migrations/meta/0000_snapshot.json +4848 -0
  443. package/dist/server/migrations/meta/0001_snapshot.json +4991 -0
  444. package/dist/server/migrations/meta/0002_snapshot.json +5013 -0
  445. package/dist/server/migrations/meta/0003_snapshot.json +5020 -0
  446. package/dist/server/migrations/meta/0004_snapshot.json +5020 -0
  447. package/dist/server/migrations/meta/0005_snapshot.json +5039 -0
  448. package/dist/server/migrations/meta/0006_snapshot.json +5045 -0
  449. package/dist/server/migrations/meta/0007_snapshot.json +5057 -0
  450. package/dist/server/migrations/meta/0016_snapshot.json +6053 -0
  451. package/dist/server/migrations/meta/0017_snapshot.json +6091 -0
  452. package/dist/server/migrations/meta/0018_snapshot.json +6098 -0
  453. package/dist/server/migrations/meta/0019_snapshot.json +6098 -0
  454. package/dist/server/migrations/meta/0020_snapshot.json +6201 -0
  455. package/dist/server/migrations/meta/0021_snapshot.json +6213 -0
  456. package/dist/server/migrations/meta/0022_snapshot.json +6213 -0
  457. package/dist/server/migrations/meta/0023_snapshot.json +6331 -0
  458. package/dist/server/migrations/meta/0024_snapshot.json +6343 -0
  459. package/dist/server/migrations/meta/0025_snapshot.json +6350 -0
  460. package/dist/server/migrations/meta/0026_snapshot.json +6440 -0
  461. package/dist/server/migrations/meta/0027_snapshot.json +6447 -0
  462. package/dist/server/migrations/meta/0028_snapshot.json +6581 -0
  463. package/dist/server/migrations/meta/0029_snapshot.json +6710 -0
  464. package/dist/server/migrations/meta/0030_snapshot.json +6748 -0
  465. package/dist/server/migrations/meta/0031_snapshot.json +6859 -0
  466. package/dist/server/migrations/meta/0032_snapshot.json +7222 -0
  467. package/dist/server/migrations/meta/0033_snapshot.json +7249 -0
  468. package/dist/server/migrations/meta/0034_snapshot.json +7255 -0
  469. package/dist/server/migrations/meta/0035_snapshot.json +7262 -0
  470. package/dist/server/migrations/meta/0036_snapshot.json +7392 -0
  471. package/dist/server/migrations/meta/0037_snapshot.json +7602 -0
  472. package/dist/server/migrations/meta/0038_snapshot.json +7609 -0
  473. package/dist/server/migrations/meta/0039_snapshot.json +7714 -0
  474. package/dist/server/migrations/meta/0040_snapshot.json +7820 -0
  475. package/dist/server/migrations/meta/0041_snapshot.json +7833 -0
  476. package/dist/server/migrations/meta/0042_snapshot.json +7839 -0
  477. package/dist/server/migrations/meta/0043_snapshot.json +7938 -0
  478. package/dist/server/migrations/meta/0044_snapshot.json +7982 -0
  479. package/dist/server/migrations/meta/0045_snapshot.json +7989 -0
  480. package/dist/server/migrations/meta/0046_snapshot.json +8263 -0
  481. package/dist/server/migrations/meta/0047_snapshot.json +8275 -0
  482. package/dist/server/migrations/meta/0048_snapshot.json +8289 -0
  483. package/dist/server/migrations/meta/0049_snapshot.json +8308 -0
  484. package/dist/server/migrations/meta/0050_snapshot.json +8314 -0
  485. package/dist/server/migrations/meta/0051_snapshot.json +8460 -0
  486. package/dist/server/migrations/meta/0052_snapshot.json +8472 -0
  487. package/dist/server/migrations/meta/0053_snapshot.json +8556 -0
  488. package/dist/server/migrations/meta/0054_snapshot.json +8586 -0
  489. package/dist/server/migrations/meta/0055_snapshot.json +8756 -0
  490. package/dist/server/migrations/meta/0056_snapshot.json +8777 -0
  491. package/dist/server/migrations/meta/0057_snapshot.json +8904 -0
  492. package/dist/server/migrations/meta/0058_snapshot.json +8911 -0
  493. package/dist/server/migrations/meta/0059_snapshot.json +9061 -0
  494. package/dist/server/migrations/meta/0060_snapshot.json +9081 -0
  495. package/dist/server/migrations/meta/_journal.json +888 -0
  496. package/dist/server/pglite/pglite.data +0 -0
  497. package/dist/server/pglite/pglite.wasm +0 -0
  498. package/dist/setup-JWKZIECM.js +25 -0
  499. package/dist/signin-3M3UPB6K.js +667 -0
  500. package/dist/ssh-client-NWQVRCV7.js +29 -0
  501. package/dist/sso-oidc-3VDIYOEJ.js +834 -0
  502. package/dist/sts-B3WIYLXR.js +1426 -0
  503. package/dist/up-P5DMQ5YO.js +55 -0
  504. package/package.json +58 -0
@@ -0,0 +1,2275 @@
1
+ #!/usr/bin/env sh
2
+ ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
+ import { createRequire as __ospCreateRequire } from "node:module";
4
+ const require = __ospCreateRequire(import.meta.url);
5
+ import {
6
+ edgeDownExplanation,
7
+ explainEdgeDown,
8
+ isEdgeDownFailure
9
+ } from "./chunk-I3DPMTSF.js";
10
+ import {
11
+ edgeFailureReason
12
+ } from "./chunk-YZUKHG3S.js";
13
+ import {
14
+ probeHostedHttp
15
+ } from "./chunk-ZVFS4LUA.js";
16
+ import {
17
+ ACME_CHALLENGE_LOCATION,
18
+ ACME_HTTP01_PORT,
19
+ EDGE_CHALLENGE_DIR,
20
+ EDGE_CHALLENGE_LOCATION,
21
+ EDGE_CHALLENGE_URL_PREFIX,
22
+ EDGE_SAME_PATH_MOUNTS,
23
+ LUA_LOGGER_PATH,
24
+ OPENRESTY_DEFAULT_PATHS,
25
+ RULES_GUARD_PATH,
26
+ buildReloadCommand,
27
+ cloudEdgeRealIpConf,
28
+ detectOpenRestyPaths,
29
+ edgeChallengeVhostConf,
30
+ isCloudFrontedHost,
31
+ luaSourceAvailable,
32
+ reloadBareOpenResty
33
+ } from "./chunk-BQMFIXHI.js";
34
+ import {
35
+ probeListeningPort
36
+ } from "./chunk-WYNM7L22.js";
37
+ import {
38
+ EXECUTOR_DELEGATE
39
+ } from "./chunk-OFXBNFNL.js";
40
+ import {
41
+ logEntry,
42
+ sq
43
+ } from "./chunk-MNV2QFHW.js";
44
+ import {
45
+ PROXY_DIRECTIVES,
46
+ parseProxyValue,
47
+ resolveProxyDirectives,
48
+ resolveRedirectStatus,
49
+ safeErrorMessage,
50
+ sanitizeProxySettings
51
+ } from "./chunk-PVOWHIND.js";
52
+
53
+ // ../../packages/adapters/src/infra/nginx.ts
54
+ import {
55
+ access as fsAccess,
56
+ writeFile as fsWriteFile,
57
+ rm as fsRm,
58
+ mkdir as fsMkdir,
59
+ readFile as fsReadFile,
60
+ readdir as fsReaddir,
61
+ rename as fsRename,
62
+ stat as fsStat
63
+ } from "fs/promises";
64
+ import { execFile as cpExecFile } from "child_process";
65
+ import { createHash, randomBytes } from "crypto";
66
+ import { promisify } from "util";
67
+ import { posix as edgePath } from "path";
68
+
69
+ // ../../packages/adapters/src/system/proxy/cert-material.ts
70
+ import { X509Certificate, createPrivateKey } from "crypto";
71
+
72
+ // ../../packages/adapters/src/system/edge-container-executor.ts
73
+ function containerCommand(container, command) {
74
+ return `docker exec ${sq(container)} sh -c ${sq(command)}`;
75
+ }
76
+ async function diagnoseEdgeDown(inner, container) {
77
+ const status = await inner.exec(`docker inspect -f '{{.State.Status}}' ${sq(container)} 2>/dev/null`).catch(() => null);
78
+ const logs = await inner.exec(`docker logs --tail 40 ${sq(container)} 2>&1`).catch(() => null);
79
+ return explainEdgeDown({
80
+ container,
81
+ status: status?.trim() || null,
82
+ reason: logs ? edgeFailureReason(logs) : null
83
+ });
84
+ }
85
+ function resolveRef(ref) {
86
+ return typeof ref === "function" ? ref() : ref;
87
+ }
88
+ async function readMaybeInContainer(exec, path, container) {
89
+ const direct = await exec.readFile(path).catch(() => "");
90
+ if (direct.trim()) return direct;
91
+ const name = await resolveRef(container);
92
+ if (!name) return "";
93
+ return exec.exec(containerCommand(name, `cat ${sq(path)}`)).catch(() => "");
94
+ }
95
+ async function writeEdgeFileIn(inner, container, path, content, opts) {
96
+ if (opts === void 0) await inner.writeFile(path, content);
97
+ else await inner.writeFile(path, content, opts);
98
+ if (!container) return;
99
+ const visible = await inner.exec(containerCommand(container, `test -e ${sq(path)} && echo visible`)).catch(() => "");
100
+ if (visible.trim() === "visible") return;
101
+ const dir = path.replace(/\/[^/]*$/, "");
102
+ if (dir && dir !== path) {
103
+ await inner.exec(containerCommand(container, `mkdir -p ${sq(dir)}`)).catch(() => {
104
+ });
105
+ }
106
+ await inner.exec(`docker cp ${sq(path)} ${sq(`${container}:${path}`)}`).catch(() => {
107
+ });
108
+ }
109
+ function edgeContainerExecutor(inner, container, opts) {
110
+ const inContainer = (command) => containerCommand(container, command);
111
+ const overrides = {
112
+ // Both command paths enrich a "the container isn't up" failure with the
113
+ // container's name, state and `[emerg]` — see `diagnoseEdgeDown`. Gated on the
114
+ // narrow daemon/nginx signatures (`isEdgeDownFailure`), because a non-zero exit
115
+ // is ordinary here: the container-file probes report a missing path that way, and
116
+ // paying two `docker` round-trips per miss would be a real cost on every read.
117
+ exec: async (command, execOpts) => {
118
+ try {
119
+ return await inner.exec(inContainer(command), execOpts);
120
+ } catch (err) {
121
+ const message = err?.message ?? String(err);
122
+ if (!isEdgeDownFailure(message)) throw err;
123
+ throw new Error(
124
+ `${await diagnoseEdgeDown(inner, container)}
125
+ ${message}`
126
+ );
127
+ }
128
+ },
129
+ // streamExec REPORTS failure instead of throwing it, and its callers build the
130
+ // error they raise out of what was streamed (`_execCertbot` accumulates the log
131
+ // lines and throws that text). So the explanation has to go through `onLog` to
132
+ // reach them — appending it only to the return value would leave certbot's
133
+ // caller raising the daemon's bare container id, which is the original bug.
134
+ streamExec: async (command, onLog, opts2) => {
135
+ const result = await inner.streamExec(inContainer(command), onLog, opts2);
136
+ if (result.code === 0 || !isEdgeDownFailure(result.output)) return result;
137
+ const explanation = await diagnoseEdgeDown(inner, container);
138
+ onLog(logEntry(explanation, "error"));
139
+ return { code: result.code, output: `${explanation}
140
+ ${result.output}` };
141
+ },
142
+ // A rename is a FILE op, so it follows the files, never the commands. Stated
143
+ // here rather than left to the Proxy's passthrough: if the inner executor has no
144
+ // `rename`, the fallback must still be the INNER shell (the host) — routing it
145
+ // through this decorator's `exec` would put a `mv` inside the container, where
146
+ // the host path it was handed does not exist.
147
+ rename: async (from, to) => {
148
+ if (inner.rename) return inner.rename(from, to);
149
+ await inner.exec(`mv ${sq(from)} ${sq(to)}`);
150
+ }
151
+ };
152
+ if (opts?.files === "auto") {
153
+ Object.assign(overrides, {
154
+ readFile: (path) => readMaybeInContainer(inner, path, container),
155
+ writeFile: (path, content, writeOpts) => writeEdgeFileIn(inner, container, path, content, writeOpts),
156
+ exists: async (path) => {
157
+ if (await inner.exists(path).catch(() => false)) return true;
158
+ const { code } = await inner.streamExec(
159
+ inContainer(`test -e ${sq(path)}`),
160
+ () => {
161
+ }
162
+ );
163
+ return code === 0;
164
+ },
165
+ // The rename half of an atomic write must land in the same namespace as the
166
+ // write itself, so it follows readFile/writeFile — host first, container only
167
+ // when the host can't see the file.
168
+ rename: async (from, to) => {
169
+ if (await inner.exists(from).catch(() => false)) {
170
+ if (inner.rename) return inner.rename(from, to);
171
+ await inner.exec(`mv ${sq(from)} ${sq(to)}`);
172
+ return;
173
+ }
174
+ await inner.exec(inContainer(`mv ${sq(from)} ${sq(to)}`));
175
+ },
176
+ // mkdir/rm are idempotent and rare (route register/deregister), so both homes
177
+ // get them unconditionally rather than paying a probe to decide.
178
+ mkdir: async (path) => {
179
+ await inner.mkdir(path);
180
+ await inner.exec(inContainer(`mkdir -p ${sq(path)}`)).catch(() => {
181
+ });
182
+ },
183
+ rm: async (path) => {
184
+ await inner.rm(path);
185
+ await inner.exec(inContainer(`rm -rf ${sq(path)}`)).catch(() => {
186
+ });
187
+ }
188
+ });
189
+ }
190
+ return new Proxy(inner, {
191
+ get(target, prop) {
192
+ if (prop === EXECUTOR_DELEGATE) return void 0;
193
+ if (Object.prototype.hasOwnProperty.call(overrides, prop)) {
194
+ return overrides[prop];
195
+ }
196
+ const value = Reflect.get(target, prop, target);
197
+ return typeof value === "function" ? value.bind(target) : value;
198
+ }
199
+ });
200
+ }
201
+
202
+ // ../../packages/adapters/src/system/proxy/cert-material.ts
203
+ var ACME_ISSUERS = [
204
+ "let's encrypt",
205
+ "lets encrypt",
206
+ "letsencrypt",
207
+ "zerossl",
208
+ "google trust services",
209
+ "buypass",
210
+ "actalis",
211
+ "ssl.com"
212
+ ];
213
+ function issuerLabel(x509) {
214
+ const fields = /* @__PURE__ */ new Map();
215
+ for (const line of x509.issuer.split("\n")) {
216
+ const eq = line.indexOf("=");
217
+ if (eq > 0)
218
+ fields.set(
219
+ line.slice(0, eq).trim().toUpperCase(),
220
+ line.slice(eq + 1).trim()
221
+ );
222
+ }
223
+ return fields.get("CN") ?? fields.get("O") ?? x509.issuer.replace(/\n/g, ", ");
224
+ }
225
+ function isAcmeIssuer(x509) {
226
+ const haystack = `${x509.issuer}`.toLowerCase();
227
+ return ACME_ISSUERS.some((ca) => haystack.includes(ca));
228
+ }
229
+ function certNames(x509) {
230
+ const names = [];
231
+ for (const entry of (x509.subjectAltName ?? "").split(",")) {
232
+ const [kind, ...rest] = entry.trim().split(":");
233
+ if (kind?.toUpperCase() === "DNS" && rest.length)
234
+ names.push(rest.join(":").trim().toLowerCase());
235
+ }
236
+ if (names.length === 0) {
237
+ const cn = x509.subject.split("\n").find((l) => l.trim().toUpperCase().startsWith("CN="));
238
+ if (cn)
239
+ names.push(
240
+ cn.slice(cn.indexOf("=") + 1).trim().toLowerCase()
241
+ );
242
+ }
243
+ return names;
244
+ }
245
+ function coversHost(x509, host) {
246
+ const target = host.toLowerCase();
247
+ if (x509.checkHost(target)) return true;
248
+ return certNames(x509).some((name) => {
249
+ if (name === target) return true;
250
+ if (!name.startsWith("*.")) return false;
251
+ const suffix = name.slice(1);
252
+ return target.endsWith(suffix) && !target.slice(0, -suffix.length).includes(".");
253
+ });
254
+ }
255
+ function validateCertFor(host, pems, source) {
256
+ const certPem = pems.certPem ?? "";
257
+ const keyPem = pems.keyPem ?? "";
258
+ if (!certPem.trim() || !keyPem.trim()) {
259
+ return { cert: null, reason: `${source}: cert or key is empty` };
260
+ }
261
+ let x509;
262
+ try {
263
+ x509 = new X509Certificate(certPem);
264
+ } catch (err) {
265
+ return {
266
+ cert: null,
267
+ reason: `${source}: unreadable certificate (${safeErrorMessage(err)})`
268
+ };
269
+ }
270
+ try {
271
+ if (!x509.checkPrivateKey(createPrivateKey(keyPem))) {
272
+ return {
273
+ cert: null,
274
+ reason: `${source}: private key does not match the certificate`
275
+ };
276
+ }
277
+ } catch (err) {
278
+ return {
279
+ cert: null,
280
+ reason: `${source}: unreadable private key (${safeErrorMessage(err)})`
281
+ };
282
+ }
283
+ if (!coversHost(x509, host)) {
284
+ return {
285
+ cert: null,
286
+ reason: `${source}: certificate covers ${certNames(x509).join(", ") || "no DNS names"} \u2014 not ${host}`
287
+ };
288
+ }
289
+ const notAfter = new Date(x509.validTo);
290
+ if (!Number.isFinite(notAfter.getTime())) {
291
+ return {
292
+ cert: null,
293
+ reason: `${source}: certificate has an unreadable expiry`
294
+ };
295
+ }
296
+ if (notAfter.getTime() <= Date.now()) {
297
+ return {
298
+ cert: null,
299
+ reason: `${source}: certificate expired ${notAfter.toISOString()}`
300
+ };
301
+ }
302
+ return {
303
+ cert: {
304
+ certPem,
305
+ keyPem,
306
+ source,
307
+ expiresAt: notAfter.toISOString(),
308
+ issuer: issuerLabel(x509),
309
+ covers: certNames(x509),
310
+ renewable: isAcmeIssuer(x509)
311
+ }
312
+ };
313
+ }
314
+ function isSafeCertPath(p) {
315
+ return /^\/[A-Za-z0-9._/-]+$/.test(p) && !p.includes("..");
316
+ }
317
+ var BOOTSTRAP_CERT_SEGMENT = "kraft-bootstrap";
318
+ function isBootstrapCertPath(p) {
319
+ return p.split("/").includes(BOOTSTRAP_CERT_SEGMENT);
320
+ }
321
+ async function readDeclaredPair(exec, certPath, keyPath, container) {
322
+ if (!isSafeCertPath(certPath) || !isSafeCertPath(keyPath)) return null;
323
+ if (isBootstrapCertPath(certPath) || isBootstrapCertPath(keyPath))
324
+ return null;
325
+ const certPem = await readMaybeInContainer(exec, certPath, container);
326
+ const keyPem = await readMaybeInContainer(exec, keyPath, container);
327
+ return certPem && keyPem ? { certPem, keyPem } : null;
328
+ }
329
+
330
+ // ../../packages/adapters/src/system/output-exists.ts
331
+ var SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
332
+ var SAFE_PATH = /^\/[A-Za-z0-9._~/-]*$/;
333
+ function safeRequestPath(path) {
334
+ return SAFE_PATH.test(path) && !path.split("/").includes("..");
335
+ }
336
+ function buildCommand(servedPath, opts) {
337
+ const p = servedPath.replace(/'/g, "'\\''");
338
+ const parts = [
339
+ `if [ -e '${p}' ]; then echo FOUND; fi`,
340
+ `if [ -f '${p}' ] || [ -f '${p}/index.html' ]; then echo INDEX; fi`
341
+ ];
342
+ const host = opts?.hostname;
343
+ const path = opts?.requestPath ?? "/";
344
+ if (host && SAFE_HOST.test(host) && safeRequestPath(path)) {
345
+ const port = opts?.edgePort ?? 80;
346
+ const maxTime = opts?.timeoutSeconds ?? 3;
347
+ parts.push(
348
+ `if command -v curl >/dev/null 2>&1; then echo "HTTP $(curl -sS -o /dev/null -w '%{http_code} %{num_connects}' --max-time ${maxTime} -H 'Host: ${host}' -H 'X-Forwarded-Proto: https' 'http://127.0.0.1:${port}${path}' 2>/dev/null)"; fi`
349
+ );
350
+ }
351
+ parts.push("true");
352
+ return parts.join("; ");
353
+ }
354
+ function statusIsServed(status) {
355
+ if (status >= 200 && status < 400) return true;
356
+ return status === 401 || status === 403;
357
+ }
358
+ function parseHttp(out) {
359
+ const match = /(^|\n)HTTP (\d+) (\d+)(\r?\n|$)/.exec(out);
360
+ if (!match) return {};
361
+ const status = Number(match[2]);
362
+ const connects = Number(match[3]);
363
+ if (!Number.isFinite(status) || status === 0 || connects === 0) return {};
364
+ return { status, served: statusIsServed(status) };
365
+ }
366
+ async function probeStaticOutput(executor, servedPath, opts) {
367
+ try {
368
+ const out = await executor.exec(buildCommand(servedPath, opts), {
369
+ timeout: 8e3
370
+ });
371
+ return {
372
+ found: /(^|\n)FOUND(\r?\n|$)/.test(out),
373
+ hasIndex: /(^|\n)INDEX(\r?\n|$)/.test(out),
374
+ checked: true,
375
+ ...parseHttp(out)
376
+ };
377
+ } catch {
378
+ return { found: false, hasIndex: false, checked: false };
379
+ }
380
+ }
381
+
382
+ // ../../packages/adapters/src/infra/nginx.ts
383
+ var { dirname, join } = edgePath;
384
+ var CTL = "\\x00-\\x1f\\x7f";
385
+ var PROXY_HEADERS = `proxy_set_header Host $host;
386
+ proxy_set_header X-Real-IP $remote_addr;
387
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
388
+ proxy_set_header X-Forwarded-Proto $kraft_fwd_proto;
389
+ proxy_http_version 1.1;
390
+ proxy_set_header Upgrade $http_upgrade;
391
+ proxy_set_header Connection "upgrade";
392
+ proxy_pass_header X-Accel-Buffering;`;
393
+ var FORWARD_VARS = ` set $kraft_fwd_proto $scheme;
394
+ set $kraft_redirect_https 1;
395
+ if ($http_x_forwarded_proto = "https") {
396
+ set $kraft_fwd_proto https;
397
+ set $kraft_redirect_https 0;
398
+ }`;
399
+ var VHOST_GENERATION = 2;
400
+ var GENERATION_MARKER = `# kraft-vhost-gen: ${VHOST_GENERATION}`;
401
+ var GENERATED_HEADER = `# Auto-generated by Kraft - do not edit manually
402
+ ${GENERATION_MARKER}`;
403
+ function readVhostGeneration(conf) {
404
+ const m = conf.match(/^# kraft-vhost-gen: (\d+)$/m);
405
+ return m ? Number(m[1]) : null;
406
+ }
407
+ var HTTPS_UPGRADE = `if ($kraft_redirect_https) {
408
+ return 301 https://$server_name$request_uri;
409
+ }
410
+
411
+ `;
412
+ var WEBHOOK_LOCATION_PREFIX = "/_kraft/hooks/";
413
+ function renderProxyLocations(route, opts) {
414
+ if (!route.proxyLocations || route.proxyLocations.length === 0) return "";
415
+ const head = `${opts.httpsUpgrade ? HTTPS_UPGRADE : ""}${renderRedirectRules(route, " ")}`;
416
+ const claimed = /* @__PURE__ */ new Set([
417
+ "prefix:/",
418
+ `prefix:${WEBHOOK_LOCATION_PREFIX}`,
419
+ // An exact webhook match would not be a duplicate, but it would outrank and
420
+ // hijack the edge-owned delivery endpoint just as surely as a prefix would.
421
+ `exact:${WEBHOOK_LOCATION_PREFIX}`
422
+ ]);
423
+ const blocks = [];
424
+ for (const loc of route.proxyLocations) {
425
+ assertValidUpstream(loc.targetUrl);
426
+ assertNotReservedLocation(loc.pathPrefix);
427
+ const headers = loc.external ? externalProxyHeaders(loc.targetUrl) : PROXY_HEADERS;
428
+ if (!loc.pattern || !loc.upstreamPath) {
429
+ assertValidLiteralPath(loc.pathPrefix, "proxy location prefix");
430
+ const kind = loc.exact ? "exact" : "prefix";
431
+ const key = `${kind}:${loc.pathPrefix}`;
432
+ if (claimed.has(key)) continue;
433
+ claimed.add(key);
434
+ blocks.push(`
435
+ location ${loc.exact ? "=" : "^~"} ${loc.pathPrefix} {
436
+ ${head}proxy_pass ${loc.targetUrl};
437
+ ${headers}
438
+ }`);
439
+ continue;
440
+ }
441
+ assertValidRedirectPattern(loc.pattern);
442
+ assertNotReservedLocation(loc.pattern);
443
+ assertNoNginxInjection(loc.upstreamPath, "rewrite upstream path");
444
+ assertNoRuntimeVariable(loc.upstreamPath, "rewrite upstream path", true);
445
+ const match = `^${toNginxPattern(loc.pattern)}$`;
446
+ if (claimed.has(`regex:${match}`)) continue;
447
+ claimed.add(`regex:${match}`);
448
+ blocks.push(`
449
+ location ~ ${match} {
450
+ ${head}rewrite ${match} ${loc.upstreamPath} break;
451
+ proxy_pass ${loc.targetUrl};
452
+ ${headers}
453
+ }`);
454
+ }
455
+ return blocks.join("");
456
+ }
457
+ function assertNotReservedLocation(pathOrPattern) {
458
+ if (pathOrPattern.startsWith(WELL_KNOWN_PREFIX)) {
459
+ throw new Error(
460
+ `Refusing to generate a location under ${WELL_KNOWN_PREFIX}: ${pathOrPattern}. That namespace belongs to the edge \u2014 a location there can hijack certificate issuance.`
461
+ );
462
+ }
463
+ }
464
+ var WELL_KNOWN_PREFIX = "/.well-known/";
465
+ function externalProxyHeaders(targetUrl) {
466
+ const host = new URL(targetUrl).host;
467
+ assertNoNginxInjection(host, "external proxy host");
468
+ return `${PROXY_HEADERS.replace("Host $host;", `Host ${host};`)}
469
+ proxy_ssl_server_name on;`;
470
+ }
471
+ function renderHostRedirect(route) {
472
+ const redirect = route.redirectHost;
473
+ if (!redirect?.target) return null;
474
+ assertValidDomain(redirect.target);
475
+ return `return ${resolveRedirectStatus(redirect.statusCode)} https://${redirect.target}$request_uri;`;
476
+ }
477
+ function renderRedirectRules(route, indent) {
478
+ if (!route.redirects || route.redirects.length === 0) return "";
479
+ const rules = route.redirects.map((r) => {
480
+ assertValidRedirect(r);
481
+ const match = r.pattern ? `^${toNginxPattern(r.pattern, "?")}(?:[?].*)?$` : r.exact ? `^${escapeLiteralPath(r.path)}(?:[?].*)?$` : `^${escapeLiteralPath(r.path)}`;
482
+ const query = r.destination.includes("?") ? "" : "$is_args$args";
483
+ return `${indent}if ($request_uri ~ "${match}") {
484
+ ${indent} return ${r.statusCode} ${r.destination}${query};
485
+ ${indent}}`;
486
+ });
487
+ return `${rules.join("\n")}
488
+
489
+ ${indent}`;
490
+ }
491
+ function assertValidHeader(key, value) {
492
+ if (!/^[A-Za-z0-9-]+$/.test(key))
493
+ throw new Error(`Invalid response header name: ${key}`);
494
+ if (/[\r\n]/.test(value))
495
+ throw new Error(`Invalid response header value for ${key}`);
496
+ assertNoRuntimeVariable(value, `response header value for ${key}`);
497
+ }
498
+ function addHeaderLine(key, value) {
499
+ assertValidHeader(key, value);
500
+ return ` add_header ${key} "${value.replace(/"/g, '\\"')}" always;`;
501
+ }
502
+ function renderServerHeaders(route, slug) {
503
+ const lines = headerEntries(route, slug).map(
504
+ (e) => e.variable === null ? addHeaderLine(e.header.key, e.header.value) : ` add_header ${e.header.key} $${e.variable} always;`
505
+ );
506
+ return lines.length > 0 ? `
507
+ ${lines.join("\n")}` : "";
508
+ }
509
+ function headerEntries(route, slug) {
510
+ const prefix = `osh_hdr_${slug.replace(/[^A-Za-z0-9]+/g, "_")}`;
511
+ let n = 0;
512
+ return (route.headerRules ?? []).flatMap(
513
+ (rule) => rule.headers.map((header) => ({
514
+ rule,
515
+ header,
516
+ variable: rule.path === "/" ? null : `${prefix}_${n++}`
517
+ }))
518
+ );
519
+ }
520
+ function renderHeaderMaps(route, slug) {
521
+ const scoped = headerEntries(route, slug).filter((e) => e.variable !== null);
522
+ if (scoped.length === 0) return "";
523
+ const blocks = scoped.map((e) => {
524
+ assertValidLiteralPath(e.rule.path, "header rule path");
525
+ assertValidHeader(e.header.key, e.header.value);
526
+ return `map $request_uri $${e.variable} {
527
+ default "";
528
+ ~^${escapeLiteralPath(e.rule.path)} "${e.header.value.replace(/"/g, '\\"')}";
529
+ }`;
530
+ });
531
+ return `${blocks.join("\n")}
532
+ `;
533
+ }
534
+ function renderUrlShape(route) {
535
+ if (!("staticRoot" in route) || !route.staticRoot) return "";
536
+ const rules = [];
537
+ if (route.cleanUrls) {
538
+ rules.push(` if ($request_uri ~ "^([^?]*)[.]html([?].*)?$") {
539
+ return 308 $1$2;
540
+ }`);
541
+ }
542
+ if (route.trailingSlash === false) {
543
+ rules.push(` if ($request_uri ~ "^([^?]+)/([?].*)?$") {
544
+ return 308 $1$2;
545
+ }`);
546
+ }
547
+ return rules.length > 0 ? `${rules.join("\n").trimStart()}
548
+
549
+ ` : "";
550
+ }
551
+ var SLASH_FALLBACK_NAME = "@osh_add_slash";
552
+ function renderSlashFallback(staticRoot) {
553
+ return ` location ${SLASH_FALLBACK_NAME} {
554
+ root ${staticRoot};
555
+
556
+ if ($request_uri ~ "^([^?]*[^/?])([?].*)?$") {
557
+ return 308 $1/$2;
558
+ }
559
+
560
+ try_files /index.html =404;
561
+ }`;
562
+ }
563
+ function renderStaticTryFiles(route) {
564
+ const parts = ["$uri"];
565
+ if (route.cleanUrls) parts.push("$uri.html");
566
+ if (route.trailingSlash === false) parts.push("$uri/index.html");
567
+ else parts.push("$uri/");
568
+ parts.push(
569
+ route.trailingSlash === true ? SLASH_FALLBACK_NAME : "/index.html"
570
+ );
571
+ return `try_files ${parts.join(" ")};`;
572
+ }
573
+ function renderProxyDirectives(proxy, opts) {
574
+ const lines = resolveProxyDirectives(proxy, {
575
+ scope: opts.tlsOnly ? "tls" : "shared",
576
+ ...opts.nginxVersion !== void 0 ? { nginxVersion: opts.nginxVersion } : {}
577
+ }).map((d) => ` ${d.directive} ${d.text};`);
578
+ return lines.length > 0 ? `
579
+ ${lines.join("\n")}` : "";
580
+ }
581
+ function renderProxyOptions(proxy, nginxVersion) {
582
+ return renderProxyDirectives(proxy, { tlsOnly: false, nginxVersion });
583
+ }
584
+ function renderProxyTlsOptions(proxy, nginxVersion) {
585
+ return renderProxyDirectives(proxy, { tlsOnly: true, nginxVersion });
586
+ }
587
+ function renderGeoEntries(whitelist) {
588
+ const entries = [
589
+ " default 0;",
590
+ " 127.0.0.1/32 1;",
591
+ " ::1/128 1;"
592
+ ];
593
+ for (const cidr of whitelist) {
594
+ if (/^[\da-fA-F.:\/]+$/.test(cidr) && cidr.length <= 50) {
595
+ entries.push(` ${cidr.padEnd(15)} 1;`);
596
+ }
597
+ }
598
+ return entries;
599
+ }
600
+ var DEFAULT_CERT_DIR = "/etc/letsencrypt/live";
601
+ var LETSENCRYPT_PRODUCTION_DIRECTORY = "https://acme-v02.api.letsencrypt.org/directory";
602
+ function assertValidAcmeOptions(opts) {
603
+ if (!!opts.acmeEabKid !== !!opts.acmeEabHmacKey) {
604
+ throw new Error("ACME EAB requires both a key identifier and an HMAC key");
605
+ }
606
+ if (opts.acmeDirectoryUrl) {
607
+ let directory;
608
+ try {
609
+ directory = new URL(opts.acmeDirectoryUrl);
610
+ } catch {
611
+ throw new Error("ACME directory must be a valid http(s) URL");
612
+ }
613
+ if (directory.protocol !== "https:" && directory.protocol !== "http:") {
614
+ throw new Error("ACME directory must use http or https");
615
+ }
616
+ }
617
+ if (opts.acmeEabKid && (!/^[\x20-\x7E]+$/.test(opts.acmeEabKid) || opts.acmeEabKid.length > 512)) {
618
+ throw new Error(
619
+ "ACME EAB key identifier must be printable ASCII (maximum 512 characters)"
620
+ );
621
+ }
622
+ if (opts.acmeEabHmacKey && !/^[A-Za-z0-9_-]+={0,2}$/.test(opts.acmeEabHmacKey)) {
623
+ throw new Error("ACME EAB HMAC key must be base64url encoded");
624
+ }
625
+ if (opts.acmeCaBundle && !opts.acmeCaBundle.startsWith("/")) {
626
+ throw new Error(
627
+ "ACME CA bundle must be an absolute path in the Certbot environment"
628
+ );
629
+ }
630
+ }
631
+ function acmeKeyArgs(keyType) {
632
+ switch (keyType) {
633
+ case "ec256":
634
+ return ["--key-type", "ecdsa", "--elliptic-curve", "secp256r1"];
635
+ case "ec384":
636
+ return ["--key-type", "ecdsa", "--elliptic-curve", "secp384r1"];
637
+ case "rsa2048":
638
+ return ["--key-type", "rsa", "--rsa-key-size", "2048"];
639
+ case "rsa4096":
640
+ return ["--key-type", "rsa", "--rsa-key-size", "4096"];
641
+ default:
642
+ return [];
643
+ }
644
+ }
645
+ var DOMAIN_RE = /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/;
646
+ function assertValidDomain(domain) {
647
+ if (!DOMAIN_RE.test(domain) || domain.length > 253) {
648
+ throw new Error(`Invalid domain: ${domain}`);
649
+ }
650
+ }
651
+ function assertNoNginxInjection(value, what) {
652
+ if (/[\s;{}#\\'"]/.test(value)) {
653
+ throw new Error(
654
+ `Invalid ${what} (contains characters that could inject nginx config): ${value}`
655
+ );
656
+ }
657
+ }
658
+ function assertNoRuntimeVariable(value, what, allowCaptures = false) {
659
+ const residue = allowCaptures ? value.replace(/\$[1-9]/g, "") : value;
660
+ if (residue.includes("$")) {
661
+ throw new Error(
662
+ `Invalid ${what} (contains an nginx variable reference): ${value}`
663
+ );
664
+ }
665
+ }
666
+ function assertValidUpstream(targetUrl) {
667
+ assertNoNginxInjection(targetUrl, "proxy target");
668
+ assertNoRuntimeVariable(targetUrl, "proxy target");
669
+ if (!/^https?:\/\/.+/.test(targetUrl)) {
670
+ throw new Error(
671
+ `Invalid proxy target (must be http/https URL): ${targetUrl}`
672
+ );
673
+ }
674
+ }
675
+ var REDIRECT_PATTERN_RE = /^\/(?:[A-Za-z0-9_~\-/]|\\\.|\(\.\*\)|\(\.\+\)|\(\[\^\/\][*+]\))*$/;
676
+ function assertValidRedirectPattern(pattern) {
677
+ if (!REDIRECT_PATTERN_RE.test(pattern)) {
678
+ throw new Error(`Invalid capture pattern: ${pattern}`);
679
+ }
680
+ }
681
+ var REGEX_METACHARS = /[*+?^$()[\]|]/;
682
+ function assertValidLiteralPath(value, what) {
683
+ assertNoNginxInjection(value, what);
684
+ if (!value.startsWith("/") || REGEX_METACHARS.test(value)) {
685
+ throw new Error(`Invalid ${what} (not a plain path): ${value}`);
686
+ }
687
+ }
688
+ function escapeLiteralPath(value) {
689
+ return value.replace(/\./g, "[.]");
690
+ }
691
+ function assertValidRedirect(redirect) {
692
+ if (redirect.pattern !== void 0)
693
+ assertValidRedirectPattern(redirect.pattern);
694
+ assertValidLiteralPath(redirect.path, "redirect path");
695
+ assertNoNginxInjection(redirect.destination, "redirect destination");
696
+ assertNoRuntimeVariable(redirect.destination, "redirect destination", true);
697
+ }
698
+ var NGINX_CAPTURE = {
699
+ "(.*)": `([^${CTL}]*)`,
700
+ "(.+)": `([^${CTL}]+)`,
701
+ "([^/]+)": `([^/${CTL}]+)`,
702
+ "([^/]*)": `([^/${CTL}]*)`
703
+ };
704
+ function toNginxPattern(pattern, alsoExclude = "") {
705
+ return pattern.replace(/\(\.[*+]\)|\(\[\^\/\][*+]\)/g, (group) => {
706
+ const hardened = NGINX_CAPTURE[group];
707
+ if (!hardened) return group;
708
+ return alsoExclude ? hardened.replace(`${CTL}]`, `${CTL}${alsoExclude}]`) : hardened;
709
+ });
710
+ }
711
+ var CHALLENGE_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._~-]{7,127}$/;
712
+ function assertValidChallengeToken(token) {
713
+ if (!CHALLENGE_TOKEN_RE.test(token) || token.includes("..")) {
714
+ throw new Error(`Invalid edge-target challenge token: ${token}`);
715
+ }
716
+ }
717
+ var MANAGED_STATIC_BASE = "/opt/kraft";
718
+ function assertValidStaticRoot(root, opts) {
719
+ assertNoNginxInjection(root, "static root");
720
+ if (!root.startsWith("/") || root.includes("..")) {
721
+ throw new Error(
722
+ `Invalid static root (must be an absolute path, no traversal): ${root}`
723
+ );
724
+ }
725
+ if (opts?.adopted) return;
726
+ const confined = root === MANAGED_STATIC_BASE || root.startsWith(`${MANAGED_STATIC_BASE}/`);
727
+ if (!confined) {
728
+ throw new Error(
729
+ `Refusing to serve static root outside ${MANAGED_STATIC_BASE}: ${root}. Kraft-managed routes serve only their own build output; pass staticRootAdopted when importing a root an existing proxy already serves.`
730
+ );
731
+ }
732
+ }
733
+ var execFileAsync = promisify(cpExecFile);
734
+ function isAcmePortBindFailure(output) {
735
+ return /could not bind (?:tcp )?port|address already in use|problem binding to port/i.test(
736
+ output || ""
737
+ );
738
+ }
739
+ function summarizeCertbotFailure(output, domain) {
740
+ const edgeDown = edgeDownExplanation(output);
741
+ if (edgeDown) return edgeDown;
742
+ const structured = certbotDiagnosis(output, domain);
743
+ if (structured) return structured;
744
+ const lines = (output || "").split("\n").map((l) => l.trim()).filter(Boolean);
745
+ return lines.slice(-3).join(" \xB7 ") || `certbot failed to issue a certificate for ${domain}`;
746
+ }
747
+ function certbotDiagnosis(output, domain) {
748
+ const text = output || "";
749
+ const pick = (re) => text.match(re)?.[0]?.replace(/\s+/g, " ").trim();
750
+ const detail = pick(/Detail:\s*[^\n]+/i);
751
+ const hint = pick(/Hint:\s*[^\n]+/i);
752
+ const challengeUrl = pick(/https?:\/\/\S*\.well-known\/acme-challenge\/\S*/i);
753
+ const redirectedToHttps = /^https:/i.test(challengeUrl ?? "");
754
+ let diagnosis;
755
+ if (isAcmePortBindFailure(text)) {
756
+ diagnosis = `Couldn't start the certificate challenge listener for ${domain} on port ${ACME_HTTP01_PORT}: something on this server is already holding it. That port is used only while a certificate is being issued, so this is almost always a certificate run from an earlier attempt that hasn't exited \u2014 retry, and if it persists find the holder with \`ss -ltnp | grep ${ACME_HTTP01_PORT}\` and stop it. Nothing was requested from Let's Encrypt, so this is not a DNS or routing problem.`;
757
+ } else if (/timeout during connect|connection refused|failed to connect|Timeout/i.test(
758
+ text
759
+ )) {
760
+ diagnosis = `Port 80 for ${domain} isn't reachable from the internet \u2014 a firewall / cloud security group is blocking it, the domain doesn't point at this server, or another proxy is still bound to :80.`;
761
+ } else if (/NXDOMAIN|no\s+(A|AAAA)\s+record|DNS problem|could not be resolved|no records? found/i.test(
762
+ text
763
+ )) {
764
+ diagnosis = `${domain} doesn't resolve to this server yet \u2014 the DNS A record is missing or hasn't propagated. Wait for propagation, then retry from the Domains tab.`;
765
+ } else if (/\b52[56]\b|ssl handshake failed/i.test(text)) {
766
+ diagnosis = `Cloudflare's proxy is answering the certificate challenge for ${domain} instead of forwarding it to this server: it redirects the request to HTTPS, then rejects the origin's TLS. Fix it one of three ways \u2014 set SSL/TLS \u2192 Overview to "Full" (not "Full (strict)") until the first certificate is issued, or turn the orange cloud off for this record until it is, or upload a Cloudflare Origin CA certificate from the Domains tab and skip Let's Encrypt entirely.`;
767
+ } else if (/404|invalid response|unauthorized|"?status"?:?\s*40\d/i.test(text)) {
768
+ diagnosis = redirectedToHttps ? `The challenge for ${domain} was redirected from HTTP to HTTPS and then answered with a 404 \u2014 so whatever served it isn't routing that hostname. If ${domain} is behind Cloudflare, its "Always Use HTTPS" is doing the redirect: set SSL/TLS \u2192 Overview to "Full" (not "Full (strict)") until the first certificate is issued, or turn the orange cloud off for this record. Otherwise check the A record points at this server and the domain is registered here. Nothing is occupying :80, so take-over / migrate will not help.` : `Port 80 answered but not with our ACME challenge \u2014 another web server is still serving :80, so the takeover didn't complete. Re-run and choose take-over / migrate.`;
769
+ } else if (/too many certificates|rateLimited|rate limit/i.test(text)) {
770
+ diagnosis = `Let's Encrypt rate limit reached for ${domain} \u2014 wait before retrying.`;
771
+ }
772
+ const parts = [diagnosis, detail, hint].filter(Boolean);
773
+ return parts.length > 0 ? parts.join(" \u2014 ") : void 0;
774
+ }
775
+ var NginxProvider = class {
776
+ sitesDir;
777
+ acmeEmail;
778
+ acmeDirectoryUrl;
779
+ acmeEabKid;
780
+ acmeEabHmacKey;
781
+ acmeKeyType;
782
+ acmeCaBundle;
783
+ acmeTosAgreed;
784
+ certDir;
785
+ executor;
786
+ paths;
787
+ reloadCommand;
788
+ pinPaths;
789
+ containerEdge;
790
+ challengeDir;
791
+ /**
792
+ * nginx version of the edge, when `detectOpenRestyPaths` could read it. Gates the
793
+ * one directive whose syntax is version-dependent (`http2`); undefined = unknown,
794
+ * which `proxyDirectiveAllowed` treats as allow.
795
+ */
796
+ nginxVersion;
797
+ constructor(opts) {
798
+ assertValidAcmeOptions(opts);
799
+ this.sitesDir = opts.paths.sitesDir;
800
+ this.nginxVersion = opts.paths.nginxVersion;
801
+ this.acmeEmail = opts.acmeEmail;
802
+ this.acmeDirectoryUrl = opts.acmeDirectoryUrl;
803
+ this.acmeEabKid = opts.acmeEabKid;
804
+ this.acmeEabHmacKey = opts.acmeEabHmacKey;
805
+ this.acmeKeyType = opts.acmeKeyType;
806
+ this.acmeCaBundle = opts.acmeCaBundle;
807
+ this.acmeTosAgreed = opts.acmeTosAgreed ?? true;
808
+ this.certDir = opts.certDir ?? DEFAULT_CERT_DIR;
809
+ this.executor = opts.executor ?? null;
810
+ this.paths = opts.paths;
811
+ this.containerEdge = opts.containerEdge ?? false;
812
+ this.reloadCommand = buildReloadCommand(opts.paths);
813
+ this.pinPaths = opts.pinPaths ?? false;
814
+ this.challengeDir = opts.challengeDir ?? EDGE_CHALLENGE_DIR;
815
+ }
816
+ // ── File operation helpers (dual-path: local or remote) ──────────────
817
+ async _writeFile(path, content, mode) {
818
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
819
+ if (this.executor) {
820
+ if (mode !== void 0) this._assertChmodReaches(path, mode);
821
+ try {
822
+ await this.executor.writeFile(tmpPath, content);
823
+ if (mode !== void 0) {
824
+ await this._chmod(tmpPath, mode);
825
+ }
826
+ if (this.executor.rename) {
827
+ await this.executor.rename(tmpPath, path);
828
+ } else {
829
+ await this.executor.exec(`mv ${sq(tmpPath)} ${sq(path)}`);
830
+ }
831
+ } catch (err) {
832
+ await this.executor.rm(tmpPath).catch(() => void 0);
833
+ throw err;
834
+ }
835
+ } else {
836
+ await fsMkdir(dirname(path), { recursive: true });
837
+ try {
838
+ await fsWriteFile(tmpPath, content, { encoding: "utf-8", mode });
839
+ await fsRename(tmpPath, path);
840
+ } catch (err) {
841
+ await fsRm(tmpPath).catch(() => void 0);
842
+ throw err;
843
+ }
844
+ }
845
+ }
846
+ async _readFile(path) {
847
+ if (this.executor) {
848
+ return this.executor.readFile(path);
849
+ }
850
+ return fsReadFile(path, "utf-8");
851
+ }
852
+ async _exists(path) {
853
+ if (this.executor) {
854
+ return this.executor.exists(path);
855
+ }
856
+ try {
857
+ await fsAccess(path);
858
+ return true;
859
+ } catch {
860
+ return false;
861
+ }
862
+ }
863
+ async _rm(path) {
864
+ if (this.executor) {
865
+ await this.executor.rm(path);
866
+ } else {
867
+ try {
868
+ await fsRm(path, { recursive: true, force: true });
869
+ } catch {
870
+ }
871
+ }
872
+ }
873
+ async _mkdir(path) {
874
+ if (this.executor) {
875
+ await this.executor.mkdir(path);
876
+ } else {
877
+ await fsMkdir(path, { recursive: true });
878
+ }
879
+ }
880
+ /**
881
+ * Do a FILE op and a COMMAND at `path` reach the same bytes?
882
+ *
883
+ * On a container edge they travel different namespaces: `writeFile`/`mkdir` land on
884
+ * the HOST (bind mount) while `exec` runs INSIDE the container. They name the same
885
+ * file only where a mount carries the same string on both sides
886
+ * ({@link EDGE_SAME_PATH_MOUNTS}). A bare or remote edge has one namespace, so
887
+ * everything qualifies.
888
+ */
889
+ _sharesHostNamespace(path) {
890
+ if (!this.containerEdge) return true;
891
+ return EDGE_SAME_PATH_MOUNTS.some(
892
+ (m) => path === m || path.startsWith(`${m}/`)
893
+ );
894
+ }
895
+ /**
896
+ * Refuse an operation that mixes the two channels at a path where they are not the
897
+ * same file. `action` completes "Refusing to …" and must name the path.
898
+ *
899
+ * Never "skip it and continue", because both failure modes are silent. A `chmod`
900
+ * either misses or tightens a DIFFERENT file that does exist in the container,
901
+ * leaving a TLS private key or the ACME EAB HMAC key at the default mode and
902
+ * reporting success. An `openssl -keyout` writes the key into the container's own
903
+ * filesystem, where the host path OpenResty reads never sees it — and a vhost
904
+ * pointing at material the host lacks fails `openresty -t` for the WHOLE edge.
905
+ */
906
+ _assertOneNamespace(path, action) {
907
+ if (this._sharesHostNamespace(path)) return;
908
+ throw new Error(
909
+ `Refusing to ${action}: this edge runs in a container, so file operations land on the host while commands run inside it. Only paths under ${EDGE_SAME_PATH_MOUNTS.join(", ")} are the same path on both sides.`
910
+ );
911
+ }
912
+ /** The chmod half of {@link _assertOneNamespace}: the write travels the file channel, the mode the command channel. */
913
+ _assertChmodReaches(path, mode) {
914
+ this._assertOneNamespace(
915
+ path,
916
+ `write ${path} with mode 0${mode.toString(8)}`
917
+ );
918
+ }
919
+ /** No-op without an executor: the local path sets the mode at creation instead. */
920
+ async _chmod(path, mode) {
921
+ if (!this.executor) return;
922
+ this._assertChmodReaches(path, mode);
923
+ await this.executor.exec(`chmod ${sq(mode.toString(8))} ${sq(path)}`);
924
+ }
925
+ /**
926
+ * Put `fullchain.pem` + `privkey.pem` in `dir` as ONE atomic step.
927
+ *
928
+ * Both files are written into a temp sibling dir, then the pair is moved into
929
+ * place together. A cert and its key are only valid as a set — writing them
930
+ * individually means a crash, a full disk, or a dropped SSH connection between
931
+ * the two leaves the new cert next to the previous key, and `openresty -t` then
932
+ * fails for the WHOLE edge (every domain, not just this one) until someone
933
+ * notices. `mv -T`/rename of the staged dir also means a concurrent reload sees
934
+ * either the old pair or the new one, never a half-written PEM.
935
+ */
936
+ async stageCertDir(dir, cert) {
937
+ const staging = `${dir}.staging-${process.pid}-${randomBytes(4).toString("hex")}`;
938
+ try {
939
+ await this._mkdir(staging);
940
+ await this._writeFile(join(staging, "fullchain.pem"), cert.certPem);
941
+ await this._writeFile(join(staging, "privkey.pem"), cert.keyPem, 384);
942
+ if (this.executor) {
943
+ await this._mkdir(dir);
944
+ await this.executor.exec(
945
+ `mv -f ${sq(join(staging, "fullchain.pem"))} ${sq(join(staging, "privkey.pem"))} ${sq(dir)}/`
946
+ );
947
+ } else {
948
+ await fsMkdir(dir, { recursive: true });
949
+ await fsRename(
950
+ join(staging, "fullchain.pem"),
951
+ join(dir, "fullchain.pem")
952
+ );
953
+ await fsRename(join(staging, "privkey.pem"), join(dir, "privkey.pem"));
954
+ }
955
+ } finally {
956
+ await this._rm(staging).catch(() => void 0);
957
+ }
958
+ }
959
+ /**
960
+ * Where a domain's temporary self-signed cert lives — a SIBLING of certbot's
961
+ * `live/`, deliberately not inside it. See {@link ensureBootstrapCert}.
962
+ */
963
+ bootstrapCertDir(domain) {
964
+ return join(dirname(this.certDir), BOOTSTRAP_CERT_SEGMENT, domain);
965
+ }
966
+ /**
967
+ * Ensure a self-signed placeholder cert exists for `domain`, so a route whose TLS
968
+ * we terminate can ALWAYS complete a TLS handshake — including before certbot has
969
+ * issued anything.
970
+ *
971
+ * Why a missing :443 block is a hard failure, not a soft one: an unmatched SNI
972
+ * falls through to the edge's :443 catch-all, which answers with the "service not
973
+ * found" page (#431) under a placeholder certificate that belongs to no domain.
974
+ * For a domain we DO route that is a broken site: the visitor is told nothing is
975
+ * deployed here, and behind Cloudflare "Full" — which accepts any origin cert —
976
+ * they get that page rather than the app. Issuance deadlocks with it: Cloudflare
977
+ * answers the HTTP-01 challenge with a 301 to https, the catch-all deliberately
978
+ * carries no ACME location on :443, so the challenge 404s and the vhost stays
979
+ * HTTP-only — forever (#308). A per-domain placeholder breaks the loop: the
980
+ * domain gets its OWN name-bound :443 block, the challenge completes, and the
981
+ * real cert takes over.
982
+ *
983
+ * So the :443 catch-all having gained a certificate of its own does NOT make this
984
+ * redundant — it only changed the symptom from a raw TLS error (#431's old error
985
+ * 525) into a wrong page. The catch-all's cert is deliberately valid for nothing;
986
+ * serving it for a domain we route is the cross-serving that block exists to
987
+ * prevent.
988
+ *
989
+ * Deliberately NOT written under `certDir`: `certsExist()` / `readCertInfo()`
990
+ * read that path, so a self-signed cert there would short-circuit provisionCert
991
+ * and report an untrusted cert as `active`, with the renewal scheduler and the
992
+ * verify-pending sweep both satisfied. Keeping it in a sibling dir means cert
993
+ * STATUS is untouched — the domain still reads provisioning/error and keeps
994
+ * retrying until a real cert lands.
995
+ *
996
+ * Returns null when nothing could be produced (no `openssl` on the executor, exec
997
+ * failure); the caller falls back to an HTTP-only vhost, exactly as before.
998
+ * Never throws — a placeholder must not be able to fail a deploy.
999
+ */
1000
+ async ensureBootstrapCert(domain) {
1001
+ const dir = this.bootstrapCertDir(domain);
1002
+ const certPath = join(dir, "fullchain.pem");
1003
+ const keyPath = join(dir, "privkey.pem");
1004
+ if (!this._sharesHostNamespace(dir)) {
1005
+ console.warn(
1006
+ `[SSL] not creating a temporary certificate for ${domain}: ${dir} is not the same path inside the edge container as on the host, so OpenResty could not read the key \u2014 serving HTTP only until a real certificate is issued.`
1007
+ );
1008
+ return null;
1009
+ }
1010
+ try {
1011
+ if (await this._exists(certPath) && await this._exists(keyPath)) {
1012
+ return { certPath, keyPath };
1013
+ }
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ const staging = `${dir}.staging-${process.pid}-${randomBytes(4).toString("hex")}`;
1018
+ try {
1019
+ await this._mkdir(staging);
1020
+ await this._exec("openssl", [
1021
+ "req",
1022
+ "-x509",
1023
+ "-newkey",
1024
+ "rsa:2048",
1025
+ "-nodes",
1026
+ "-keyout",
1027
+ join(staging, "privkey.pem"),
1028
+ "-out",
1029
+ join(staging, "fullchain.pem"),
1030
+ "-days",
1031
+ "3650",
1032
+ "-subj",
1033
+ `/CN=${domain}`,
1034
+ "-addext",
1035
+ `subjectAltName=DNS:${domain}`
1036
+ ]);
1037
+ await this._chmod(join(staging, "privkey.pem"), 384);
1038
+ await this._mkdir(dir);
1039
+ if (this.executor) {
1040
+ await this.executor.exec(
1041
+ `mv -f ${sq(join(staging, "fullchain.pem"))} ${sq(join(staging, "privkey.pem"))} ${sq(dir)}/`
1042
+ );
1043
+ } else {
1044
+ await fsRename(join(staging, "fullchain.pem"), certPath);
1045
+ await fsRename(join(staging, "privkey.pem"), keyPath);
1046
+ }
1047
+ return { certPath, keyPath };
1048
+ } catch (err) {
1049
+ console.warn(
1050
+ `[SSL] could not create a temporary certificate for ${domain} \u2014 serving HTTP only until a real certificate is issued (is openssl installed on the edge?): ${safeErrorMessage(err)}`
1051
+ );
1052
+ return null;
1053
+ } finally {
1054
+ await this._rm(staging).catch(() => void 0);
1055
+ }
1056
+ }
1057
+ async _exec(command, args = []) {
1058
+ if (this.executor) {
1059
+ const full = args.length ? `${command} ${args.map(sq).join(" ")}` : command;
1060
+ return this.executor.exec(full);
1061
+ }
1062
+ try {
1063
+ const { stdout } = await execFileAsync(command, args);
1064
+ return stdout;
1065
+ } catch (err) {
1066
+ const e = err;
1067
+ const detail = [e.stderr?.trim(), e.stdout?.trim()].filter(Boolean).join("\n");
1068
+ throw new Error(detail || e.message || String(err));
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Does this static `root` resolve where OpenResty looks? (RoutingProvider)
1073
+ *
1074
+ * Runs the probe through `this.executor` — the SAME handle vhost writes and
1075
+ * `openresty -s reload` go through — so it answers from the edge's own vantage
1076
+ * point. That is the whole value: for a containerized edge the executor's `exec`
1077
+ * lands INSIDE the container (DockerEdgeExecutor / edgeContainerExecutor), so a
1078
+ * `root` that exists on the host but was never bind-mounted reads as missing
1079
+ * here, which is exactly the 404 a host-side probe cannot see. With no executor
1080
+ * the edge is bare-local and shares this process's filesystem, so node:fs is
1081
+ * already the right vantage point.
1082
+ *
1083
+ * Advisory: never throws.
1084
+ */
1085
+ async probeStaticRoot(servedPath, opts) {
1086
+ if (this.executor)
1087
+ return probeStaticOutput(this.executor, servedPath, opts);
1088
+ const fs = await this.probeStaticRootViaFs(servedPath);
1089
+ if (!opts?.hostname) return fs;
1090
+ const http = await probeHostedHttp({
1091
+ hostname: opts.hostname,
1092
+ path: opts.requestPath ?? "/",
1093
+ port: opts.edgePort ?? 80,
1094
+ timeoutMs: (opts.timeoutSeconds ?? 3) * 1e3
1095
+ });
1096
+ return http ? { ...fs, status: http.status, served: http.served } : fs;
1097
+ }
1098
+ async probeStaticRootViaFs(servedPath) {
1099
+ const found = await this._exists(servedPath);
1100
+ if (!found) return { found: false, hasIndex: false, checked: true };
1101
+ const isFile = await fsStat(servedPath).then((s) => s.isFile()).catch(() => false);
1102
+ const hasIndex = isFile || await this._exists(join(servedPath, "index.html"));
1103
+ return { found: true, hasIndex, checked: true };
1104
+ }
1105
+ /**
1106
+ * Run certbot, optionally STREAMING its output line-by-line to `onLog` (the
1107
+ * live verify modal). Falls back to buffered `_exec` when no callback / no
1108
+ * executor. `streamExec` doesn't reject on a non-zero exit — it returns the
1109
+ * code — so mirror `_exec`'s throw-with-output so `summarizeCertbotFailure`
1110
+ * still sees the real cause.
1111
+ */
1112
+ async _execCertbot(args, onLog) {
1113
+ const command = this.acmeCaBundle ? "env" : "certbot";
1114
+ const commandArgs = this.acmeCaBundle ? [`REQUESTS_CA_BUNDLE=${this.acmeCaBundle}`, "certbot", ...args] : args;
1115
+ if (!onLog || !this.executor) {
1116
+ let out;
1117
+ try {
1118
+ out = await this._exec(command, commandArgs);
1119
+ } catch (err) {
1120
+ throw new Error(this.redactAcmeSecrets(safeErrorMessage(err)));
1121
+ }
1122
+ const redacted = this.redactAcmeSecrets(out);
1123
+ if (redacted) onLog?.(redacted);
1124
+ return redacted;
1125
+ }
1126
+ const full = `${command} ${commandArgs.map(sq).join(" ")}`;
1127
+ let output = "";
1128
+ const { code } = await this.executor.streamExec(full, (log) => {
1129
+ output += log.message;
1130
+ onLog(this.redactAcmeSecrets(log.message));
1131
+ });
1132
+ if (code !== 0) {
1133
+ throw new Error(
1134
+ this.redactAcmeSecrets(output).trim() || `certbot exited ${code}`
1135
+ );
1136
+ }
1137
+ return this.redactAcmeSecrets(output);
1138
+ }
1139
+ redactAcmeSecrets(value) {
1140
+ return this.acmeEabHmacKey ? value.split(this.acmeEabHmacKey).join("[REDACTED]") : value;
1141
+ }
1142
+ /**
1143
+ * Keep EAB HMAC material out of argv/process listings and remove it after use.
1144
+ *
1145
+ * Same layout contract as runtime/git-ssh-material.ts (the repo's other
1146
+ * ephemeral-secret-on-disk site): the material lives in its OWN 0700 directory
1147
+ * with the file at 0600, the bytes never travel through a shell command line,
1148
+ * and cleanup removes the directory as a unit. The 0700 parent goes first, so
1149
+ * even the temp file _writeFile stages is born unreadable to other users.
1150
+ */
1151
+ async createEphemeralEabConfig() {
1152
+ if (!this.acmeEabKid || !this.acmeEabHmacKey) return null;
1153
+ const dir = join(
1154
+ dirname(this.certDir),
1155
+ `.kraft-eab-${randomBytes(12).toString("hex")}`
1156
+ );
1157
+ const path = join(dir, "eab.ini");
1158
+ if (this.executor) await this.executor.mkdir(dir);
1159
+ else await fsMkdir(dir, { recursive: true, mode: 448 });
1160
+ try {
1161
+ await this._chmod(dir, 448);
1162
+ await this._writeFile(
1163
+ path,
1164
+ `eab-kid = ${this.acmeEabKid}
1165
+ eab-hmac-key = ${this.acmeEabHmacKey}
1166
+ `,
1167
+ 384
1168
+ );
1169
+ } catch (err) {
1170
+ await this._rm(dir).catch(() => void 0);
1171
+ throw err;
1172
+ }
1173
+ return path;
1174
+ }
1175
+ /** Materialize generated DNS hooks on the same target where Certbot runs. */
1176
+ async createEphemeralDnsHooks(opts) {
1177
+ if (!opts?.dnsAuthHookScript) return null;
1178
+ const dir = join(
1179
+ dirname(this.certDir),
1180
+ `.kraft-dns-${randomBytes(12).toString("hex")}`
1181
+ );
1182
+ const authPath = join(dir, "auth.sh");
1183
+ const cleanupPath = join(dir, "cleanup.sh");
1184
+ const recordPath = join(dir, "record-id.txt");
1185
+ await this._mkdir(dir);
1186
+ try {
1187
+ await this._chmod(dir, 448);
1188
+ await this._writeFile(authPath, opts.dnsAuthHookScript, 448);
1189
+ if (opts.dnsCleanupHookScript) {
1190
+ await this._writeFile(cleanupPath, opts.dnsCleanupHookScript, 448);
1191
+ }
1192
+ } catch (err) {
1193
+ await this._rm(dir).catch(() => void 0);
1194
+ throw err;
1195
+ }
1196
+ const recordEnv = `KRAFT_DNS_RECORD_FILE=${sq(recordPath)}`;
1197
+ return {
1198
+ dir,
1199
+ authCommand: `${recordEnv} ${sq(authPath)}`,
1200
+ cleanupCommand: opts.dnsCleanupHookScript ? `${recordEnv} ${sq(cleanupPath)}` : void 0
1201
+ };
1202
+ }
1203
+ async _captureFile(path) {
1204
+ if (!await this._exists(path)) {
1205
+ return { exists: false };
1206
+ }
1207
+ return {
1208
+ exists: true,
1209
+ content: await this._readFile(path)
1210
+ };
1211
+ }
1212
+ async _restoreFile(path, snapshot) {
1213
+ if (!snapshot.exists) {
1214
+ await this._rm(path);
1215
+ return;
1216
+ }
1217
+ await this._writeFile(path, snapshot.content ?? "");
1218
+ }
1219
+ // ── Routing ──────────────────────────────────────────────────────────
1220
+ /**
1221
+ * Register a route by writing an OpenResty server block.
1222
+ *
1223
+ * Creates a conf file in sites-enabled, then reloads.
1224
+ * If TLS is enabled and certs exist, configures SSL. If certs don't
1225
+ * exist yet, writes an HTTP-only block (certbot will add SSL later
1226
+ * via provisionCert).
1227
+ */
1228
+ async registerRoute(route) {
1229
+ assertValidDomain(route.domain);
1230
+ await this._mkdir(this.sitesDir);
1231
+ const slug = await this.resolveSlug(route.domain);
1232
+ const configPath = join(this.sitesDir, `${slug}.conf`);
1233
+ if ("staticRoot" in route && route.staticRoot) {
1234
+ assertValidStaticRoot(route.staticRoot, {
1235
+ adopted: route.staticRootAdopted
1236
+ });
1237
+ } else {
1238
+ assertValidUpstream(route.targetUrl);
1239
+ }
1240
+ const hostRedirect = renderHostRedirect(route);
1241
+ const locationBody = hostRedirect ? hostRedirect : "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
1242
+ index index.html;
1243
+ ${renderStaticTryFiles(route)}` : `proxy_pass ${route.targetUrl};
1244
+ ${PROXY_HEADERS}`;
1245
+ const urlShape = hostRedirect ? "" : renderUrlShape(route);
1246
+ const redirectRules = hostRedirect ? "" : renderRedirectRules(route, " ");
1247
+ const extraLocations = hostRedirect ? "" : renderProxyLocations(route, { httpsUpgrade: false });
1248
+ const upgradingExtraLocations = hostRedirect ? "" : renderProxyLocations(route, { httpsUpgrade: true });
1249
+ const serverHeaders = hostRedirect ? "" : renderServerHeaders(route, slug);
1250
+ const prelude = hostRedirect ? "" : renderHeaderMaps(route, slug);
1251
+ const proxyOpts = renderProxyOptions(route.proxy, this.nginxVersion);
1252
+ const proxyTlsOpts = renderProxyTlsOptions(route.proxy, this.nginxVersion);
1253
+ const preamble = isCloudFrontedHost(route.domain) ? `${cloudEdgeRealIpConf()}
1254
+ ${FORWARD_VARS}` : FORWARD_VARS;
1255
+ const webhookLocation = route.webhookProxy && !hostRedirect ? `
1256
+ location ^~ ${WEBHOOK_LOCATION_PREFIX} {
1257
+ proxy_pass ${route.webhookProxy};
1258
+ proxy_set_header Host $host;
1259
+ proxy_set_header X-Real-IP $remote_addr;
1260
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1261
+ proxy_set_header X-Forwarded-Proto $kraft_fwd_proto;
1262
+ }
1263
+ ` : "";
1264
+ const edgeLua = luaSourceAvailable();
1265
+ const luaProxy = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};
1266
+ access_by_lua_file ${RULES_GUARD_PATH};` : "";
1267
+ const slashFallback = !hostRedirect && "staticRoot" in route && route.staticRoot && route.trailingSlash === true ? `
1268
+
1269
+ ${renderSlashFallback(route.staticRoot)}` : "";
1270
+ const serveLocation = ` location / {
1271
+ ${redirectRules}${urlShape}${locationBody}
1272
+ }${slashFallback}`;
1273
+ const redirectOrServeLocation = hostRedirect ? serveLocation : ` location / {
1274
+ ${HTTPS_UPGRADE}${redirectRules}${urlShape}${locationBody}
1275
+ }${slashFallback}`;
1276
+ const sharedBody = `${serverHeaders}${proxyOpts}
1277
+ ${webhookLocation}${extraLocations}`;
1278
+ const httpsUpgradeBody = `${serverHeaders}${proxyOpts}
1279
+ ${webhookLocation}${upgradingExtraLocations}`;
1280
+ const httpOnlyBlock = `${GENERATED_HEADER}
1281
+ server {
1282
+ listen 80;
1283
+ server_name ${route.domain};
1284
+
1285
+ ${preamble}
1286
+
1287
+ ${luaProxy}
1288
+
1289
+ ${ACME_CHALLENGE_LOCATION}
1290
+
1291
+ ${EDGE_CHALLENGE_LOCATION}
1292
+ ${sharedBody}
1293
+ ${serveLocation}
1294
+ }
1295
+ `;
1296
+ let serverBlock;
1297
+ let bootstrapServed = false;
1298
+ if (route.tls && await this.certsExist(route.domain)) {
1299
+ const certPath = join(this.certDir, route.domain, "fullchain.pem");
1300
+ const keyPath = join(this.certDir, route.domain, "privkey.pem");
1301
+ serverBlock = `${GENERATED_HEADER}
1302
+ server {
1303
+ listen 80;
1304
+ server_name ${route.domain};
1305
+
1306
+ ${preamble}
1307
+
1308
+ ${luaProxy}
1309
+
1310
+ ${ACME_CHALLENGE_LOCATION}
1311
+
1312
+ ${EDGE_CHALLENGE_LOCATION}
1313
+ ${httpsUpgradeBody}
1314
+ ${redirectOrServeLocation}
1315
+ }
1316
+
1317
+ server {
1318
+ listen 443 ssl;
1319
+ server_name ${route.domain};
1320
+
1321
+ ${preamble}
1322
+
1323
+ ${luaProxy}
1324
+
1325
+ ssl_certificate ${certPath};
1326
+ ssl_certificate_key ${keyPath};${proxyTlsOpts}
1327
+ ${sharedBody}
1328
+ ${serveLocation}
1329
+ }
1330
+ `;
1331
+ } else if (route.tls && route.terminatesTlsLocally) {
1332
+ const bootstrap = await this.ensureBootstrapCert(route.domain);
1333
+ bootstrapServed = !!bootstrap;
1334
+ serverBlock = bootstrap ? `${GENERATED_HEADER}
1335
+ # kraft-bootstrap-tls: ${route.domain} is serving a TEMPORARY self-signed
1336
+ # certificate because its real certificate has not been issued yet. Kraft
1337
+ # replaces this file automatically once it lands. Do not adopt this material.
1338
+ server {
1339
+ listen 80;
1340
+ server_name ${route.domain};
1341
+
1342
+ ${preamble}
1343
+
1344
+ ${luaProxy}
1345
+
1346
+ ${ACME_CHALLENGE_LOCATION}
1347
+
1348
+ ${EDGE_CHALLENGE_LOCATION}
1349
+ ${sharedBody}
1350
+ ${serveLocation}
1351
+ }
1352
+
1353
+ server {
1354
+ listen 443 ssl;
1355
+ server_name ${route.domain};
1356
+
1357
+ ${preamble}
1358
+
1359
+ ${luaProxy}
1360
+
1361
+ ssl_certificate ${bootstrap.certPath};
1362
+ ssl_certificate_key ${bootstrap.keyPath};${proxyTlsOpts}
1363
+ ${sharedBody}
1364
+ ${serveLocation}
1365
+ }
1366
+ ` : httpOnlyBlock;
1367
+ } else {
1368
+ serverBlock = httpOnlyBlock;
1369
+ }
1370
+ const snapshot = await this._captureFile(configPath);
1371
+ await this._writeFile(configPath, `${prelude}${serverBlock}`);
1372
+ try {
1373
+ await this.reload();
1374
+ } catch (err) {
1375
+ await this._restoreFile(configPath, snapshot);
1376
+ await this.reload().catch(() => void 0);
1377
+ throw err;
1378
+ }
1379
+ if (!bootstrapServed) {
1380
+ await this._rm(this.bootstrapCertDir(route.domain)).catch(
1381
+ () => void 0
1382
+ );
1383
+ }
1384
+ await this._writeFile(
1385
+ this.routeStatePath(slug),
1386
+ JSON.stringify(route)
1387
+ ).catch(() => void 0);
1388
+ }
1389
+ routeStatePath(slug) {
1390
+ return join(this.sitesDir, `${slug}.route.json`);
1391
+ }
1392
+ /**
1393
+ * Re-emit every vhost an older generator wrote, from its own persisted RouteConfig.
1394
+ *
1395
+ * Why this exists: a fix to the EMITTED shape otherwise never reaches a running box.
1396
+ * `registerRoute` is the only writer, and the only things that call it are a deploy, a
1397
+ * domain save, a Retry, and cert issuance — so a project nobody redeploys keeps the vhost
1398
+ * it was first given for as long as it lives. That is how the Cloud-fronted `X-Real-IP`
1399
+ * block shipped and then applied to nothing: correct code, upgraded box, unchanged file.
1400
+ *
1401
+ * Cheap and idempotent by construction: a vhost already stamped at
1402
+ * {@link VHOST_GENERATION} is read and skipped, so the pass writes nothing and reloads
1403
+ * nothing once converged. Only the FIRST run after a bump does work.
1404
+ *
1405
+ * That first run is not free, and the release that introduced the marker is its worst
1406
+ * case: every conf written before it is unstamped, so a box with N routes reapplies all
1407
+ * N. `registerRoute` writes and reloads per route by design — a conf that fails
1408
+ * `openresty -t` is rolled back and reloaded on its own, which is why one bad route
1409
+ * cannot take the other N-1 down — so that run costs N whole-config tests and N reloads,
1410
+ * serially, on a deploy. Batching them behind a single test would trade that isolation
1411
+ * for the seconds it saves once per box, which is the wrong trade. What it does mean is
1412
+ * that a VHOST_GENERATION bump is a fleet-wide cost, not a free redeploy: bump it for a
1413
+ * fix that has to reach existing boxes, not to tidy the emitted text.
1414
+ *
1415
+ * Never throws and never aborts the sweep. One route whose sidecar is unreadable, or
1416
+ * whose re-render fails `openresty -t` (registerRoute rolls that file back on its own),
1417
+ * must not stop the rest — a half-converged edge with a reported failure is strictly
1418
+ * better than a wedged one. A route with no sidecar is left ALONE rather than
1419
+ * reconstructed from its conf: the scrape recovers only the primary target, so replaying
1420
+ * it would drop composite locations and the webhook proxy.
1421
+ */
1422
+ async reapplyStoredRoutes() {
1423
+ const repaired = [];
1424
+ const failed = [];
1425
+ let entries;
1426
+ try {
1427
+ entries = await this._lsSites();
1428
+ } catch (err) {
1429
+ return {
1430
+ scanned: 0,
1431
+ repaired,
1432
+ failed: [{ slug: "*", reason: safeErrorMessage(err) }]
1433
+ };
1434
+ }
1435
+ const slugs = entries.filter((name) => name.endsWith(".route.json")).map((name) => name.slice(0, -".route.json".length));
1436
+ const stale = await this._staleVhostSlugs().catch(() => null);
1437
+ for (const slug of slugs) {
1438
+ if (stale && !stale.has(slug)) continue;
1439
+ try {
1440
+ const conf = await this._readFile(
1441
+ join(this.sitesDir, `${slug}.conf`)
1442
+ ).catch(() => null);
1443
+ if (conf === null) continue;
1444
+ const gen = readVhostGeneration(conf);
1445
+ if (gen !== null && gen >= VHOST_GENERATION) continue;
1446
+ const saved = JSON.parse(
1447
+ await this._readFile(this.routeStatePath(slug))
1448
+ );
1449
+ if (!saved?.domain) {
1450
+ failed.push({ slug, reason: "sidecar has no domain" });
1451
+ continue;
1452
+ }
1453
+ await this.registerRoute(saved);
1454
+ repaired.push(saved.domain);
1455
+ } catch (err) {
1456
+ failed.push({ slug, reason: safeErrorMessage(err) });
1457
+ }
1458
+ }
1459
+ return { scanned: slugs.length, repaired, failed };
1460
+ }
1461
+ /**
1462
+ * Names in the sites directory.
1463
+ *
1464
+ * Listing goes through the COMMAND channel, so on a container edge it must name the
1465
+ * path commands see: `sitesDir` is the HOST side of the bind mount and does not exist
1466
+ * inside the container. Same directory either way, and only basenames are used — the
1467
+ * reads that follow go back through the file channel at the host path.
1468
+ */
1469
+ async _lsSites() {
1470
+ if (!this.executor) return fsReaddir(this.sitesDir);
1471
+ const out = await this.executor.exec(
1472
+ `ls -1 ${sq(this._commandSitesDir())} 2>/dev/null || true`
1473
+ );
1474
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
1475
+ }
1476
+ /**
1477
+ * Slugs whose `.conf` does not carry the CURRENT generation marker — `null` when the
1478
+ * question can't be answered in one command and the caller should fall back to reading.
1479
+ *
1480
+ * `grep -L` on the whole directory, so the steady state (everything current) costs one
1481
+ * round trip instead of one per route. The pattern is anchored on both ends and built from
1482
+ * the same constant the writer emits, so `gen: 1` cannot pass for `gen: 10` and the two
1483
+ * halves cannot drift apart.
1484
+ */
1485
+ async _staleVhostSlugs() {
1486
+ if (!this.executor) return null;
1487
+ const dir = this._commandSitesDir();
1488
+ const out = await this.executor.exec(
1489
+ `grep -L ${sq(`^${GENERATION_MARKER}$`)} ${sq(dir)}/*.conf 2>/dev/null || true`
1490
+ );
1491
+ const slugs = /* @__PURE__ */ new Set();
1492
+ for (const line of out.split("\n")) {
1493
+ const name = line.trim().split("/").pop();
1494
+ if (name?.endsWith(".conf")) slugs.add(name.slice(0, -".conf".length));
1495
+ }
1496
+ return slugs;
1497
+ }
1498
+ /**
1499
+ * The sites directory as COMMANDS see it.
1500
+ *
1501
+ * On a container edge `sitesDir` is the HOST side of the bind mount and does not exist
1502
+ * inside the container, where `exec` runs. Same directory, two names; only basenames are
1503
+ * used, and the reads that follow go back through the file channel at the host path.
1504
+ */
1505
+ _commandSitesDir() {
1506
+ return this.containerEdge ? OPENRESTY_DEFAULT_PATHS.sitesDir : this.sitesDir;
1507
+ }
1508
+ /**
1509
+ * Recover the proxy tunables from a vhost we already wrote, for the legacy
1510
+ * no-sidecar re-register below. Rewriting the conf without them would silently
1511
+ * revert a site to nginx's 1 MB / 60 s defaults as a side effect of issuing a
1512
+ * certificate — the one moment an operator is least likely to look for it.
1513
+ *
1514
+ * Same table as the renderer, read backwards, so the two cannot drift.
1515
+ */
1516
+ scrapeProxySettings(conf) {
1517
+ const found = {};
1518
+ for (const spec of PROXY_DIRECTIVES) {
1519
+ const m = conf.match(
1520
+ new RegExp(`^\\s*${spec.directive}\\s+([^;\\n]+);`, "m")
1521
+ );
1522
+ if (!m?.[1]) continue;
1523
+ const parsed = parseProxyValue(spec, m[1].trim());
1524
+ if (parsed !== void 0) found[spec.key] = parsed;
1525
+ }
1526
+ return sanitizeProxySettings(found);
1527
+ }
1528
+ /**
1529
+ * Remove a route by deleting its conf + route-state files, then reload.
1530
+ *
1531
+ * Self-rollback (same pattern as registerRoute): snapshot both files first, and
1532
+ * if the post-removal `openresty -t`/reload fails — usually because an UNRELATED
1533
+ * vhost is broken — restore them so the edge on disk == the running config.
1534
+ * Without this, a failed reload left the route GONE from disk but still served
1535
+ * from the old in-memory config (an inconsistent half-state that would silently
1536
+ * vanish on the next unrelated reload). The error is re-thrown for retry.
1537
+ */
1538
+ async removeRoute(domain, opts) {
1539
+ assertValidDomain(domain);
1540
+ if (opts?.signal?.aborted) {
1541
+ throw new Error(`Route removal aborted before it started: ${domain}`);
1542
+ }
1543
+ const slug = await this.resolveSlug(domain);
1544
+ const configPath = join(this.sitesDir, `${slug}.conf`);
1545
+ const statePath = this.routeStatePath(slug);
1546
+ const confSnapshot = await this._captureFile(configPath);
1547
+ const stateSnapshot = await this._captureFile(statePath);
1548
+ await this._rm(configPath);
1549
+ await this._rm(statePath).catch(() => void 0);
1550
+ try {
1551
+ await this.reload({ allowStopped: true });
1552
+ } catch (err) {
1553
+ try {
1554
+ if (!opts?.signal?.aborted)
1555
+ await this._restoreFile(configPath, confSnapshot);
1556
+ if (!opts?.signal?.aborted)
1557
+ await this._restoreFile(statePath, stateSnapshot);
1558
+ if (!opts?.signal?.aborted) {
1559
+ await this.reload({ allowStopped: true }).catch(() => void 0);
1560
+ }
1561
+ } finally {
1562
+ if (opts?.signal?.aborted) {
1563
+ await this._rm(configPath).catch(() => void 0);
1564
+ await this._rm(statePath).catch(() => void 0);
1565
+ }
1566
+ }
1567
+ throw err;
1568
+ }
1569
+ await this._rm(this.bootstrapCertDir(domain)).catch(() => void 0);
1570
+ }
1571
+ // ── Edge-target challenge ────────────────────────────────────────────
1572
+ /** Filename for a host's challenge vhost. The leading `_` is a namespace
1573
+ * `resolveSlug` can never produce (`domainSlug` emits only `[A-Za-z0-9-]`), so no
1574
+ * `registerRoute`/`removeRoute` for any domain can overwrite or delete it —
1575
+ * matching the existing `_default.conf` / `_management.conf` convention. */
1576
+ challengeVhostPath(host) {
1577
+ return join(
1578
+ this.sitesDir,
1579
+ `_oblien-challenge-${this.domainSlug(host)}.conf`
1580
+ );
1581
+ }
1582
+ /**
1583
+ * Make `host` able to answer edge-target challenges, and serve any tokens given.
1584
+ *
1585
+ * Called TWO ways, and the split is the point:
1586
+ * - `{ host }` alone, from edge-ensure (install + every deploy) — writes only the
1587
+ * vhost, so the box can answer before any verification exists.
1588
+ * - `{ host, tokens }`, from the verify flow — writes the token files too.
1589
+ *
1590
+ * That the first form is possible at all is what makes this ready from first
1591
+ * install rather than only once a challenge is in flight: the location serves a
1592
+ * DIRECTORY, so it is valid with nothing in it (a missing token is a 404, which is
1593
+ * the right answer). And because the vhost lands in the bind-mounted sites-enabled
1594
+ * written by our own code, it works on ANY edge image — including one predating
1595
+ * this feature, and including a rollback. The baked image's catch-all cannot make
1596
+ * that claim, which is why it is not the mechanism.
1597
+ *
1598
+ * Idempotent: skips the vhost write AND the reload when what is on disk already
1599
+ * matches, so re-asserting on every deploy costs a couple of stats.
1600
+ *
1601
+ * `tokens` is a list because a re-issued verification resets the token while an
1602
+ * older record may still be probed, and because two orgs can target one box. All
1603
+ * are served; none is ever removed — Oblien re-probes the SAME token near its
1604
+ * 90-day expiry, so dropping one silently kills that route ~83 days later.
1605
+ */
1606
+ async serveEdgeChallenge(input) {
1607
+ const host = input.host.trim().toLowerCase().replace(/\.$/, "");
1608
+ assertValidDomain(host);
1609
+ const tokens = input.tokens ?? [];
1610
+ for (const token of tokens) assertValidChallengeToken(token);
1611
+ for (const token of tokens) {
1612
+ await this._writeFile(join(this.challengeDir, token), token);
1613
+ }
1614
+ const base = this.domainSlug(host);
1615
+ for (const stem of [base, `${base}-${this.slugSuffix(host)}`]) {
1616
+ const path2 = join(this.sitesDir, `${stem}.conf`);
1617
+ const conf = await this._readFile(path2).catch(() => "");
1618
+ if (!conf || !this.serverNamesIn(conf).includes(host)) continue;
1619
+ if (conf.includes(`${EDGE_CHALLENGE_URL_PREFIX} {`)) {
1620
+ return { served: true, via: "existing-vhost" };
1621
+ }
1622
+ return {
1623
+ served: false,
1624
+ via: null,
1625
+ claimedBy: path2,
1626
+ reason: `${host} is already served by ${path2}, which predates edge-target challenge support. Re-register that domain (a deploy or a Domains-tab save) to refresh its vhost, then retry.`
1627
+ };
1628
+ }
1629
+ const vhost = edgeChallengeVhostConf(host);
1630
+ const path = this.challengeVhostPath(host);
1631
+ const existing = await this._readFile(path).catch(() => "");
1632
+ if (existing === vhost) return { served: true, via: "challenge-vhost" };
1633
+ const snapshot = await this._captureFile(path);
1634
+ await this._writeFile(path, vhost);
1635
+ try {
1636
+ await this.reload();
1637
+ } catch (err) {
1638
+ await this._restoreFile(path, snapshot);
1639
+ await this.reload().catch(() => void 0);
1640
+ throw err;
1641
+ }
1642
+ return { served: true, via: "challenge-vhost" };
1643
+ }
1644
+ // ── SSL ──────────────────────────────────────────────────────────────
1645
+ /**
1646
+ * Provision a TLS certificate using certbot.
1647
+ *
1648
+ * HTTP-01 ONLY — there is no DNS-01 path anywhere in this file, and the DNS credentials the
1649
+ * product stores are for record MANAGEMENT, not for challenges.
1650
+ *
1651
+ * The authenticator is `--standalone` on {@link ACME_HTTP01_PORT} (a loopback alt-port), with
1652
+ * the edge proxying `/.well-known/acme-challenge/` to it — see `ACME_CHALLENGE_LOCATION`. So
1653
+ * Let's Encrypt fetches the challenge over :80 from OpenResty, which forwards it to certbot's
1654
+ * transient listener. No port-80 fight, no webroot directory to keep in sync, and zero
1655
+ * downtime: certbot holds that port only while issuing.
1656
+ *
1657
+ * THIS COMMENT USED TO DESCRIBE `--webroot`, and argued that `--standalone` was dead code
1658
+ * because "certbot --standalone binds to port 80 itself, but OpenResty already owns 80". That
1659
+ * was true of plain `--standalone`, and it is exactly the problem `--http-01-port` solves —
1660
+ * the two sat contradicting each other in one file, with the doc describing a mechanism the
1661
+ * code below had stopped using. Left recorded because the stale version's reasoning is
1662
+ * persuasive enough to "fix" the code back.
1663
+ *
1664
+ * The caller (route-registration.ts) wraps this in try/catch, so a failure becomes a "deploy
1665
+ * continues on HTTP, retry from the Domains tab" warning rather than a deploy abort.
1666
+ */
1667
+ async provisionCert(domain, opts) {
1668
+ assertValidDomain(domain);
1669
+ if (!opts?.force && await this.certsExist(domain)) {
1670
+ return this.readCertInfo(domain);
1671
+ }
1672
+ const validEmail = !!this.acmeEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.acmeEmail);
1673
+ const emailArgs = validEmail ? ["--email", this.acmeEmail] : ["--register-unsafely-without-email"];
1674
+ let certonlyOut = "";
1675
+ const eabConfig = await this.createEphemeralEabConfig();
1676
+ let generatedDnsHooks = null;
1677
+ try {
1678
+ generatedDnsHooks = await this.createEphemeralDnsHooks(opts);
1679
+ const isDnsChallenge = opts?.challenge === "dns-01" || domain.startsWith("*.");
1680
+ const challengeArgs = isDnsChallenge ? [
1681
+ "certonly",
1682
+ "--manual",
1683
+ "--preferred-challenges",
1684
+ "dns",
1685
+ ...generatedDnsHooks?.authCommand || opts?.dnsAuthHook ? [
1686
+ "--manual-auth-hook",
1687
+ generatedDnsHooks?.authCommand ?? opts.dnsAuthHook
1688
+ ] : [],
1689
+ ...generatedDnsHooks?.cleanupCommand || opts?.dnsCleanupHook ? [
1690
+ "--manual-cleanup-hook",
1691
+ generatedDnsHooks?.cleanupCommand ?? opts.dnsCleanupHook
1692
+ ] : []
1693
+ ] : [
1694
+ "certonly",
1695
+ "--standalone",
1696
+ "--http-01-port",
1697
+ String(ACME_HTTP01_PORT)
1698
+ ];
1699
+ certonlyOut = await this._execCertbot(
1700
+ [
1701
+ ...eabConfig ? ["--config", eabConfig] : [],
1702
+ ...challengeArgs,
1703
+ "--cert-name",
1704
+ domain,
1705
+ "-d",
1706
+ domain,
1707
+ ...this.acmeDirectoryUrl ? ["--server", this.acmeDirectoryUrl] : [],
1708
+ ...acmeKeyArgs(this.acmeKeyType),
1709
+ ...emailArgs,
1710
+ ...this.acmeTosAgreed ? ["--agree-tos"] : [],
1711
+ "--non-interactive",
1712
+ // Forced reissue: certbot would otherwise print "not due for renewal"
1713
+ // (exit 0) when a lineage exists, leaving the stale cert in place.
1714
+ ...opts?.force ? ["--force-renewal"] : []
1715
+ ],
1716
+ opts?.onLog
1717
+ );
1718
+ } catch (err) {
1719
+ const raw = this.redactAcmeSecrets(safeErrorMessage(err));
1720
+ let summary = summarizeCertbotFailure(raw, domain);
1721
+ if (isAcmePortBindFailure(raw) && this.executor) {
1722
+ const occupant = await probeListeningPort(
1723
+ this.executor,
1724
+ ACME_HTTP01_PORT
1725
+ ).catch(() => null);
1726
+ if (occupant) {
1727
+ summary += ` Currently held by ${occupant.command}` + (occupant.pid !== null ? ` (pid ${occupant.pid})` : "") + (occupant.systemdUnit ? ` via ${occupant.systemdUnit}` : "") + `.`;
1728
+ }
1729
+ }
1730
+ throw new Error(summary);
1731
+ } finally {
1732
+ if (eabConfig) await this._rm(dirname(eabConfig)).catch(() => void 0);
1733
+ if (generatedDnsHooks)
1734
+ await this._rm(generatedDnsHooks.dir).catch(() => void 0);
1735
+ }
1736
+ const slug = await this.resolveSlug(domain);
1737
+ const configPath = join(this.sitesDir, `${slug}.conf`);
1738
+ try {
1739
+ const state = await this._readFile(this.routeStatePath(slug));
1740
+ const saved = JSON.parse(state);
1741
+ await this.registerRoute({ ...saved, domain, tls: true });
1742
+ return this.ensureIssued(domain, certonlyOut);
1743
+ } catch {
1744
+ }
1745
+ try {
1746
+ const existing = await this._readFile(configPath);
1747
+ const scraped = this.scrapeProxySettings(existing);
1748
+ const targetMatch = existing.match(/proxy_pass\s+([^;]+);/);
1749
+ if (targetMatch) {
1750
+ await this.registerRoute({
1751
+ domain,
1752
+ targetUrl: targetMatch[1],
1753
+ tls: true,
1754
+ ...scraped ? { proxy: scraped } : {}
1755
+ });
1756
+ return this.ensureIssued(domain, certonlyOut);
1757
+ }
1758
+ const rootMatch = existing.match(/root\s+([^;]+);/);
1759
+ if (rootMatch) {
1760
+ await this.registerRoute({
1761
+ domain,
1762
+ staticRoot: rootMatch[1],
1763
+ staticRootAdopted: true,
1764
+ tls: true,
1765
+ ...scraped ? { proxy: scraped } : {}
1766
+ });
1767
+ return this.ensureIssued(domain, certonlyOut);
1768
+ }
1769
+ } catch {
1770
+ }
1771
+ return this.ensureIssued(domain, certonlyOut);
1772
+ }
1773
+ /**
1774
+ * After a certbot run that DIDN'T throw, confirm a usable cert actually landed
1775
+ * at the expected path. certbot can exit 0 while producing nothing readable
1776
+ * there (a lineage written elsewhere, "not due for renewal", 0 domains
1777
+ * authenticated) — a silent `{missing}` hides why.
1778
+ *
1779
+ * The exit code plus this read of `live/<domain>` is the WHOLE verdict. certbot's
1780
+ * stdout is evidence for the operator, never the test: a zero-exit run ends with
1781
+ * the donation footer, and running the failure summarizer over it reported
1782
+ * "Donating to ISRG / Let's Encrypt: …" as the cause of a cert that had in fact
1783
+ * been issued. Only a diagnosis that matches a KNOWN failure shape is quoted.
1784
+ */
1785
+ async ensureIssued(domain, certbotOutput) {
1786
+ const result = await this.readCertInfo(domain);
1787
+ if (result.verified) return result;
1788
+ const dir = join(this.certDir, domain);
1789
+ const onDisk = result.reason === "read_error" ? `a certificate is at ${dir} but couldn't be read (permissions, or a partial write)` : result.reason === "invalid" ? `the certificate at ${dir} isn't usable for ${domain} (expired, wrong hostname, or the key doesn't match)` : `no certificate is at ${dir}`;
1790
+ const diagnosis = certbotDiagnosis(certbotOutput, domain);
1791
+ const tail = certbotOutput.trim().slice(-1200);
1792
+ throw new Error(
1793
+ `certbot exited without an error but ${onDisk}.` + (diagnosis ? ` ${diagnosis}` : "") + (tail ? `
1794
+
1795
+ certbot said:
1796
+ ${tail}` : "")
1797
+ );
1798
+ }
1799
+ /**
1800
+ * Renew a TLS certificate using certbot.
1801
+ */
1802
+ async renewCert(domain, opts) {
1803
+ assertValidDomain(domain);
1804
+ if (opts?.challenge === "dns-01" || domain.startsWith("*.")) {
1805
+ return this.provisionCert(domain, {
1806
+ ...opts,
1807
+ challenge: "dns-01",
1808
+ force: true
1809
+ });
1810
+ }
1811
+ if (!await this.certsExist(domain)) {
1812
+ return this.provisionCert(domain, opts);
1813
+ }
1814
+ if (!await this.hasCertbotLineage(domain)) {
1815
+ return this.provisionCert(domain, { ...opts, force: true });
1816
+ }
1817
+ if (!await this.lineageServerMatches(domain)) {
1818
+ return this.provisionCert(domain, { ...opts, force: true });
1819
+ }
1820
+ await this._execCertbot(
1821
+ [
1822
+ "renew",
1823
+ "--cert-name",
1824
+ domain,
1825
+ ...acmeKeyArgs(this.acmeKeyType),
1826
+ "--non-interactive"
1827
+ ],
1828
+ opts?.onLog
1829
+ );
1830
+ await this.reload();
1831
+ return this.readCertInfo(domain);
1832
+ }
1833
+ /**
1834
+ * Does certbot track a renewal lineage for this domain? `/etc/letsencrypt/renewal/
1835
+ * <domain>.conf` is the per-lineage record certbot writes on first issuance and
1836
+ * reads on every `renew`, so its presence is the authoritative answer — the cert
1837
+ * FILES existing is not (they can be adopted, or copied in by hand).
1838
+ */
1839
+ renewalConfPath(domain) {
1840
+ return join(dirname(this.certDir), "renewal", `${domain}.conf`);
1841
+ }
1842
+ async hasCertbotLineage(domain) {
1843
+ return this._exists(this.renewalConfPath(domain));
1844
+ }
1845
+ /**
1846
+ * Was this lineage issued by the CURRENTLY configured ACME directory? The
1847
+ * renewal conf records the issuing `server`, and `certbot renew` only holds an
1848
+ * account for THAT server — after the operator points at a different CA, renewing
1849
+ * a pre-switch lineage would need an account registration `renew` cannot perform
1850
+ * (and EAB material it is never given). An unreadable/unparsable conf answers
1851
+ * `true` so the plain renew still runs and surfaces certbot's own error.
1852
+ */
1853
+ async lineageServerMatches(domain) {
1854
+ let conf;
1855
+ try {
1856
+ conf = await this._readFile(this.renewalConfPath(domain));
1857
+ } catch {
1858
+ return true;
1859
+ }
1860
+ const recorded = conf.match(/^\s*server\s*=\s*(\S+)\s*$/m)?.[1];
1861
+ if (!recorded) return true;
1862
+ const effective = this.acmeDirectoryUrl ?? LETSENCRYPT_PRODUCTION_DIRECTORY;
1863
+ return recorded === effective;
1864
+ }
1865
+ /**
1866
+ * Install an operator-supplied or adopted certificate (no ACME). Validates the
1867
+ * PEM pair against the domain, writes it to the same on-disk path certbot uses,
1868
+ * then re-registers the vhost with TLS — mirroring provisionCert's tail so
1869
+ * composite routes are preserved.
1870
+ *
1871
+ * The validation is deliberately here, at the last gate before disk, and not
1872
+ * only in the callers: every path that puts a cert on this box (operator upload,
1873
+ * migration carry, cert reuse) funnels through this method, so a check here can't
1874
+ * be bypassed by a new caller that forgets it.
1875
+ */
1876
+ async installCert(domain, cert) {
1877
+ assertValidDomain(domain);
1878
+ const candidate = validateCertFor(domain, cert, "supplied certificate");
1879
+ if (!candidate.cert)
1880
+ throw new Error(`Invalid certificate: ${candidate.reason}`);
1881
+ const { expiresAt } = candidate.cert;
1882
+ const dir = join(this.certDir, domain);
1883
+ await this.stageCertDir(dir, cert);
1884
+ const slug = await this.resolveSlug(domain);
1885
+ try {
1886
+ const state = await this._readFile(this.routeStatePath(slug));
1887
+ const saved = JSON.parse(state);
1888
+ await this.registerRoute({ ...saved, domain, tls: true });
1889
+ } catch {
1890
+ }
1891
+ return {
1892
+ domain,
1893
+ expiresAt,
1894
+ issuer: candidate.cert.renewable ? candidate.cert.issuer : "manual",
1895
+ verified: true,
1896
+ reason: "issued"
1897
+ };
1898
+ }
1899
+ // ── Helpers ──────────────────────────────────────────────────────────
1900
+ /**
1901
+ * Filename stem for a domain. NOT injective — see `resolveSlug`.
1902
+ *
1903
+ * Kept exactly as-is on purpose: it names every vhost file on every existing
1904
+ * install, so changing it would rename the lot and leave the old names behind
1905
+ * still serving. `resolveSlug` handles the collisions instead.
1906
+ */
1907
+ domainSlug(domain) {
1908
+ return domain.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-");
1909
+ }
1910
+ /** Deterministic per-domain disambiguator for a slug collision. */
1911
+ slugSuffix(domain) {
1912
+ return createHash("sha1").update(domain).digest("hex").slice(0, 8);
1913
+ }
1914
+ /** Every `server_name` token declared in a vhost body. */
1915
+ serverNamesIn(conf) {
1916
+ const names = [];
1917
+ for (const m of conf.matchAll(/server_name\s+([^;]+);/g)) {
1918
+ for (const tok of m[1].trim().split(/\s+/)) {
1919
+ if (tok) names.push(tok.toLowerCase().replace(/\.$/, ""));
1920
+ }
1921
+ }
1922
+ return names;
1923
+ }
1924
+ /**
1925
+ * The slug THIS domain's files live under.
1926
+ *
1927
+ * `domainSlug` folds every non-alphanumeric to `-`, so a dot and a literal dash
1928
+ * collapse together and two different, separately-claimable hostnames land on
1929
+ * one filename:
1930
+ *
1931
+ * staging.app.example.com -> staging-app-example-com
1932
+ * staging-app.example.com -> staging-app-example-com
1933
+ *
1934
+ * Both are ordinary hostnames and can belong to different projects — even
1935
+ * different orgs, since one edge fronts the whole box. Whichever registered last
1936
+ * silently overwrote the other's vhost, and the loser's traffic fell through to
1937
+ * `default_server`: a silent outage of somebody else's domain. `removeRoute` on
1938
+ * either one deleted the shared file and took out both.
1939
+ *
1940
+ * Resolution, in this order, and the order is the whole correctness argument:
1941
+ *
1942
+ * 1. A disambiguated file for this domain already exists → keep using it.
1943
+ * MUST come first. If the incumbent is later removed, step 3 would hand
1944
+ * this domain the now-free base name while its real conf stayed on disk
1945
+ * under the suffixed name — two files serving one hostname, which is the
1946
+ * exact orphan class this whole change exists to remove.
1947
+ * 2. The base file exists and does NOT list this domain in its `server_name`
1948
+ * → it is someone else's. Take a suffixed name; the incumbent is untouched.
1949
+ * 3. Otherwise the base name is ours (free, or already ours).
1950
+ *
1951
+ * Containment, not equality, in step 2 — and the reason is narrower than it
1952
+ * looks. Everything WE write carries exactly one `server_name` (see the server
1953
+ * blocks above), and a takeover fans out one `registerRoute` per hostname
1954
+ * (`registerImportedSites`), so no Kraft-managed conf ever lists two. The
1955
+ * only file that can is one the operator placed in `sites-enabled` themselves.
1956
+ *
1957
+ * For that file, reusing it is the lesser evil, not a free win: we rewrite it
1958
+ * with a single `server_name`, so a sibling hostname it also served stops being
1959
+ * served. The alternative is worse — a second vhost answering a name the first
1960
+ * one already claims makes OpenResty warn "conflicting server name" and silently
1961
+ * keep the FIRST, so the route we were asked to create would not take effect at
1962
+ * all. Losing the sibling is at least the outcome the caller asked for.
1963
+ */
1964
+ async resolveSlug(domain) {
1965
+ const base = this.domainSlug(domain);
1966
+ const suffixed = `${base}-${this.slugSuffix(domain)}`;
1967
+ const host = domain.toLowerCase().replace(/\.$/, "");
1968
+ if (await this._exists(join(this.sitesDir, `${suffixed}.conf`)).catch(
1969
+ () => false
1970
+ )) {
1971
+ return suffixed;
1972
+ }
1973
+ const basePath = join(this.sitesDir, `${base}.conf`);
1974
+ if (await this._exists(basePath).catch(() => false)) {
1975
+ const conf = await this._readFile(basePath).catch(() => "");
1976
+ if (conf) {
1977
+ const names = this.serverNamesIn(conf);
1978
+ if (names.length > 0 && !names.includes(host)) return suffixed;
1979
+ }
1980
+ }
1981
+ return base;
1982
+ }
1983
+ /**
1984
+ * Re-detect OpenResty paths and reload.
1985
+ *
1986
+ * Paths can become stale after an OpenResty reinstall (the binary or
1987
+ * config may move). Re-detecting on every reload keeps the provider
1988
+ * in sync without requiring an API restart.
1989
+ */
1990
+ async reload(opts = {}) {
1991
+ if (this.executor) {
1992
+ if (!this.pinPaths) {
1993
+ try {
1994
+ const freshPaths = await detectOpenRestyPaths(this.executor);
1995
+ this.paths = freshPaths;
1996
+ this.sitesDir = freshPaths.sitesDir;
1997
+ this.nginxVersion = freshPaths.nginxVersion ?? this.nginxVersion;
1998
+ this.reloadCommand = buildReloadCommand(freshPaths);
1999
+ } catch {
2000
+ }
2001
+ }
2002
+ if (this.containerEdge) {
2003
+ await this.executor.exec(this.reloadCommand, { timeout: 15e3 });
2004
+ } else {
2005
+ await reloadBareOpenResty(this.executor, this.paths, {
2006
+ allowStopped: opts.allowStopped ?? false
2007
+ });
2008
+ }
2009
+ return;
2010
+ }
2011
+ if (this.containerEdge) {
2012
+ await execFileAsync("sh", ["-lc", this.reloadCommand], {
2013
+ timeout: 15e3
2014
+ });
2015
+ return;
2016
+ }
2017
+ const localExecutor = {
2018
+ exec: async (command, execOpts) => {
2019
+ const { stdout } = await execFileAsync("sh", ["-lc", command], {
2020
+ timeout: execOpts?.timeout
2021
+ });
2022
+ return String(stdout);
2023
+ }
2024
+ };
2025
+ await reloadBareOpenResty(localExecutor, this.paths, {
2026
+ allowStopped: opts.allowStopped ?? false
2027
+ });
2028
+ }
2029
+ async certsExist(domain) {
2030
+ const certPath = join(this.certDir, domain, "fullchain.pem");
2031
+ if (this.executor) {
2032
+ return this.executor.exists(certPath);
2033
+ }
2034
+ try {
2035
+ await fsReadFile(certPath);
2036
+ return true;
2037
+ } catch {
2038
+ return false;
2039
+ }
2040
+ }
2041
+ /**
2042
+ * Read the on-disk cert and report its validity. Distinguishes three cases so
2043
+ * the caller never downgrades a healthy domain on a transient hiccup:
2044
+ * - file present + parses → verified, real expiry
2045
+ * - file ABSENT → not verified, reason "missing" (no cert yet)
2046
+ * - file present, unreadable/unparseable → not verified, reason "read_error"
2047
+ */
2048
+ async readCertInfo(domain) {
2049
+ if (!await this.certsExist(domain)) {
2050
+ return {
2051
+ domain,
2052
+ expiresAt: "",
2053
+ issuer: "certbot",
2054
+ verified: false,
2055
+ reason: "missing"
2056
+ };
2057
+ }
2058
+ let certPem;
2059
+ let keyPem;
2060
+ try {
2061
+ certPem = await this._readFile(
2062
+ join(this.certDir, domain, "fullchain.pem")
2063
+ );
2064
+ keyPem = await this._readFile(join(this.certDir, domain, "privkey.pem"));
2065
+ } catch {
2066
+ return {
2067
+ domain,
2068
+ expiresAt: "",
2069
+ issuer: "certbot",
2070
+ verified: false,
2071
+ reason: "read_error"
2072
+ };
2073
+ }
2074
+ const candidate = validateCertFor(
2075
+ domain,
2076
+ { certPem, keyPem },
2077
+ "on-disk certificate"
2078
+ );
2079
+ if (!candidate.cert) {
2080
+ console.warn(
2081
+ `[SSL] unusable cert on disk for ${domain}: ${candidate.reason}`
2082
+ );
2083
+ return {
2084
+ domain,
2085
+ expiresAt: "",
2086
+ issuer: "certbot",
2087
+ verified: false,
2088
+ reason: "invalid"
2089
+ };
2090
+ }
2091
+ return {
2092
+ domain,
2093
+ expiresAt: candidate.cert.expiresAt,
2094
+ issuer: candidate.cert.issuer || "certbot",
2095
+ verified: true
2096
+ };
2097
+ }
2098
+ /**
2099
+ * Read-only check that the Let's Encrypt cert is actually present + valid on
2100
+ * the serving host. No certbot, no config rewrite — safe to call any time
2101
+ * (deploy, "Recheck SSL" button).
2102
+ */
2103
+ async verifyCert(domain) {
2104
+ assertValidDomain(domain);
2105
+ return this.readCertInfo(domain);
2106
+ }
2107
+ // ── Rate Limiting ──────────────────────────────────────────────────
2108
+ /** Dedicated include dir for Kraft-managed OpenResty snippets. */
2109
+ get rateLimitIncludeDir() {
2110
+ return join(dirname(this.sitesDir), "kraft-includes");
2111
+ }
2112
+ /** Path to the managed rate-limit snippet inside the dedicated include dir. */
2113
+ get rateLimitConfPath() {
2114
+ return join(this.rateLimitIncludeDir, "ratelimit.conf");
2115
+ }
2116
+ /**
2117
+ * Apply rate limit configuration to OpenResty.
2118
+ *
2119
+ * Writes a `ratelimit.conf` snippet with geo whitelist, map, and
2120
+ * limit_req_zone directives. Ensures an `include` for it exists in
2121
+ * nginx.conf's http block. Then validates + reloads.
2122
+ *
2123
+ * Pass rps=0 to disable rate limiting entirely.
2124
+ */
2125
+ async applyRateLimit(config) {
2126
+ const confPath = this.rateLimitConfPath;
2127
+ const nginxConfPath = join(dirname(this.sitesDir), "nginx.conf");
2128
+ const snapshots = {
2129
+ nginx: await this._captureFile(nginxConfPath),
2130
+ current: await this._captureFile(confPath)
2131
+ };
2132
+ const geoEntries = renderGeoEntries(config.whitelist);
2133
+ const snippet = `# Auto-generated by Kraft - rate limit config
2134
+ # Edit via Settings > Rate Limiting in the dashboard
2135
+
2136
+ geo $whitelist {
2137
+ ${geoEntries.join("\n")}
2138
+ }
2139
+
2140
+ map $whitelist $limit_key {
2141
+ 1 "";
2142
+ 0 $binary_remote_addr;
2143
+ }
2144
+
2145
+ limit_req_zone $limit_key zone=global_limit:10m rate=${config.rps}r/s;
2146
+ limit_req zone=global_limit burst=${config.burst} nodelay;
2147
+ limit_req_status 429;
2148
+ `;
2149
+ try {
2150
+ await this.ensureRateLimitInclude();
2151
+ if (config.rps <= 0) {
2152
+ await this._rm(confPath);
2153
+ } else {
2154
+ await this._mkdir(this.rateLimitIncludeDir);
2155
+ await this._writeFile(confPath, snippet);
2156
+ }
2157
+ await this.reload();
2158
+ } catch (err) {
2159
+ let rollbackError = null;
2160
+ try {
2161
+ await this._restoreFile(confPath, snapshots.current);
2162
+ await this._restoreFile(nginxConfPath, snapshots.nginx);
2163
+ await this.reload();
2164
+ } catch (restoreErr) {
2165
+ rollbackError = safeErrorMessage(restoreErr);
2166
+ }
2167
+ const message = safeErrorMessage(err);
2168
+ if (rollbackError) {
2169
+ throw new Error(`${message}; rollback failed: ${rollbackError}`);
2170
+ }
2171
+ throw err;
2172
+ }
2173
+ }
2174
+ /**
2175
+ * Read the current rate limit config from the snippet file.
2176
+ * Returns a disabled config when the snippet doesn't exist yet.
2177
+ * Returns null only when the file exists but couldn't be read or parsed.
2178
+ */
2179
+ async getRateLimitConfig() {
2180
+ try {
2181
+ const confPath = this.rateLimitConfPath;
2182
+ if (!await this._exists(confPath)) {
2183
+ return { rps: 0, burst: 0, whitelist: [] };
2184
+ }
2185
+ const content = await this._readFile(confPath);
2186
+ if (content.includes("disabled"))
2187
+ return { rps: 0, burst: 0, whitelist: [] };
2188
+ const rpsMatch = content.match(/rate=(\d+)r\/s/);
2189
+ const rps = rpsMatch ? parseInt(rpsMatch[1], 10) : 50;
2190
+ const burstMatch = content.match(/burst=(\d+)/);
2191
+ const burst = burstMatch ? parseInt(burstMatch[1], 10) : 20;
2192
+ const whitelist = [];
2193
+ const geoBlock = content.match(/geo \$whitelist \{([\s\S]*?)\}/);
2194
+ if (geoBlock) {
2195
+ const lines = geoBlock[1].split("\n");
2196
+ for (const line of lines) {
2197
+ const m = line.match(/^\s+([\da-fA-F.:\/]+)\s+1;/);
2198
+ if (m && !["127.0.0.1/32", "::1/128"].includes(m[1]) && m[1] !== "default") {
2199
+ whitelist.push(m[1]);
2200
+ }
2201
+ }
2202
+ }
2203
+ return { rps, burst, whitelist };
2204
+ } catch {
2205
+ return null;
2206
+ }
2207
+ }
2208
+ /**
2209
+ * Ensure nginx.conf includes the rate limit snippet in its http block.
2210
+ */
2211
+ async ensureRateLimitInclude() {
2212
+ const confDir = dirname(this.sitesDir);
2213
+ const confPath = join(confDir, "nginx.conf");
2214
+ const desiredIncludeLine = `include ${this.rateLimitIncludeDir}/*.conf;`;
2215
+ const content = await this._readFile(confPath);
2216
+ const trailingNewline = content.endsWith("\n");
2217
+ const lines = content.split("\n");
2218
+ const nextLines = [];
2219
+ let foundDesiredInclude = false;
2220
+ let changed = false;
2221
+ await this._mkdir(this.rateLimitIncludeDir);
2222
+ for (const line of lines) {
2223
+ const trimmed = line.trim();
2224
+ if (trimmed === desiredIncludeLine) {
2225
+ if (foundDesiredInclude) {
2226
+ changed = true;
2227
+ continue;
2228
+ }
2229
+ foundDesiredInclude = true;
2230
+ }
2231
+ nextLines.push(line);
2232
+ }
2233
+ if (!foundDesiredInclude) {
2234
+ const httpIndex = nextLines.findIndex(
2235
+ (line) => /^\s*http\s*\{\s*$/.test(line)
2236
+ );
2237
+ if (httpIndex === -1) {
2238
+ throw new Error(
2239
+ `Failed to ensure rate-limit include: ${confPath} is missing an http block`
2240
+ );
2241
+ }
2242
+ const indent = nextLines[httpIndex].match(/^\s*/)?.[0] ?? "";
2243
+ nextLines.splice(httpIndex + 1, 0, `${indent} ${desiredIncludeLine}`);
2244
+ changed = true;
2245
+ }
2246
+ if (changed) {
2247
+ const nextContent = nextLines.join("\n");
2248
+ await this._writeFile(
2249
+ confPath,
2250
+ trailingNewline ? `${nextContent}
2251
+ ` : nextContent
2252
+ );
2253
+ }
2254
+ }
2255
+ };
2256
+
2257
+ export {
2258
+ containerCommand,
2259
+ readMaybeInContainer,
2260
+ edgeContainerExecutor,
2261
+ validateCertFor,
2262
+ isSafeCertPath,
2263
+ readDeclaredPair,
2264
+ VHOST_GENERATION,
2265
+ readVhostGeneration,
2266
+ renderProxyOptions,
2267
+ renderProxyTlsOptions,
2268
+ renderGeoEntries,
2269
+ acmeKeyArgs,
2270
+ assertValidChallengeToken,
2271
+ MANAGED_STATIC_BASE,
2272
+ isAcmePortBindFailure,
2273
+ summarizeCertbotFailure,
2274
+ NginxProvider
2275
+ };