@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.
- package/README.md +263 -0
- package/dist/bare-HL2GUYE2.js +27 -0
- package/dist/bowser-HNOTPGJO.js +2825 -0
- package/dist/chunk-3CJLKL6E.js +124 -0
- package/dist/chunk-3NQQXMDR.js +2098 -0
- package/dist/chunk-42WQABXM.js +439 -0
- package/dist/chunk-5GPXMTE4.js +25 -0
- package/dist/chunk-AJ4QZLZ2.js +1069 -0
- package/dist/chunk-AO252K7Z.js +137 -0
- package/dist/chunk-BQMFIXHI.js +975 -0
- package/dist/chunk-DM3PKWDC.js +2453 -0
- package/dist/chunk-DOD2BXHQ.js +1364 -0
- package/dist/chunk-DRK3MEDS.js +2870 -0
- package/dist/chunk-EW4PHKB2.js +13914 -0
- package/dist/chunk-HMJUFZK7.js +340 -0
- package/dist/chunk-I2TOOF2A.js +517 -0
- package/dist/chunk-I3DPMTSF.js +29 -0
- package/dist/chunk-JSCZT76F.js +2588 -0
- package/dist/chunk-MNV2QFHW.js +138 -0
- package/dist/chunk-MOLPYPVH.js +2529 -0
- package/dist/chunk-N4OALTFK.js +295 -0
- package/dist/chunk-OFXBNFNL.js +458 -0
- package/dist/chunk-ONKEYSMY.js +1388 -0
- package/dist/chunk-PDW4L3NV.js +17 -0
- package/dist/chunk-PNNXWA2A.js +1947 -0
- package/dist/chunk-PNRPKRJU.js +11105 -0
- package/dist/chunk-PVOWHIND.js +26781 -0
- package/dist/chunk-PXKCZB2I.js +46 -0
- package/dist/chunk-PZVKPCKE.js +475 -0
- package/dist/chunk-QWHBRY2K.js +4945 -0
- package/dist/chunk-RFOBMHQ7.js +338 -0
- package/dist/chunk-RHLNVZMS.js +28 -0
- package/dist/chunk-RVER2EOO.js +577 -0
- package/dist/chunk-S3QBGGVP.js +81 -0
- package/dist/chunk-TAML5WMA.js +198 -0
- package/dist/chunk-THTEUDBC.js +3188 -0
- package/dist/chunk-WYNM7L22.js +329 -0
- package/dist/chunk-XGCG4MD5.js +102 -0
- package/dist/chunk-XVKUFMIB.js +148 -0
- package/dist/chunk-XZYFZ6A5.js +50 -0
- package/dist/chunk-Y7PVJUXJ.js +2275 -0
- package/dist/chunk-YRSZPVMB.js +306 -0
- package/dist/chunk-YZUKHG3S.js +362 -0
- package/dist/chunk-ZVFS4LUA.js +116 -0
- package/dist/chunk-ZWLDLTJP.js +1417 -0
- package/dist/detect-676SNZK4.js +48 -0
- package/dist/dist-es-2YRDXMUU.js +73 -0
- package/dist/dist-es-CCVZEFRZ.js +91 -0
- package/dist/dist-es-EDUSMF6Q.js +488 -0
- package/dist/dist-es-IOH6G4GM.js +380 -0
- package/dist/dist-es-KWSMDPIP.js +26 -0
- package/dist/dist-es-UYDDOVZ3.js +173 -0
- package/dist/dist-es-XUOR4NQN.js +321 -0
- package/dist/docker-GOT3Q7DJ.js +41 -0
- package/dist/docker-edge-executor-VO6JHHW2.js +19 -0
- package/dist/edge-import-NFCHIW7G.js +50 -0
- package/dist/edge-preflight-QUJ757C6.js +45 -0
- package/dist/ensure-container-edge-Q7IZXHGM.js +38 -0
- package/dist/event-streams-TR4GAC53.js +46 -0
- package/dist/executor-GKXKN52K.js +42 -0
- package/dist/host-channel-preflight-Z6HYSPKO.js +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9469 -0
- package/dist/loadSso-UGF4JMRK.js +581 -0
- package/dist/local-executor-OMMDKXCM.js +13 -0
- package/dist/nginx-VITTWZZU.js +42 -0
- package/dist/node-bootstrap.js +277 -0
- package/dist/node-entry.js +55 -0
- package/dist/noop-CLLJHCPL.js +11 -0
- package/dist/openresty-lua-6I3Z6PEQ.js +90 -0
- package/dist/privilege-UQAI2HV4.js +20 -0
- package/dist/server/engine/ChangeLog +2289 -0
- package/dist/server/engine/Documentations +5 -0
- package/dist/server/engine/LICENSE +674 -0
- package/dist/server/engine/README.md +34 -0
- package/dist/server/engine/conf/amavisd +150 -0
- package/dist/server/engine/conf/clamav +71 -0
- package/dist/server/engine/conf/core +632 -0
- package/dist/server/engine/conf/dovecot +151 -0
- package/dist/server/engine/conf/fail2ban +72 -0
- package/dist/server/engine/conf/global +698 -0
- package/dist/server/engine/conf/iredapd +52 -0
- package/dist/server/engine/conf/logwatch +28 -0
- package/dist/server/engine/conf/postfix +97 -0
- package/dist/server/engine/conf/postgresql +93 -0
- package/dist/server/engine/conf/spamassassin +39 -0
- package/dist/server/engine/dialog/config_via_dialog.sh +258 -0
- package/dist/server/engine/dialog/optional_components.sh +122 -0
- package/dist/server/engine/dialog/pgsql_config.sh +52 -0
- package/dist/server/engine/dialog/virtual_domain_config.sh +94 -0
- package/dist/server/engine/functions/amavisd.sh +267 -0
- package/dist/server/engine/functions/backend.sh +85 -0
- package/dist/server/engine/functions/clamav.sh +100 -0
- package/dist/server/engine/functions/cleanup.sh +393 -0
- package/dist/server/engine/functions/dovecot.sh +583 -0
- package/dist/server/engine/functions/fail2ban.sh +209 -0
- package/dist/server/engine/functions/iredapd.sh +356 -0
- package/dist/server/engine/functions/optional_components.sh +24 -0
- package/dist/server/engine/functions/packages.sh +522 -0
- package/dist/server/engine/functions/packages_freebsd.sh +179 -0
- package/dist/server/engine/functions/postfix.sh +457 -0
- package/dist/server/engine/functions/postgresql.sh +239 -0
- package/dist/server/engine/functions/spamassassin.sh +49 -0
- package/dist/server/engine/functions/system_accounts.sh +128 -0
- package/dist/server/engine/iRedMail.sh +144 -0
- package/dist/server/engine/pkgs/get_all.sh +338 -0
- package/dist/server/engine/pkgs/pkgs.freebsd.sha256 +3 -0
- package/dist/server/engine/pkgs/pkgs.openbsd.sha256 +6 -0
- package/dist/server/engine/pkgs/pkgs.sha256 +1 -0
- package/dist/server/engine/samples/amavisd/amavisd.conf +702 -0
- package/dist/server/engine/samples/amavisd/amavisd.mysql +278 -0
- package/dist/server/engine/samples/amavisd/amavisd.pgsql +253 -0
- package/dist/server/engine/samples/amavisd/amavisd.schema +942 -0
- package/dist/server/engine/samples/amavisd/default_spam_policy.sql +46 -0
- package/dist/server/engine/samples/dovecot/dovecot-2.4-mariadb.conf +758 -0
- package/dist/server/engine/samples/dovecot/dovecot-2.4-openldap.conf +767 -0
- package/dist/server/engine/samples/dovecot/dovecot-2.4-pgsql.conf +762 -0
- package/dist/server/engine/samples/dovecot/dovecot-last-login.conf +50 -0
- package/dist/server/engine/samples/dovecot/dovecot-ldap.conf +21 -0
- package/dist/server/engine/samples/dovecot/dovecot-share-folder.conf +22 -0
- package/dist/server/engine/samples/dovecot/dovecot-sql.conf +28 -0
- package/dist/server/engine/samples/dovecot/dovecot-used-quota.conf +13 -0
- package/dist/server/engine/samples/dovecot/dovecot.conf +484 -0
- package/dist/server/engine/samples/dovecot/dovecot.sieve +7 -0
- package/dist/server/engine/samples/dovecot/dovecot2-quota-warning.sh +38 -0
- package/dist/server/engine/samples/dovecot/sql/imap_share_folder.mysql +16 -0
- package/dist/server/engine/samples/dovecot/sql/last_login.mysql +12 -0
- package/dist/server/engine/samples/dovecot/sql/used_quota.mysql +26 -0
- package/dist/server/engine/samples/dovecot/systemd/override.conf +3 -0
- package/dist/server/engine/samples/fail2ban/action.d/banned_db.conf +9 -0
- package/dist/server/engine/samples/fail2ban/bin/fail2ban_banned_db +268 -0
- package/dist/server/engine/samples/fail2ban/fail2ban.local +17 -0
- package/dist/server/engine/samples/fail2ban/filter.d/dovecot.iredmail.conf +5 -0
- package/dist/server/engine/samples/fail2ban/filter.d/postfix-pregreet.iredmail.conf +16 -0
- package/dist/server/engine/samples/fail2ban/filter.d/postfix.iredmail.conf +18 -0
- package/dist/server/engine/samples/fail2ban/filter.d/roundcube.iredmail.conf +9 -0
- package/dist/server/engine/samples/fail2ban/jail.d/dovecot.local +8 -0
- package/dist/server/engine/samples/fail2ban/jail.d/nginx-http-auth.local +8 -0
- package/dist/server/engine/samples/fail2ban/jail.d/postfix-pregreet.local +9 -0
- package/dist/server/engine/samples/fail2ban/jail.d/postfix.local +8 -0
- package/dist/server/engine/samples/fail2ban/jail.d/roundcube.local +9 -0
- package/dist/server/engine/samples/fail2ban/jail.d/sogo.local +8 -0
- package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +11 -0
- package/dist/server/engine/samples/fail2ban/jail.local +6 -0
- package/dist/server/engine/samples/fail2ban/openbsd/rc +27 -0
- package/dist/server/engine/samples/fail2ban/sql/fail2ban.mysql +49 -0
- package/dist/server/engine/samples/fail2ban/sql/fail2ban.pgsql +48 -0
- package/dist/server/engine/samples/firewall/firewalld/services/ssh.xml +6 -0
- package/dist/server/engine/samples/firewall/firewalld/zones/iredmail.xml +15 -0
- package/dist/server/engine/samples/firewall/iptables/ip6tables.init.debian +362 -0
- package/dist/server/engine/samples/firewall/iptables/ip6tables.rules +53 -0
- package/dist/server/engine/samples/firewall/iptables/iptables.init.debian +363 -0
- package/dist/server/engine/samples/firewall/iptables/iptables.rules +54 -0
- package/dist/server/engine/samples/firewall/nftables.conf +58 -0
- package/dist/server/engine/samples/freebsd/newsyslog.conf.d/dovecot.conf +3 -0
- package/dist/server/engine/samples/freebsd/newsyslog.conf.d/iredapd.conf +1 -0
- package/dist/server/engine/samples/freebsd/newsyslog.conf.d/mlmmjadmin.conf +1 -0
- package/dist/server/engine/samples/freebsd/newsyslog.conf.d/php-fpm.conf +2 -0
- package/dist/server/engine/samples/freebsd/newsyslog.conf.d/slapd.conf +1 -0
- package/dist/server/engine/samples/freebsd/syslog.d/dovecot.conf +2 -0
- package/dist/server/engine/samples/freebsd/syslog.d/fail2ban.conf +2 -0
- package/dist/server/engine/samples/freebsd/syslog.d/iredapd.conf +2 -0
- package/dist/server/engine/samples/freebsd/syslog.d/mlmmjadmin.conf +2 -0
- package/dist/server/engine/samples/freebsd/syslog.d/php-fpm.conf +2 -0
- package/dist/server/engine/samples/freebsd/syslog.d/slapd.conf +2 -0
- package/dist/server/engine/samples/iredmail/iredmail.mysql +491 -0
- package/dist/server/engine/samples/iredmail/iredmail.pgsql +465 -0
- package/dist/server/engine/samples/iredmail/iredmail.schema +456 -0
- package/dist/server/engine/samples/iredmail/used_quota_triggers_dovecot_2.3.pgsql +40 -0
- package/dist/server/engine/samples/iredmail/used_quota_triggers_dovecot_2.4.pgsql +18 -0
- package/dist/server/engine/samples/logrotate/dovecot +13 -0
- package/dist/server/engine/samples/logrotate/iredapd +13 -0
- package/dist/server/engine/samples/logrotate/mlmmjadmin +13 -0
- package/dist/server/engine/samples/logrotate/openldap +19 -0
- package/dist/server/engine/samples/logrotate/php-fpm +13 -0
- package/dist/server/engine/samples/openbsd/ldapd.conf +58 -0
- package/dist/server/engine/samples/openbsd/pf.conf +28 -0
- package/dist/server/engine/samples/postfix/command_filter.pcre +3 -0
- package/dist/server/engine/samples/postfix/freebsd/mailer.conf +9 -0
- package/dist/server/engine/samples/postfix/helo_access.pcre +180 -0
- package/dist/server/engine/samples/postfix/ldap/catchall_maps.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/recipient_bcc_maps_domain.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/recipient_bcc_maps_user.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/relay_domains.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/sender_bcc_maps_domain.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/sender_bcc_maps_user.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/sender_dependent_relayhost_maps_domain.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/sender_dependent_relayhost_maps_user.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/sender_login_maps.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/transport_maps_domain.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/transport_maps_user.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/virtual_alias_maps.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/virtual_group_maps.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/virtual_group_members_maps.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/virtual_mailbox_domains.cf +11 -0
- package/dist/server/engine/samples/postfix/ldap/virtual_mailbox_maps.cf +12 -0
- package/dist/server/engine/samples/postfix/main.cf +282 -0
- package/dist/server/engine/samples/postfix/main.cf.amavisd +7 -0
- package/dist/server/engine/samples/postfix/main.cf.dovecot +8 -0
- package/dist/server/engine/samples/postfix/main.cf.ldap +39 -0
- package/dist/server/engine/samples/postfix/main.cf.mlmmj +5 -0
- package/dist/server/engine/samples/postfix/main.cf.mysql +39 -0
- package/dist/server/engine/samples/postfix/main.cf.pgsql +39 -0
- package/dist/server/engine/samples/postfix/main.cf.postscreen +30 -0
- package/dist/server/engine/samples/postfix/master.cf +101 -0
- package/dist/server/engine/samples/postfix/mysql/catchall_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/domain_alias_catchall_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/domain_alias_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/recipient_bcc_maps_domain.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/recipient_bcc_maps_user.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/relay_domains.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/sender_bcc_maps_domain.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/sender_bcc_maps_user.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/sender_dependent_relayhost_maps.cf +6 -0
- package/dist/server/engine/samples/postfix/mysql/sender_login_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/transport_maps_domain.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/transport_maps_maillist.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/transport_maps_user.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/virtual_alias_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/virtual_mailbox_domains.cf +5 -0
- package/dist/server/engine/samples/postfix/mysql/virtual_mailbox_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/catchall_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/domain_alias_catchall_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/domain_alias_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/recipient_bcc_maps_domain.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/recipient_bcc_maps_user.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/relay_domains.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/sender_bcc_maps_domain.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/sender_bcc_maps_user.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/sender_dependent_relayhost_maps.cf +6 -0
- package/dist/server/engine/samples/postfix/pgsql/sender_login_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/transport_maps_domain.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/transport_maps_maillist.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/transport_maps_user.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/virtual_alias_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/virtual_mailbox_domains.cf +5 -0
- package/dist/server/engine/samples/postfix/pgsql/virtual_mailbox_maps.cf +5 -0
- package/dist/server/engine/samples/postfix/postscreen_access.cidr +6 -0
- package/dist/server/engine/samples/postgresql/sql/add_first_domain_and_user.sql +34 -0
- package/dist/server/engine/samples/postgresql/sql/grant_permissions.sql +14 -0
- package/dist/server/engine/samples/postgresql/sql/init_vmail_db.sql +15 -0
- package/dist/server/engine/samples/rsyslog.d/1-iredmail-dovecot.conf +23 -0
- package/dist/server/engine/samples/rsyslog.d/1-iredmail-fail2ban.conf +2 -0
- package/dist/server/engine/samples/rsyslog.d/1-iredmail-iredapd.conf +12 -0
- package/dist/server/engine/samples/rsyslog.d/1-iredmail-mlmmjadmin.conf +12 -0
- package/dist/server/engine/samples/rsyslog.d/1-iredmail-openldap.conf +2 -0
- package/dist/server/engine/samples/rsyslog.d/1-iredmail-phpfpm.conf +3 -0
- package/dist/server/engine/samples/spamassassin/local.cf +186 -0
- package/dist/server/engine/samples/spamassassin/razor.conf +1 -0
- package/dist/server/engine/samples/systemd/clamd.service.d/override.conf +2 -0
- package/dist/server/engine/samples/systemd/mariadb.service.d/override.conf +3 -0
- package/dist/server/engine/samples/systemd/slapd.service.d/override.conf +6 -0
- package/dist/server/engine/samples/yum/CentOS-Linux-AppStream.repo +6 -0
- package/dist/server/engine/samples/yum/CentOS-Linux-PowerTools.repo +6 -0
- package/dist/server/engine/samples/yum/CentOS-Stream-AppStream.repo +7 -0
- package/dist/server/engine/samples/yum/CentOS-Stream-PowerTools.repo +7 -0
- package/dist/server/engine/samples/yum/symas-openldap.repo +6 -0
- package/dist/server/engine/tools/add_enabled_service.py +60 -0
- package/dist/server/engine/tools/backup_mysql.sh +238 -0
- package/dist/server/engine/tools/backup_openldap.sh +230 -0
- package/dist/server/engine/tools/backup_pgsql.sh +217 -0
- package/dist/server/engine/tools/backup_sogo.sh +156 -0
- package/dist/server/engine/tools/create_mail_user_OpenLDAP.py +335 -0
- package/dist/server/engine/tools/create_mail_user_OpenLDAP.sh +287 -0
- package/dist/server/engine/tools/create_mail_user_SQL.sh +127 -0
- package/dist/server/engine/tools/enable_postscreen.sh +144 -0
- package/dist/server/engine/tools/fail2ban_unban_ip.sh +39 -0
- package/dist/server/engine/tools/find_sasl_login_ip.sh +20 -0
- package/dist/server/engine/tools/find_top_sasl_usernames.sh +28 -0
- package/dist/server/engine/tools/generate_ssl_keys.sh +85 -0
- package/dist/server/engine/tools/kill_high_cpu_sogo_process.sh +32 -0
- package/dist/server/engine/tools/ldap_assign_user_to_groups.py +151 -0
- package/dist/server/engine/tools/ldap_move_members_to_another_group.py +129 -0
- package/dist/server/engine/tools/migrate_sql_alias_table.py +177 -0
- package/dist/server/engine/update/0.9.8/amavisd.mysql +29 -0
- package/dist/server/engine/update/0.9.8/amavisd.pgsql +39 -0
- package/dist/server/engine/update/0.9.8/iredmail.mysql +53 -0
- package/dist/server/engine/update/0.9.8/iredmail.pgsql +61 -0
- package/dist/server/engine/update/0.9.9/iredmail.mysql +2 -0
- package/dist/server/engine/update/0.9.9/iredmail.pgsql +2 -0
- package/dist/server/engine/update/1.0/iredmail.mysql +2 -0
- package/dist/server/engine/update/1.0/iredmail.pgsql +2 -0
- package/dist/server/engine/update/1.2/last_login.mysql +16 -0
- package/dist/server/engine/update/1.4.0/iredmail.mysql +22 -0
- package/dist/server/engine/update/1.4.0/iredmail.pgsql +20 -0
- package/dist/server/engine/update/1.4.1/iredmail.mysql +3 -0
- package/dist/server/engine/update/1.4.1/iredmail.pgsql +3 -0
- package/dist/server/engine/update/1.4.1/sogo.mysql +11 -0
- package/dist/server/engine/update/1.4.1/sogo.pgsql +30 -0
- package/dist/server/engine/update/1.4.1/update-ldap.py +66 -0
- package/dist/server/engine/update/1.4.2/iredmail.mysql +9 -0
- package/dist/server/engine/update/1.4.2/iredmail.pgsql +9 -0
- package/dist/server/engine/update/1.4.2/sogo.pgsql +30 -0
- package/dist/server/engine/update/1.6.0/sogo_view.pgsql +27 -0
- package/dist/server/engine/update/1.6.3/iredmail.mysql +1 -0
- package/dist/server/engine/update/1.7.0/fail2ban.mysql +11 -0
- package/dist/server/engine/update/1.7.0/fail2ban.pgsql +12 -0
- package/dist/server/engine/update/1.7.1/amavisd.mysql +1 -0
- package/dist/server/engine/update/1.7.2/vmail.mysql +30 -0
- package/dist/server/engine/update/1.7.3/deleted_mailboxes.mysql +43 -0
- package/dist/server/engine/update/1.7.3/iredadmin.mysql +26 -0
- package/dist/server/engine/update/1.7.3/vmail.mysql +79 -0
- package/dist/server/engine/update/1.7.3/vmail.pgsql +9 -0
- package/dist/server/engine/update/README.md +1 -0
- package/dist/server/engine/update/ldap/README.md +39 -0
- package/dist/server/engine/update/ldap/update-ldap-dovecot-2.3.py +69 -0
- package/dist/server/index.js +349016 -0
- package/dist/server/lua/geo_country.lua +127 -0
- package/dist/server/lua/maxminddb.lua +403 -0
- package/dist/server/lua/mgmt_api.lua +654 -0
- package/dist/server/lua/pipe_stream.lua +103 -0
- package/dist/server/lua/rules_guard.lua +132 -0
- package/dist/server/lua/rules_lib.lua +176 -0
- package/dist/server/lua/site_logger.lua +405 -0
- package/dist/server/lua/webhook_handler.lua +126 -0
- package/dist/server/migrations/0000_init.sql +666 -0
- package/dist/server/migrations/0001_brief_tomorrow_man.sql +47 -0
- package/dist/server/migrations/0002_funny_dazzler.sql +2 -0
- package/dist/server/migrations/0003_third_killer_shrike.sql +1 -0
- package/dist/server/migrations/0004_session_active_org_not_null.sql +10 -0
- package/dist/server/migrations/0005_warm_amazoness.sql +3 -0
- package/dist/server/migrations/0006_lumpy_joystick.sql +1 -0
- package/dist/server/migrations/0007_outgoing_maginty.sql +2 -0
- package/dist/server/migrations/0008_frozen_cutover.sql +3 -0
- package/dist/server/migrations/0009_credits_billing.sql +142 -0
- package/dist/server/migrations/0010_credit_grant_dedupe.sql +14 -0
- package/dist/server/migrations/0011_remove_credit_ledger.sql +23 -0
- package/dist/server/migrations/0012_smart_deploys_and_git_rollback.sql +81 -0
- package/dist/server/migrations/0013_fix_check_run_rollup_unique.sql +38 -0
- package/dist/server/migrations/0014_default_rollback_git.sql +9 -0
- package/dist/server/migrations/0015_billing_idempotency_grants.sql +41 -0
- package/dist/server/migrations/0016_github_auth_high_findings.sql +30 -0
- package/dist/server/migrations/0017_spooky_rumiko_fujikawa.sql +6 -0
- package/dist/server/migrations/0018_calm_shatterstar.sql +1 -0
- package/dist/server/migrations/0019_rename_workspace_install_to_prepare.sql +1 -0
- package/dist/server/migrations/0020_uneven_the_renegades.sql +13 -0
- package/dist/server/migrations/0021_shiny_firestar.sql +2 -0
- package/dist/server/migrations/0022_version_on_success_backfill.sql +6 -0
- package/dist/server/migrations/0023_productive_gorilla_man.sql +17 -0
- package/dist/server/migrations/0024_sleepy_peter_quill.sql +2 -0
- package/dist/server/migrations/0025_smooth_blue_shield.sql +1 -0
- package/dist/server/migrations/0026_mail_backup_source.sql +11 -0
- package/dist/server/migrations/0027_opposite_anthem.sql +6 -0
- package/dist/server/migrations/0028_stale_rogue.sql +18 -0
- package/dist/server/migrations/0029_bouncy_loners.sql +16 -0
- package/dist/server/migrations/0030_bright_darkstar.sql +6 -0
- package/dist/server/migrations/0031_conscious_catseye.sql +13 -0
- package/dist/server/migrations/0032_wet_mimic.sql +51 -0
- package/dist/server/migrations/0033_eminent_molly_hayes.sql +2 -0
- package/dist/server/migrations/0034_nervous_surge.sql +1 -0
- package/dist/server/migrations/0035_thankful_morgan_stark.sql +1 -0
- package/dist/server/migrations/0036_gorgeous_scalphunter.sql +17 -0
- package/dist/server/migrations/0037_clever_makkari.sql +28 -0
- package/dist/server/migrations/0038_young_magneto.sql +1 -0
- package/dist/server/migrations/0039_secret_lady_ursula.sql +15 -0
- package/dist/server/migrations/0040_sharp_red_wolf.sql +16 -0
- package/dist/server/migrations/0041_round_spyke.sql +4 -0
- package/dist/server/migrations/0042_dapper_havok.sql +1 -0
- package/dist/server/migrations/0043_fast_sharon_ventura.sql +15 -0
- package/dist/server/migrations/0044_amused_shape.sql +8 -0
- package/dist/server/migrations/0045_amusing_korath.sql +1 -0
- package/dist/server/migrations/0046_white_harrier.sql +35 -0
- package/dist/server/migrations/0047_sloppy_strong_guy.sql +2 -0
- package/dist/server/migrations/0048_funny_emma_frost.sql +2 -0
- package/dist/server/migrations/0049_nappy_piledriver.sql +3 -0
- package/dist/server/migrations/0050_add_service_deployment_image_digest.sql +1 -0
- package/dist/server/migrations/0051_add_update_status.sql +19 -0
- package/dist/server/migrations/0052_add_notice_target.sql +2 -0
- package/dist/server/migrations/0053_colossal_sandman.sql +14 -0
- package/dist/server/migrations/0054_add_instance_smtp.sql +5 -0
- package/dist/server/migrations/0055_rainy_klaw.sql +22 -0
- package/dist/server/migrations/0056_moaning_daredevil.sql +2 -0
- package/dist/server/migrations/0057_complete_prism.sql +18 -0
- package/dist/server/migrations/0058_add_server_is_local.sql +1 -0
- package/dist/server/migrations/0059_legal_fixer.sql +18 -0
- package/dist/server/migrations/0060_add_route_strategy_host_port.sql +3 -0
- package/dist/server/migrations/0061_notification_default_multi_kind.sql +3 -0
- package/dist/server/migrations/0062_add_incoming_webhook.sql +21 -0
- package/dist/server/migrations/0063_add_migration_logs.sql +1 -0
- package/dist/server/migrations/0064_add_migration_partial.sql +2 -0
- package/dist/server/migrations/0065_custom_app_template.sql +14 -0
- package/dist/server/migrations/0066_ensure_migration_partial_columns.sql +2 -0
- package/dist/server/migrations/0067_webhook_delivery.sql +28 -0
- package/dist/server/migrations/0068_add_migration_target_volumes.sql +1 -0
- package/dist/server/migrations/0069_add_project_composite_routes.sql +1 -0
- package/dist/server/migrations/0070_forward_git_to_server.sql +1 -0
- package/dist/server/migrations/0071_gh_device_token.sql +3 -0
- package/dist/server/migrations/0072_gh_device_token_method.sql +1 -0
- package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
- package/dist/server/migrations/0074_project_compose_path.sql +1 -0
- package/dist/server/migrations/0075_service_command_argv.sql +1 -0
- package/dist/server/migrations/0076_rollback_retention.sql +17 -0
- package/dist/server/migrations/0077_domain_redirect.sql +13 -0
- package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
- package/dist/server/migrations/0079_project_readiness.sql +18 -0
- package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
- package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
- package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
- package/dist/server/migrations/0083_service_incident.sql +84 -0
- package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
- package/dist/server/migrations/0085_resource_usage.sql +68 -0
- package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
- package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
- package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
- package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
- package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
- package/dist/server/migrations/0091_project_server_id.sql +26 -0
- package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
- package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
- package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
- package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
- package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
- package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
- package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
- package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
- package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
- package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
- package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
- package/dist/server/migrations/0103_dns_credential.sql +41 -0
- package/dist/server/migrations/0104_instance_host_control.sql +15 -0
- package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
- package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
- package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
- package/dist/server/migrations/0108_credential.sql +68 -0
- package/dist/server/migrations/0109_service_build_args.sql +9 -0
- package/dist/server/migrations/0110_service_deployment_host_ports.sql +18 -0
- package/dist/server/migrations/0111_host_port_claim.sql +381 -0
- package/dist/server/migrations/0112_backup_run_execution_lease.sql +12 -0
- package/dist/server/migrations/0113_docker_migration_execution_lease.sql +11 -0
- package/dist/server/migrations/0114_build_session_execution_lease_index.sql +6 -0
- package/dist/server/migrations/0115_orphan_target_key.sql +3 -0
- package/dist/server/migrations/0116_domain_ssl_challenge.sql +2 -0
- package/dist/server/migrations/0117_github_installation_workspace_scope.sql +24 -0
- package/dist/server/migrations/0118_git_sources.sql +66 -0
- package/dist/server/migrations/0119_backup_run_batch.sql +8 -0
- package/dist/server/migrations/0120_domain_primary_unique.sql +24 -0
- package/dist/server/migrations/0121_host_port_claim_overlap.sql +14 -0
- package/dist/server/migrations/0122_data_transfer_staging.sql +45 -0
- package/dist/server/migrations/0123_drop_cloud_infra.sql +7 -0
- package/dist/server/migrations/0124_mail_dmarc_report.sql +23 -0
- package/dist/server/migrations/0125_server_security_snapshot.sql +22 -0
- package/dist/server/migrations/meta/0000_snapshot.json +4848 -0
- package/dist/server/migrations/meta/0001_snapshot.json +4991 -0
- package/dist/server/migrations/meta/0002_snapshot.json +5013 -0
- package/dist/server/migrations/meta/0003_snapshot.json +5020 -0
- package/dist/server/migrations/meta/0004_snapshot.json +5020 -0
- package/dist/server/migrations/meta/0005_snapshot.json +5039 -0
- package/dist/server/migrations/meta/0006_snapshot.json +5045 -0
- package/dist/server/migrations/meta/0007_snapshot.json +5057 -0
- package/dist/server/migrations/meta/0016_snapshot.json +6053 -0
- package/dist/server/migrations/meta/0017_snapshot.json +6091 -0
- package/dist/server/migrations/meta/0018_snapshot.json +6098 -0
- package/dist/server/migrations/meta/0019_snapshot.json +6098 -0
- package/dist/server/migrations/meta/0020_snapshot.json +6201 -0
- package/dist/server/migrations/meta/0021_snapshot.json +6213 -0
- package/dist/server/migrations/meta/0022_snapshot.json +6213 -0
- package/dist/server/migrations/meta/0023_snapshot.json +6331 -0
- package/dist/server/migrations/meta/0024_snapshot.json +6343 -0
- package/dist/server/migrations/meta/0025_snapshot.json +6350 -0
- package/dist/server/migrations/meta/0026_snapshot.json +6440 -0
- package/dist/server/migrations/meta/0027_snapshot.json +6447 -0
- package/dist/server/migrations/meta/0028_snapshot.json +6581 -0
- package/dist/server/migrations/meta/0029_snapshot.json +6710 -0
- package/dist/server/migrations/meta/0030_snapshot.json +6748 -0
- package/dist/server/migrations/meta/0031_snapshot.json +6859 -0
- package/dist/server/migrations/meta/0032_snapshot.json +7222 -0
- package/dist/server/migrations/meta/0033_snapshot.json +7249 -0
- package/dist/server/migrations/meta/0034_snapshot.json +7255 -0
- package/dist/server/migrations/meta/0035_snapshot.json +7262 -0
- package/dist/server/migrations/meta/0036_snapshot.json +7392 -0
- package/dist/server/migrations/meta/0037_snapshot.json +7602 -0
- package/dist/server/migrations/meta/0038_snapshot.json +7609 -0
- package/dist/server/migrations/meta/0039_snapshot.json +7714 -0
- package/dist/server/migrations/meta/0040_snapshot.json +7820 -0
- package/dist/server/migrations/meta/0041_snapshot.json +7833 -0
- package/dist/server/migrations/meta/0042_snapshot.json +7839 -0
- package/dist/server/migrations/meta/0043_snapshot.json +7938 -0
- package/dist/server/migrations/meta/0044_snapshot.json +7982 -0
- package/dist/server/migrations/meta/0045_snapshot.json +7989 -0
- package/dist/server/migrations/meta/0046_snapshot.json +8263 -0
- package/dist/server/migrations/meta/0047_snapshot.json +8275 -0
- package/dist/server/migrations/meta/0048_snapshot.json +8289 -0
- package/dist/server/migrations/meta/0049_snapshot.json +8308 -0
- package/dist/server/migrations/meta/0050_snapshot.json +8314 -0
- package/dist/server/migrations/meta/0051_snapshot.json +8460 -0
- package/dist/server/migrations/meta/0052_snapshot.json +8472 -0
- package/dist/server/migrations/meta/0053_snapshot.json +8556 -0
- package/dist/server/migrations/meta/0054_snapshot.json +8586 -0
- package/dist/server/migrations/meta/0055_snapshot.json +8756 -0
- package/dist/server/migrations/meta/0056_snapshot.json +8777 -0
- package/dist/server/migrations/meta/0057_snapshot.json +8904 -0
- package/dist/server/migrations/meta/0058_snapshot.json +8911 -0
- package/dist/server/migrations/meta/0059_snapshot.json +9061 -0
- package/dist/server/migrations/meta/0060_snapshot.json +9081 -0
- package/dist/server/migrations/meta/_journal.json +888 -0
- package/dist/server/pglite/pglite.data +0 -0
- package/dist/server/pglite/pglite.wasm +0 -0
- package/dist/setup-JWKZIECM.js +25 -0
- package/dist/signin-3M3UPB6K.js +667 -0
- package/dist/ssh-client-NWQVRCV7.js +29 -0
- package/dist/sso-oidc-3VDIYOEJ.js +834 -0
- package/dist/sts-B3WIYLXR.js +1426 -0
- package/dist/up-P5DMQ5YO.js +55 -0
- 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
|
+
};
|