@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,2529 @@
|
|
|
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
|
+
probeTcpDetailed
|
|
7
|
+
} from "./chunk-ZVFS4LUA.js";
|
|
8
|
+
import {
|
|
9
|
+
getTarCreateEnv,
|
|
10
|
+
prepareSourceTarArgs
|
|
11
|
+
} from "./chunk-XGCG4MD5.js";
|
|
12
|
+
import {
|
|
13
|
+
connectSshClient,
|
|
14
|
+
openDockerDialStdioChannel,
|
|
15
|
+
openSftp,
|
|
16
|
+
openSshUnixSocket,
|
|
17
|
+
reconcileKnownHosts
|
|
18
|
+
} from "./chunk-HMJUFZK7.js";
|
|
19
|
+
import {
|
|
20
|
+
LocalExecutor
|
|
21
|
+
} from "./chunk-XVKUFMIB.js";
|
|
22
|
+
import {
|
|
23
|
+
HostChannelUnavailableError,
|
|
24
|
+
SshDisconnectedError,
|
|
25
|
+
SshExecRequestError,
|
|
26
|
+
commandForError,
|
|
27
|
+
isSshAuthError,
|
|
28
|
+
isSshExecRequestError
|
|
29
|
+
} from "./chunk-3CJLKL6E.js";
|
|
30
|
+
import {
|
|
31
|
+
emitBufferedLines,
|
|
32
|
+
flushBufferedLines,
|
|
33
|
+
hasLocalCommand,
|
|
34
|
+
logEntry,
|
|
35
|
+
sq
|
|
36
|
+
} from "./chunk-MNV2QFHW.js";
|
|
37
|
+
import {
|
|
38
|
+
HOST_CHANNEL_AUTH_REJECTED,
|
|
39
|
+
HOST_CHANNEL_NOT_PROVISIONED,
|
|
40
|
+
HOST_CHANNEL_UNPROVISIONED,
|
|
41
|
+
TRANSFER_EXCLUDES,
|
|
42
|
+
explainHostChannelCause,
|
|
43
|
+
formatBytes,
|
|
44
|
+
hostChannelAccount,
|
|
45
|
+
hostFirewallRule,
|
|
46
|
+
safeErrorMessage
|
|
47
|
+
} from "./chunk-PVOWHIND.js";
|
|
48
|
+
|
|
49
|
+
// ../../packages/adapters/src/system/executor.ts
|
|
50
|
+
import { existsSync, readFileSync } from "fs";
|
|
51
|
+
import { networkInterfaces } from "os";
|
|
52
|
+
|
|
53
|
+
// ../../packages/adapters/src/system/ssh-executor.ts
|
|
54
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
55
|
+
import { createReadStream } from "fs";
|
|
56
|
+
import { mkdtemp as mkdtemp2, rm as fsRm2, stat } from "fs/promises";
|
|
57
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
58
|
+
import { join as join2, posix } from "path";
|
|
59
|
+
|
|
60
|
+
// ../../packages/adapters/src/system/remote-transfer.ts
|
|
61
|
+
import { spawn } from "child_process";
|
|
62
|
+
import { createWriteStream } from "fs";
|
|
63
|
+
import {
|
|
64
|
+
chmod,
|
|
65
|
+
mkdtemp,
|
|
66
|
+
rm as fsRm,
|
|
67
|
+
writeFile as fsWriteFile
|
|
68
|
+
} from "fs/promises";
|
|
69
|
+
import { tmpdir } from "os";
|
|
70
|
+
import { join } from "path";
|
|
71
|
+
function packLocalArchive(tarArgs, outFile) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const tar = spawn("tar", tarArgs, {
|
|
74
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
75
|
+
env: getTarCreateEnv()
|
|
76
|
+
});
|
|
77
|
+
const out = createWriteStream(outFile);
|
|
78
|
+
let stderr = "";
|
|
79
|
+
let tarCode = null;
|
|
80
|
+
let tarClosed = false;
|
|
81
|
+
let outClosed = false;
|
|
82
|
+
const settle = () => {
|
|
83
|
+
if (!tarClosed || !outClosed) return;
|
|
84
|
+
if (tarCode === 0) resolve();
|
|
85
|
+
else
|
|
86
|
+
reject(
|
|
87
|
+
new Error(
|
|
88
|
+
`tar failed (exit ${tarCode})${stderr.trim() ? `: ${stderr.trim().slice(-300)}` : ""}`
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
tar.stderr.on("data", (d) => {
|
|
93
|
+
stderr += d.toString();
|
|
94
|
+
});
|
|
95
|
+
tar.on("error", reject);
|
|
96
|
+
out.on("error", reject);
|
|
97
|
+
tar.stdout.pipe(out);
|
|
98
|
+
tar.on("close", (code) => {
|
|
99
|
+
tarCode = code ?? 1;
|
|
100
|
+
tarClosed = true;
|
|
101
|
+
settle();
|
|
102
|
+
});
|
|
103
|
+
out.on("finish", () => {
|
|
104
|
+
outClosed = true;
|
|
105
|
+
settle();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function extractRemoteArchive(exec, remoteArchive, remotePath, expectedBytes, onLog) {
|
|
110
|
+
const received = Number((await exec(`wc -c < ${sq(remoteArchive)}`)).trim());
|
|
111
|
+
if (received !== expectedBytes) {
|
|
112
|
+
await exec(`rm -f ${sq(remoteArchive)}`).catch(() => {
|
|
113
|
+
});
|
|
114
|
+
throw new Error(
|
|
115
|
+
`upload truncated: sent ${expectedBytes} bytes, server received ${received}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
await exec(
|
|
119
|
+
`mkdir -p ${sq(remotePath)} && tar xzf ${sq(remoteArchive)} -C ${sq(remotePath)}; rc=$?; rm -f ${sq(remoteArchive)}; exit $rc`
|
|
120
|
+
);
|
|
121
|
+
onLog?.(
|
|
122
|
+
logEntry(
|
|
123
|
+
`Transferred ${formatBytes(expectedBytes)} and extracted on the server.`
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function formatPrivateKeyForOpenSsh(privateKey) {
|
|
128
|
+
const normalized = privateKey.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
129
|
+
return normalized.endsWith("\n") ? normalized : `${normalized}
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
async function canUseRemoteRsync(deps) {
|
|
133
|
+
if (deps.config.privateKey && deps.config.privateKeyPassphrase && !deps.config.sshAgent) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: "encrypted SSH keys without an agent are not supported by non-interactive rsync"
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const [localRsync, localSsh, localSshpass, remoteRsync] = await Promise.all([
|
|
140
|
+
hasLocalCommand("rsync"),
|
|
141
|
+
hasLocalCommand("ssh"),
|
|
142
|
+
deps.config.password ? hasLocalCommand("sshpass") : Promise.resolve(true),
|
|
143
|
+
deps.hasRemoteCommand("rsync")
|
|
144
|
+
]);
|
|
145
|
+
if (!localRsync) return { ok: false, reason: "local rsync is not installed" };
|
|
146
|
+
if (!localSsh) return { ok: false, reason: "local ssh is not installed" };
|
|
147
|
+
if (!localSshpass)
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: "local sshpass is not installed for password-based rsync"
|
|
151
|
+
};
|
|
152
|
+
if (!remoteRsync)
|
|
153
|
+
return { ok: false, reason: "remote rsync is not installed" };
|
|
154
|
+
return { ok: true };
|
|
155
|
+
}
|
|
156
|
+
function buildRsyncSshCommand(config, keyPath) {
|
|
157
|
+
const args = config.password ? [
|
|
158
|
+
"sshpass",
|
|
159
|
+
"-e",
|
|
160
|
+
"ssh",
|
|
161
|
+
"-p",
|
|
162
|
+
String(config.port ?? 22),
|
|
163
|
+
"-o",
|
|
164
|
+
"NumberOfPasswordPrompts=1",
|
|
165
|
+
"-o",
|
|
166
|
+
"PreferredAuthentications=password,keyboard-interactive",
|
|
167
|
+
"-o",
|
|
168
|
+
"PubkeyAuthentication=no",
|
|
169
|
+
"-o",
|
|
170
|
+
"StrictHostKeyChecking=accept-new"
|
|
171
|
+
] : [
|
|
172
|
+
"ssh",
|
|
173
|
+
"-p",
|
|
174
|
+
String(config.port ?? 22),
|
|
175
|
+
"-o",
|
|
176
|
+
"BatchMode=yes",
|
|
177
|
+
"-o",
|
|
178
|
+
"StrictHostKeyChecking=accept-new"
|
|
179
|
+
];
|
|
180
|
+
if (config.sshAgent) {
|
|
181
|
+
args.push("-A");
|
|
182
|
+
}
|
|
183
|
+
if (keyPath) {
|
|
184
|
+
args.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
|
|
185
|
+
}
|
|
186
|
+
return args.map(sq).join(" ");
|
|
187
|
+
}
|
|
188
|
+
async function withTemporaryPrivateKey(config, fn) {
|
|
189
|
+
if (!config.privateKey || config.sshAgent) {
|
|
190
|
+
return fn();
|
|
191
|
+
}
|
|
192
|
+
const tempDir = await mkdtemp(join(tmpdir(), "kraft-rsync-key-"));
|
|
193
|
+
const keyPath = join(tempDir, "id_rsa");
|
|
194
|
+
try {
|
|
195
|
+
await fsWriteFile(keyPath, formatPrivateKeyForOpenSsh(config.privateKey), {
|
|
196
|
+
mode: 384
|
|
197
|
+
});
|
|
198
|
+
await chmod(keyPath, 384);
|
|
199
|
+
return await fn(keyPath);
|
|
200
|
+
} finally {
|
|
201
|
+
await fsRm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function runRsync(config, args, onLog, cwd) {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const proc = spawn("rsync", args, {
|
|
208
|
+
cwd,
|
|
209
|
+
env: {
|
|
210
|
+
...getTarCreateEnv(),
|
|
211
|
+
...config.password ? { SSHPASS: config.password } : {}
|
|
212
|
+
},
|
|
213
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
214
|
+
});
|
|
215
|
+
const stdoutState = { partial: "" };
|
|
216
|
+
const stderrState = { partial: "" };
|
|
217
|
+
proc.stdout.on("data", (chunk) => {
|
|
218
|
+
emitBufferedLines(chunk, stdoutState, (line) => onLog?.(logEntry(line)));
|
|
219
|
+
});
|
|
220
|
+
proc.stderr.on("data", (chunk) => {
|
|
221
|
+
emitBufferedLines(chunk, stderrState, (line) => onLog?.(logEntry(line)));
|
|
222
|
+
});
|
|
223
|
+
proc.on("error", (err) => {
|
|
224
|
+
reject(new Error(`rsync failed to start: ${err.message}`));
|
|
225
|
+
});
|
|
226
|
+
proc.on("close", (code) => {
|
|
227
|
+
flushBufferedLines(stdoutState, (line) => onLog?.(logEntry(line)));
|
|
228
|
+
flushBufferedLines(stderrState, (line) => onLog?.(logEntry(line)));
|
|
229
|
+
resolve({ code: code ?? 1 });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
async function uploadFileWithRsync(localFile, remoteFile, deps, onLog, opts) {
|
|
234
|
+
await reconcileKnownHosts(deps.config);
|
|
235
|
+
const retries = Math.max(1, opts?.retries ?? 3);
|
|
236
|
+
const host = deps.config.host.includes(":") ? `[${deps.config.host}]` : deps.config.host;
|
|
237
|
+
const user = deps.config.username ?? "root";
|
|
238
|
+
const target = `${user}@${host}:${remoteFile}`;
|
|
239
|
+
await withTemporaryPrivateKey(deps.config, async (keyPath) => {
|
|
240
|
+
let lastCode = 1;
|
|
241
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
242
|
+
if (attempt > 1) {
|
|
243
|
+
onLog?.(
|
|
244
|
+
logEntry(
|
|
245
|
+
`Connection dropped \u2014 resuming upload (attempt ${attempt}/${retries})...`,
|
|
246
|
+
"warn"
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const args = [
|
|
251
|
+
"-a",
|
|
252
|
+
// already gzipped, so no `-z`
|
|
253
|
+
"--partial",
|
|
254
|
+
// keep the partial on interruption; delta-resume on re-run
|
|
255
|
+
"--progress",
|
|
256
|
+
"--timeout=60",
|
|
257
|
+
// abort if no I/O for 60s → surfaces a stall as a retryable exit
|
|
258
|
+
"-e",
|
|
259
|
+
buildRsyncSshCommand(deps.config, keyPath),
|
|
260
|
+
localFile,
|
|
261
|
+
target
|
|
262
|
+
];
|
|
263
|
+
const { code } = await runRsync(deps.config, args, onLog);
|
|
264
|
+
if (code === 0) return;
|
|
265
|
+
lastCode = code;
|
|
266
|
+
}
|
|
267
|
+
throw new Error(
|
|
268
|
+
`rsync upload failed after ${retries} attempt(s) (exit ${lastCode})`
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ../../packages/adapters/src/system/ssh-executor.ts
|
|
274
|
+
var remoteDirname = posix.dirname;
|
|
275
|
+
var SFTP_OPERATION_TIMEOUT_MS = 3e4;
|
|
276
|
+
var CHANNEL_CLOSE_GRACE_MS = 750;
|
|
277
|
+
var TRANSPORT_CLOSE_GRACE_MS = 250;
|
|
278
|
+
function tagExecRequestError(error) {
|
|
279
|
+
return error.message === "Unable to exec" ? new SshExecRequestError(error) : error;
|
|
280
|
+
}
|
|
281
|
+
function abortError(operation, signal) {
|
|
282
|
+
const reason = signal.reason;
|
|
283
|
+
const suffix = reason instanceof Error && reason.message ? `: ${reason.message}` : "";
|
|
284
|
+
const error = new Error(`SSH ${operation} cancelled${suffix}`);
|
|
285
|
+
error.name = "AbortError";
|
|
286
|
+
return error;
|
|
287
|
+
}
|
|
288
|
+
function clampWindow(value, fallback, min, max) {
|
|
289
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
290
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
291
|
+
}
|
|
292
|
+
var SshExecutor = class _SshExecutor {
|
|
293
|
+
client = null;
|
|
294
|
+
connecting = null;
|
|
295
|
+
/** One shared SFTP subsystem channel per client — see sftp(). */
|
|
296
|
+
sftpChannel = null;
|
|
297
|
+
sftpWrapper = null;
|
|
298
|
+
/** All requests cancelled on one shared channel join the same close/reset
|
|
299
|
+
* barrier, so the second listener cannot miss the first listener's `close`
|
|
300
|
+
* event and needlessly wait for the force-reset deadline. */
|
|
301
|
+
sftpQuiescence = /* @__PURE__ */ new WeakMap();
|
|
302
|
+
config;
|
|
303
|
+
/** Deployment cancellation belongs to an async call tree, not to this pooled
|
|
304
|
+
* executor instance. AsyncLocalStorage prevents one deployment from making
|
|
305
|
+
* unrelated users of the same SSH connection inherit its signal. */
|
|
306
|
+
abortScope = new AsyncLocalStorage();
|
|
307
|
+
/** Subscribers notified when the transport drops (see onDisconnect). */
|
|
308
|
+
disconnectListeners = /* @__PURE__ */ new Set();
|
|
309
|
+
/** In-flight cancelable ops — each entry aborts ONE exec/stream/SFTP request on a drop,
|
|
310
|
+
* so a dead channel fails fast instead of hanging to its command timeout. */
|
|
311
|
+
inflight = /* @__PURE__ */ new Set();
|
|
312
|
+
/** Reverse-forward handlers keyed by the remote bound port (see reverseForward). */
|
|
313
|
+
reverseHandlers = /* @__PURE__ */ new Map();
|
|
314
|
+
/** The client the single 'tcp connection' dispatcher is attached to (re-attached on reconnect). */
|
|
315
|
+
reverseListenerClient = null;
|
|
316
|
+
constructor(config) {
|
|
317
|
+
if (!config.privateKey && !config.sshAgent && !config.password) {
|
|
318
|
+
throw new Error("SSH requires one of privateKey, sshAgent, or password.");
|
|
319
|
+
}
|
|
320
|
+
this.config = config;
|
|
321
|
+
}
|
|
322
|
+
runWithAbortSignal(signal, fn) {
|
|
323
|
+
if (signal.aborted) return Promise.reject(abortError("operation", signal));
|
|
324
|
+
return this.abortScope.run(signal, async () => {
|
|
325
|
+
this.throwIfAborted("operation");
|
|
326
|
+
return fn();
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
operationSignal() {
|
|
330
|
+
return this.abortScope.getStore();
|
|
331
|
+
}
|
|
332
|
+
throwIfAborted(operation, signal = this.operationSignal()) {
|
|
333
|
+
if (signal?.aborted) throw abortError(operation, signal);
|
|
334
|
+
}
|
|
335
|
+
resolvedSignal(explicit) {
|
|
336
|
+
const ambient = this.operationSignal();
|
|
337
|
+
if (!explicit || explicit === ambient) return ambient ?? explicit;
|
|
338
|
+
if (!ambient) return explicit;
|
|
339
|
+
return AbortSignal.any([explicit, ambient]);
|
|
340
|
+
}
|
|
341
|
+
/** A handshake is already bounded by ssh2's readyTimeout. A deployment
|
|
342
|
+
* cancellation may stop waiting sooner because no remote mutation can have
|
|
343
|
+
* started before connect() resolves; the shared attempt is left alive for an
|
|
344
|
+
* unrelated/following caller instead of tearing down pooled state. */
|
|
345
|
+
awaitConnection(attempt) {
|
|
346
|
+
const signal = this.operationSignal();
|
|
347
|
+
if (!signal) return attempt;
|
|
348
|
+
this.throwIfAborted("connection", signal);
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
let settled = false;
|
|
351
|
+
const finish = (act) => {
|
|
352
|
+
if (settled) return;
|
|
353
|
+
settled = true;
|
|
354
|
+
signal.removeEventListener("abort", onAbort);
|
|
355
|
+
act();
|
|
356
|
+
};
|
|
357
|
+
const onAbort = () => finish(() => reject(abortError("connection", signal)));
|
|
358
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
359
|
+
if (signal.aborted) {
|
|
360
|
+
onAbort();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
attempt.then(
|
|
364
|
+
(client) => finish(() => resolve(client)),
|
|
365
|
+
(error) => finish(() => reject(error))
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
async connect() {
|
|
370
|
+
if (this.client) {
|
|
371
|
+
this.throwIfAborted("connection");
|
|
372
|
+
return this.client;
|
|
373
|
+
}
|
|
374
|
+
if (this.connecting) return this.awaitConnection(this.connecting);
|
|
375
|
+
const attempt = (async () => {
|
|
376
|
+
const client = await connectSshClient(this.config);
|
|
377
|
+
const onTransportDown = (cause) => {
|
|
378
|
+
if (this.client !== client) return;
|
|
379
|
+
this.client = null;
|
|
380
|
+
this.sftpChannel = null;
|
|
381
|
+
this.sftpWrapper = null;
|
|
382
|
+
this.handleDisconnect(cause);
|
|
383
|
+
};
|
|
384
|
+
client.on("close", () => onTransportDown());
|
|
385
|
+
client.on("end", () => onTransportDown());
|
|
386
|
+
client.on("error", (err) => onTransportDown(err));
|
|
387
|
+
this.client = client;
|
|
388
|
+
return client;
|
|
389
|
+
})();
|
|
390
|
+
this.connecting = attempt;
|
|
391
|
+
const clearMemo = () => {
|
|
392
|
+
if (this.connecting === attempt) this.connecting = null;
|
|
393
|
+
};
|
|
394
|
+
attempt.then(clearMemo, clearMemo);
|
|
395
|
+
return this.awaitConnection(attempt);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Subscribe to transport-level disconnects. Returns an unsubscribe fn.
|
|
399
|
+
*/
|
|
400
|
+
onDisconnect(cb) {
|
|
401
|
+
this.disconnectListeners.add(cb);
|
|
402
|
+
return () => {
|
|
403
|
+
this.disconnectListeners.delete(cb);
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The transport died. Reject every in-flight exec/stream with a typed
|
|
408
|
+
* SshDisconnectedError — so they fail in <1s instead of hanging to their
|
|
409
|
+
* per-command timeout on a dead channel — then notify subscribers so the
|
|
410
|
+
* manager can reconnect / re-drive journaled ops.
|
|
411
|
+
*/
|
|
412
|
+
handleDisconnect(cause) {
|
|
413
|
+
const err = new SshDisconnectedError(
|
|
414
|
+
cause?.message ? `SSH connection lost: ${cause.message}` : "SSH connection lost"
|
|
415
|
+
);
|
|
416
|
+
this.rejectInflight(err);
|
|
417
|
+
for (const cb of [...this.disconnectListeners]) {
|
|
418
|
+
try {
|
|
419
|
+
cb(err);
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
rejectInflight(err) {
|
|
425
|
+
const aborts = [...this.inflight];
|
|
426
|
+
this.inflight.clear();
|
|
427
|
+
for (const abort of aborts) {
|
|
428
|
+
try {
|
|
429
|
+
abort(err);
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async waitForChannelClose(channel, close) {
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
let settled = false;
|
|
437
|
+
const finish = (closed) => {
|
|
438
|
+
if (settled) return;
|
|
439
|
+
settled = true;
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
channel.removeListener("close", onClose);
|
|
442
|
+
channel.removeListener("error", onError);
|
|
443
|
+
resolve(closed);
|
|
444
|
+
};
|
|
445
|
+
const onClose = () => finish(true);
|
|
446
|
+
const onError = () => {
|
|
447
|
+
};
|
|
448
|
+
const timer = setTimeout(() => finish(false), CHANNEL_CLOSE_GRACE_MS);
|
|
449
|
+
timer.unref?.();
|
|
450
|
+
channel.once("close", onClose);
|
|
451
|
+
channel.once("error", onError);
|
|
452
|
+
try {
|
|
453
|
+
close();
|
|
454
|
+
} catch {
|
|
455
|
+
finish(false);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/** Hard transport reset used only when a channel cannot prove it closed.
|
|
460
|
+
* Every operation on that transport is rejected before we return, and a
|
|
461
|
+
* later operation must establish a fresh connection. */
|
|
462
|
+
async forceResetTransport(client, cause) {
|
|
463
|
+
if (this.client !== client) return;
|
|
464
|
+
this.client = null;
|
|
465
|
+
this.connecting = null;
|
|
466
|
+
this.sftpChannel = null;
|
|
467
|
+
this.sftpWrapper = null;
|
|
468
|
+
if (this.reverseListenerClient === client)
|
|
469
|
+
this.reverseListenerClient = null;
|
|
470
|
+
this.rejectInflight(
|
|
471
|
+
new SshDisconnectedError(
|
|
472
|
+
`SSH connection reset to quiesce an operation: ${cause.message}`
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
let closed = false;
|
|
476
|
+
const closePromise = new Promise((resolve) => {
|
|
477
|
+
const done = () => {
|
|
478
|
+
if (closed) return;
|
|
479
|
+
closed = true;
|
|
480
|
+
resolve();
|
|
481
|
+
};
|
|
482
|
+
client.once("close", done);
|
|
483
|
+
client.once("end", done);
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
client.end();
|
|
487
|
+
} catch {
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
client.destroy();
|
|
491
|
+
} catch {
|
|
492
|
+
}
|
|
493
|
+
await Promise.race([
|
|
494
|
+
closePromise,
|
|
495
|
+
new Promise((resolve) => {
|
|
496
|
+
const timer = setTimeout(resolve, TRANSPORT_CLOSE_GRACE_MS);
|
|
497
|
+
timer.unref?.();
|
|
498
|
+
})
|
|
499
|
+
]);
|
|
500
|
+
}
|
|
501
|
+
quiesceSftp(client, wrapper, cause) {
|
|
502
|
+
if (wrapper) {
|
|
503
|
+
const existing = this.sftpQuiescence.get(wrapper);
|
|
504
|
+
if (existing) return existing;
|
|
505
|
+
}
|
|
506
|
+
const quiescence = this.quiesceSftpOnce(client, wrapper, cause);
|
|
507
|
+
if (wrapper) this.sftpQuiescence.set(wrapper, quiescence);
|
|
508
|
+
return quiescence;
|
|
509
|
+
}
|
|
510
|
+
async quiesceSftpOnce(client, wrapper, cause) {
|
|
511
|
+
if (!wrapper || this.sftpWrapper === wrapper) {
|
|
512
|
+
this.sftpChannel = null;
|
|
513
|
+
this.sftpWrapper = null;
|
|
514
|
+
}
|
|
515
|
+
if (wrapper) {
|
|
516
|
+
const closed = await this.waitForChannelClose(
|
|
517
|
+
wrapper,
|
|
518
|
+
() => wrapper.end()
|
|
519
|
+
);
|
|
520
|
+
if (closed) return;
|
|
521
|
+
}
|
|
522
|
+
await this.forceResetTransport(client, cause);
|
|
523
|
+
}
|
|
524
|
+
awaitSftpOpen(opening, client) {
|
|
525
|
+
const signal = this.operationSignal();
|
|
526
|
+
this.throwIfAborted("SFTP channel open", signal);
|
|
527
|
+
return new Promise((resolve, reject) => {
|
|
528
|
+
let settled = false;
|
|
529
|
+
let terminating = false;
|
|
530
|
+
const cleanup = () => {
|
|
531
|
+
clearTimeout(timer);
|
|
532
|
+
this.inflight.delete(abort);
|
|
533
|
+
signal?.removeEventListener("abort", onSignalAbort);
|
|
534
|
+
};
|
|
535
|
+
const finish = (act) => {
|
|
536
|
+
if (settled || terminating) return;
|
|
537
|
+
settled = true;
|
|
538
|
+
cleanup();
|
|
539
|
+
act();
|
|
540
|
+
};
|
|
541
|
+
const terminate = (error) => {
|
|
542
|
+
if (settled || terminating) return;
|
|
543
|
+
terminating = true;
|
|
544
|
+
cleanup();
|
|
545
|
+
const settle = () => {
|
|
546
|
+
settled = true;
|
|
547
|
+
reject(error);
|
|
548
|
+
};
|
|
549
|
+
void this.forceResetTransport(client, error).then(settle, settle);
|
|
550
|
+
};
|
|
551
|
+
const abort = (error) => terminate(error);
|
|
552
|
+
const onSignalAbort = () => terminate(abortError("SFTP channel open", signal));
|
|
553
|
+
const timer = setTimeout(
|
|
554
|
+
() => terminate(
|
|
555
|
+
new Error(
|
|
556
|
+
`SFTP channel open timed out after ${SFTP_OPERATION_TIMEOUT_MS}ms`
|
|
557
|
+
)
|
|
558
|
+
),
|
|
559
|
+
SFTP_OPERATION_TIMEOUT_MS
|
|
560
|
+
);
|
|
561
|
+
timer.unref?.();
|
|
562
|
+
this.inflight.add(abort);
|
|
563
|
+
signal?.addEventListener("abort", onSignalAbort, { once: true });
|
|
564
|
+
if (signal?.aborted) {
|
|
565
|
+
onSignalAbort();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
opening.then(
|
|
569
|
+
(wrapper) => finish(() => resolve(wrapper)),
|
|
570
|
+
(error) => finish(() => reject(error))
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* One SFTP subsystem channel per client, opened lazily and shared by every
|
|
576
|
+
* file op. ssh2's SFTPWrapper pipelines many concurrent requests over a
|
|
577
|
+
* single channel, so file operations cost exactly ONE session against the
|
|
578
|
+
* server's MaxSessions — not one channel per op, which leaks and exhausts
|
|
579
|
+
* the quota, then takes down the whole connection (incl. a live build) on
|
|
580
|
+
* the next channel-open failure (#34). Self-clears on the channel's own
|
|
581
|
+
* close/error and on resetConnection/dispose so the next op reopens clean.
|
|
582
|
+
*/
|
|
583
|
+
async sftp() {
|
|
584
|
+
const client = await this.connect();
|
|
585
|
+
this.throwIfAborted("SFTP channel open");
|
|
586
|
+
if (this.sftpChannel) return this.awaitSftpOpen(this.sftpChannel, client);
|
|
587
|
+
const opening = openSftp(client).then((wrapper) => {
|
|
588
|
+
if (this.client !== client || this.sftpChannel !== opening) {
|
|
589
|
+
try {
|
|
590
|
+
wrapper.end();
|
|
591
|
+
} catch {
|
|
592
|
+
}
|
|
593
|
+
throw new SshDisconnectedError(
|
|
594
|
+
"SFTP channel opened after its SSH transport was reset"
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const drop = () => {
|
|
598
|
+
if (this.sftpChannel === opening) {
|
|
599
|
+
this.sftpChannel = null;
|
|
600
|
+
this.sftpWrapper = null;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
wrapper.once("close", drop);
|
|
604
|
+
wrapper.once("error", drop);
|
|
605
|
+
this.sftpWrapper = wrapper;
|
|
606
|
+
return wrapper;
|
|
607
|
+
});
|
|
608
|
+
opening.catch(() => {
|
|
609
|
+
if (this.sftpChannel === opening) this.sftpChannel = null;
|
|
610
|
+
});
|
|
611
|
+
this.sftpChannel = opening;
|
|
612
|
+
return this.awaitSftpOpen(opening, client);
|
|
613
|
+
}
|
|
614
|
+
/** Close + forget the shared SFTP channel (frees its session) without
|
|
615
|
+
* touching the SSH client, so file ops can reopen on the same connection. */
|
|
616
|
+
dropSftp() {
|
|
617
|
+
const ch = this.sftpChannel;
|
|
618
|
+
this.sftpChannel = null;
|
|
619
|
+
this.sftpWrapper = null;
|
|
620
|
+
if (ch)
|
|
621
|
+
ch.then((w) => {
|
|
622
|
+
try {
|
|
623
|
+
w.end();
|
|
624
|
+
} catch {
|
|
625
|
+
}
|
|
626
|
+
}).catch(() => {
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Force-close the current connection so the next call reconnects.
|
|
631
|
+
*/
|
|
632
|
+
resetConnection() {
|
|
633
|
+
this.dropSftp();
|
|
634
|
+
if (this.client) {
|
|
635
|
+
try {
|
|
636
|
+
this.client.end();
|
|
637
|
+
} catch {
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
this.client.destroy();
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
this.client = null;
|
|
644
|
+
}
|
|
645
|
+
this.connecting = null;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Recover from a channel-open failure without killing live work. If other
|
|
649
|
+
* ops are still streaming on this connection (a docker build, an exec), the
|
|
650
|
+
* failure is session pressure — NOT a dead socket — so ending the client
|
|
651
|
+
* would abort that in-flight command (#34). Free the SFTP channel and keep
|
|
652
|
+
* the connection. With nothing in flight the cached socket is stale (idle
|
|
653
|
+
* drop), so reset it fully.
|
|
654
|
+
*/
|
|
655
|
+
recoverFromChannelError() {
|
|
656
|
+
if (this.inflight.size > 0) this.dropSftp();
|
|
657
|
+
else this.resetConnection();
|
|
658
|
+
}
|
|
659
|
+
/** Returns true if the error is an SSH channel-open or channel-exec failure. */
|
|
660
|
+
static isChannelError(err) {
|
|
661
|
+
if (!(err instanceof Error)) return false;
|
|
662
|
+
if (isSshExecRequestError(err)) return true;
|
|
663
|
+
const msg = err.message.toLowerCase();
|
|
664
|
+
return msg.includes("channel open failure") || msg.includes("open failed");
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Run an operation, and if it fails opening an SSH channel on a half-dead
|
|
668
|
+
* cached connection ("Channel open failure: open failed" — common after the
|
|
669
|
+
* idle timeout drops the socket), recover channel capacity and retry ONCE.
|
|
670
|
+
* An idle connection is replaced; a busy one stays alive so its other command
|
|
671
|
+
* is not aborted (see recoverFromChannelError). The SFTP-based ops
|
|
672
|
+
* (writeFile/readFile/exists) must go through this too, or a deploy's route
|
|
673
|
+
* write fails spuriously and only succeeds on a manual redeploy.
|
|
674
|
+
*/
|
|
675
|
+
async withChannelRetry(fn) {
|
|
676
|
+
this.throwIfAborted("operation");
|
|
677
|
+
try {
|
|
678
|
+
return await fn();
|
|
679
|
+
} catch (err) {
|
|
680
|
+
if (_SshExecutor.isChannelError(err) && !this.operationSignal()?.aborted) {
|
|
681
|
+
this.recoverFromChannelError();
|
|
682
|
+
return fn();
|
|
683
|
+
}
|
|
684
|
+
throw err;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async exec(command, opts) {
|
|
688
|
+
return this.withChannelRetry(() => this._exec(command, opts));
|
|
689
|
+
}
|
|
690
|
+
/** Prefix applied to every SSH command - keeps dpkg non-interactive. */
|
|
691
|
+
static ENV_PREFIX = "export DEBIAN_FRONTEND=noninteractive DPKG_FORCE=confnew && ";
|
|
692
|
+
async _exec(command, opts) {
|
|
693
|
+
const client = await this.connect();
|
|
694
|
+
const timeout = opts?.timeout ?? 3e4;
|
|
695
|
+
const signal = this.operationSignal();
|
|
696
|
+
this.throwIfAborted("command", signal);
|
|
697
|
+
return new Promise((resolve, reject) => {
|
|
698
|
+
let settled = false;
|
|
699
|
+
let terminating = false;
|
|
700
|
+
let timer = null;
|
|
701
|
+
let channel = null;
|
|
702
|
+
const cleanup = () => {
|
|
703
|
+
if (timer) clearTimeout(timer);
|
|
704
|
+
this.inflight.delete(abort);
|
|
705
|
+
signal?.removeEventListener("abort", onSignalAbort);
|
|
706
|
+
};
|
|
707
|
+
const finish = (act) => {
|
|
708
|
+
if (settled || terminating) return;
|
|
709
|
+
settled = true;
|
|
710
|
+
cleanup();
|
|
711
|
+
act();
|
|
712
|
+
};
|
|
713
|
+
const terminate = (error) => {
|
|
714
|
+
if (settled || terminating) return;
|
|
715
|
+
terminating = true;
|
|
716
|
+
cleanup();
|
|
717
|
+
const quiesce = (async () => {
|
|
718
|
+
if (channel) {
|
|
719
|
+
const closed = await this.waitForChannelClose(
|
|
720
|
+
channel,
|
|
721
|
+
() => channel.close()
|
|
722
|
+
);
|
|
723
|
+
if (!closed) await this.forceResetTransport(client, error);
|
|
724
|
+
} else {
|
|
725
|
+
await this.forceResetTransport(client, error);
|
|
726
|
+
}
|
|
727
|
+
})();
|
|
728
|
+
const settle = () => {
|
|
729
|
+
settled = true;
|
|
730
|
+
reject(error);
|
|
731
|
+
};
|
|
732
|
+
void quiesce.then(settle, settle);
|
|
733
|
+
};
|
|
734
|
+
const abort = (err) => terminate(err);
|
|
735
|
+
const onSignalAbort = () => terminate(abortError("command", signal));
|
|
736
|
+
this.inflight.add(abort);
|
|
737
|
+
signal?.addEventListener("abort", onSignalAbort, { once: true });
|
|
738
|
+
if (signal?.aborted) {
|
|
739
|
+
onSignalAbort();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
timer = setTimeout(
|
|
743
|
+
() => terminate(
|
|
744
|
+
new Error(
|
|
745
|
+
`Command timed out after ${timeout}ms: ${commandForError(command)}`
|
|
746
|
+
)
|
|
747
|
+
),
|
|
748
|
+
timeout
|
|
749
|
+
);
|
|
750
|
+
timer.unref?.();
|
|
751
|
+
client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
|
|
752
|
+
if (err) return finish(() => reject(tagExecRequestError(err)));
|
|
753
|
+
channel = stream;
|
|
754
|
+
if (terminating) {
|
|
755
|
+
try {
|
|
756
|
+
stream.close();
|
|
757
|
+
} catch {
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
let stdout = "";
|
|
762
|
+
let stderr = "";
|
|
763
|
+
stream.on("data", (data) => {
|
|
764
|
+
stdout += data.toString();
|
|
765
|
+
});
|
|
766
|
+
stream.stderr.on("data", (data) => {
|
|
767
|
+
stderr += data.toString();
|
|
768
|
+
});
|
|
769
|
+
stream.on("error", (error) => finish(() => reject(error)));
|
|
770
|
+
stream.on("close", (code) => {
|
|
771
|
+
finish(() => {
|
|
772
|
+
if (code !== 0) {
|
|
773
|
+
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
774
|
+
reject(new Error(detail || `Exit code ${code}`));
|
|
775
|
+
} else resolve(stdout.trim());
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
streamExec(command, onLog, opts) {
|
|
782
|
+
const scopedOpts = { ...opts, signal: this.resolvedSignal(opts?.signal) };
|
|
783
|
+
return this._streamExec(command, onLog, scopedOpts).catch((err) => {
|
|
784
|
+
if (_SshExecutor.isChannelError(err) && !scopedOpts.signal?.aborted) {
|
|
785
|
+
this.recoverFromChannelError();
|
|
786
|
+
return this._streamExec(command, onLog, scopedOpts);
|
|
787
|
+
}
|
|
788
|
+
throw err;
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
async _streamExec(command, onLog, opts) {
|
|
792
|
+
const client = await this.connect();
|
|
793
|
+
const signal = opts?.signal;
|
|
794
|
+
return new Promise((resolve, reject) => {
|
|
795
|
+
let settled = false;
|
|
796
|
+
let terminating = false;
|
|
797
|
+
const chunks = [];
|
|
798
|
+
let channel = null;
|
|
799
|
+
const cleanup = () => {
|
|
800
|
+
this.inflight.delete(abort);
|
|
801
|
+
signal?.removeEventListener("abort", onSignalAbort);
|
|
802
|
+
};
|
|
803
|
+
const finish = (act) => {
|
|
804
|
+
if (settled || terminating) return;
|
|
805
|
+
settled = true;
|
|
806
|
+
cleanup();
|
|
807
|
+
act();
|
|
808
|
+
};
|
|
809
|
+
const terminate = (error, intentional) => {
|
|
810
|
+
if (settled || terminating) return;
|
|
811
|
+
terminating = true;
|
|
812
|
+
cleanup();
|
|
813
|
+
const quiesce = (async () => {
|
|
814
|
+
if (channel) {
|
|
815
|
+
const closed = await this.waitForChannelClose(
|
|
816
|
+
channel,
|
|
817
|
+
() => channel.close()
|
|
818
|
+
);
|
|
819
|
+
if (!closed) await this.forceResetTransport(client, error);
|
|
820
|
+
} else {
|
|
821
|
+
await this.forceResetTransport(client, error);
|
|
822
|
+
}
|
|
823
|
+
})();
|
|
824
|
+
const settle = () => {
|
|
825
|
+
settled = true;
|
|
826
|
+
if (intentional) resolve({ code: 0, output: chunks.join("") });
|
|
827
|
+
else reject(error);
|
|
828
|
+
};
|
|
829
|
+
void quiesce.then(settle, settle);
|
|
830
|
+
};
|
|
831
|
+
const abort = (err) => terminate(err, false);
|
|
832
|
+
const onSignalAbort = () => terminate(abortError("streaming command", signal), true);
|
|
833
|
+
this.inflight.add(abort);
|
|
834
|
+
signal?.addEventListener("abort", onSignalAbort, { once: true });
|
|
835
|
+
if (signal?.aborted) {
|
|
836
|
+
onSignalAbort();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
|
|
840
|
+
if (err) return finish(() => reject(tagExecRequestError(err)));
|
|
841
|
+
channel = stream;
|
|
842
|
+
if (terminating || signal?.aborted) {
|
|
843
|
+
try {
|
|
844
|
+
stream.close();
|
|
845
|
+
} catch {
|
|
846
|
+
}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const onChunk = (data, level) => {
|
|
850
|
+
const text = data.toString();
|
|
851
|
+
if (!text) return;
|
|
852
|
+
chunks.push(text);
|
|
853
|
+
onLog(logEntry(text, level, data.toString("base64")));
|
|
854
|
+
};
|
|
855
|
+
stream.on("data", (data) => onChunk(data, "info"));
|
|
856
|
+
stream.stderr.on("data", (data) => onChunk(data, "warn"));
|
|
857
|
+
stream.on("error", (error) => finish(() => reject(error)));
|
|
858
|
+
let exitCode = null;
|
|
859
|
+
stream.on("exit", (code) => {
|
|
860
|
+
exitCode = code;
|
|
861
|
+
});
|
|
862
|
+
stream.on("close", (code) => {
|
|
863
|
+
finish(() => {
|
|
864
|
+
const final = typeof code === "number" ? code : exitCode;
|
|
865
|
+
if (final == null) {
|
|
866
|
+
reject(
|
|
867
|
+
new Error(
|
|
868
|
+
"remote channel closed without an exit status \u2014 the SSH connection or channel was terminated mid-command"
|
|
869
|
+
)
|
|
870
|
+
);
|
|
871
|
+
} else {
|
|
872
|
+
resolve({ code: final, output: chunks.join("") });
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/** Turn ssh2's callback-only SFTP request into a bounded operation. A lost
|
|
880
|
+
* callback used to leave the compose deploy promise and its host mutex tail
|
|
881
|
+
* pending forever. Cancellation/timeout closes the shared subsystem first;
|
|
882
|
+
* if close itself wedges, the parent SSH transport is destroyed before this
|
|
883
|
+
* promise rejects. */
|
|
884
|
+
runSftpRequest(client, sftp, operation, start) {
|
|
885
|
+
const signal = this.operationSignal();
|
|
886
|
+
this.throwIfAborted(operation, signal);
|
|
887
|
+
return new Promise((resolve, reject) => {
|
|
888
|
+
let settled = false;
|
|
889
|
+
let terminating = false;
|
|
890
|
+
const cleanup = () => {
|
|
891
|
+
clearTimeout(timer);
|
|
892
|
+
this.inflight.delete(abort);
|
|
893
|
+
signal?.removeEventListener("abort", onSignalAbort);
|
|
894
|
+
};
|
|
895
|
+
const finish = (act) => {
|
|
896
|
+
if (settled || terminating) return;
|
|
897
|
+
settled = true;
|
|
898
|
+
cleanup();
|
|
899
|
+
act();
|
|
900
|
+
};
|
|
901
|
+
const terminate = (error) => {
|
|
902
|
+
if (settled || terminating) return;
|
|
903
|
+
terminating = true;
|
|
904
|
+
cleanup();
|
|
905
|
+
const settle = () => {
|
|
906
|
+
settled = true;
|
|
907
|
+
reject(error);
|
|
908
|
+
};
|
|
909
|
+
void this.quiesceSftp(client, sftp, error).then(settle, settle);
|
|
910
|
+
};
|
|
911
|
+
const abort = (error) => terminate(error);
|
|
912
|
+
const onSignalAbort = () => terminate(abortError(operation, signal));
|
|
913
|
+
const timer = setTimeout(
|
|
914
|
+
() => terminate(
|
|
915
|
+
new Error(
|
|
916
|
+
`${operation} timed out after ${SFTP_OPERATION_TIMEOUT_MS}ms`
|
|
917
|
+
)
|
|
918
|
+
),
|
|
919
|
+
SFTP_OPERATION_TIMEOUT_MS
|
|
920
|
+
);
|
|
921
|
+
timer.unref?.();
|
|
922
|
+
this.inflight.add(abort);
|
|
923
|
+
signal?.addEventListener("abort", onSignalAbort, { once: true });
|
|
924
|
+
if (signal?.aborted) {
|
|
925
|
+
onSignalAbort();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
start((error, value) => {
|
|
930
|
+
if (error) finish(() => reject(error));
|
|
931
|
+
else finish(() => resolve(value));
|
|
932
|
+
});
|
|
933
|
+
} catch (error) {
|
|
934
|
+
finish(() => reject(error));
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
async writeFile(path, content, opts) {
|
|
939
|
+
const dir = remoteDirname(path);
|
|
940
|
+
try {
|
|
941
|
+
await this.exec(`mkdir -p ${sq(dir)}`);
|
|
942
|
+
} catch {
|
|
943
|
+
}
|
|
944
|
+
return this.withChannelRetry(async () => {
|
|
945
|
+
const sftp = await this.sftp();
|
|
946
|
+
const client = this.client;
|
|
947
|
+
if (!client)
|
|
948
|
+
throw new SshDisconnectedError("SSH connection lost before SFTP write");
|
|
949
|
+
const write = (data, mode) => this.runSftpRequest(
|
|
950
|
+
client,
|
|
951
|
+
sftp,
|
|
952
|
+
`SFTP write ${path}`,
|
|
953
|
+
(done) => {
|
|
954
|
+
sftp.writeFile(
|
|
955
|
+
path,
|
|
956
|
+
data,
|
|
957
|
+
{ encoding: "utf-8", mode },
|
|
958
|
+
(err) => done(err)
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
);
|
|
962
|
+
const chmod2 = (mode) => this.runSftpRequest(
|
|
963
|
+
client,
|
|
964
|
+
sftp,
|
|
965
|
+
`SFTP chmod ${path}`,
|
|
966
|
+
(done) => {
|
|
967
|
+
sftp.chmod(path, mode, (err) => done(err));
|
|
968
|
+
}
|
|
969
|
+
);
|
|
970
|
+
if (opts?.mode !== void 0) {
|
|
971
|
+
await write("", opts.mode);
|
|
972
|
+
await chmod2(opts.mode);
|
|
973
|
+
await write(content);
|
|
974
|
+
} else {
|
|
975
|
+
await write(content);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
async rename(from, to) {
|
|
980
|
+
await this.exec(`mv ${sq(from)} ${sq(to)}`);
|
|
981
|
+
}
|
|
982
|
+
async readFile(path) {
|
|
983
|
+
return this.withChannelRetry(async () => {
|
|
984
|
+
const sftp = await this.sftp();
|
|
985
|
+
const client = this.client;
|
|
986
|
+
if (!client)
|
|
987
|
+
throw new SshDisconnectedError("SSH connection lost before SFTP read");
|
|
988
|
+
return this.runSftpRequest(
|
|
989
|
+
client,
|
|
990
|
+
sftp,
|
|
991
|
+
`SFTP read ${path}`,
|
|
992
|
+
(done) => {
|
|
993
|
+
sftp.readFile(path, { encoding: "utf-8" }, (err, data) => {
|
|
994
|
+
done(err, data?.toString());
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
async exists(path) {
|
|
1001
|
+
return this.withChannelRetry(async () => {
|
|
1002
|
+
const sftp = await this.sftp();
|
|
1003
|
+
const client = this.client;
|
|
1004
|
+
if (!client)
|
|
1005
|
+
throw new SshDisconnectedError("SSH connection lost before SFTP stat");
|
|
1006
|
+
return this.runSftpRequest(
|
|
1007
|
+
client,
|
|
1008
|
+
sftp,
|
|
1009
|
+
`SFTP stat ${path}`,
|
|
1010
|
+
(done) => {
|
|
1011
|
+
sftp.stat(path, (err) => {
|
|
1012
|
+
done(null, !err);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
async mkdir(path) {
|
|
1019
|
+
await this.exec(`mkdir -p ${sq(path)}`);
|
|
1020
|
+
}
|
|
1021
|
+
async rm(path) {
|
|
1022
|
+
try {
|
|
1023
|
+
await this.exec(`rm -rf ${sq(path)}`);
|
|
1024
|
+
} catch {
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
rawExec(command) {
|
|
1028
|
+
return (async () => {
|
|
1029
|
+
const client = await this.connect();
|
|
1030
|
+
return new Promise((resolve, reject) => {
|
|
1031
|
+
client.exec(command, (err, stream) => {
|
|
1032
|
+
if (err) return reject(err);
|
|
1033
|
+
const onClose = new Promise((res) => {
|
|
1034
|
+
stream.on("close", (code) => res(code ?? 1));
|
|
1035
|
+
});
|
|
1036
|
+
resolve({
|
|
1037
|
+
stdout: stream,
|
|
1038
|
+
stderr: stream.stderr,
|
|
1039
|
+
onClose,
|
|
1040
|
+
kill: () => {
|
|
1041
|
+
try {
|
|
1042
|
+
stream.close();
|
|
1043
|
+
} catch {
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
})();
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Pipe `body` into a remote command's stdin over a raw ssh2 channel, half-
|
|
1053
|
+
* closing stdin at EOF. The streaming inverse of rawExec: used to stream a
|
|
1054
|
+
* `docker save` tar straight into `docker load` on another host without
|
|
1055
|
+
* staging the (multi-GB) image to a temp file. Bounded stderr tail for
|
|
1056
|
+
* diagnostics; registered for transport-drop abort like _exec/_streamExec.
|
|
1057
|
+
*/
|
|
1058
|
+
execWithInput(command, body) {
|
|
1059
|
+
return (async () => {
|
|
1060
|
+
const client = await this.connect();
|
|
1061
|
+
return new Promise(
|
|
1062
|
+
(resolve, reject) => {
|
|
1063
|
+
let settled = false;
|
|
1064
|
+
const abort = (err) => finish(() => reject(err));
|
|
1065
|
+
const finish = (act) => {
|
|
1066
|
+
if (settled) return;
|
|
1067
|
+
settled = true;
|
|
1068
|
+
this.inflight.delete(abort);
|
|
1069
|
+
act();
|
|
1070
|
+
};
|
|
1071
|
+
this.inflight.add(abort);
|
|
1072
|
+
client.exec(command, (err, stream) => {
|
|
1073
|
+
if (err) return finish(() => reject(err));
|
|
1074
|
+
let stderr = "";
|
|
1075
|
+
stream.stderr.on("data", (d) => {
|
|
1076
|
+
stderr += d.toString();
|
|
1077
|
+
if (stderr.length > 16 * 1024) stderr = stderr.slice(-16 * 1024);
|
|
1078
|
+
});
|
|
1079
|
+
let stdout = "";
|
|
1080
|
+
stream.on("data", (d) => {
|
|
1081
|
+
stdout += d.toString();
|
|
1082
|
+
if (stdout.length > 16 * 1024) stdout = stdout.slice(-16 * 1024);
|
|
1083
|
+
});
|
|
1084
|
+
let exitCode = null;
|
|
1085
|
+
stream.on("exit", (code) => {
|
|
1086
|
+
exitCode = code;
|
|
1087
|
+
});
|
|
1088
|
+
stream.on("close", (code) => {
|
|
1089
|
+
finish(() => {
|
|
1090
|
+
const final = typeof code === "number" ? code : exitCode;
|
|
1091
|
+
if (final == null) {
|
|
1092
|
+
reject(
|
|
1093
|
+
new Error(
|
|
1094
|
+
"remote channel closed without an exit status \u2014 the SSH connection was terminated mid-command"
|
|
1095
|
+
)
|
|
1096
|
+
);
|
|
1097
|
+
} else {
|
|
1098
|
+
resolve({
|
|
1099
|
+
code: final,
|
|
1100
|
+
stderr: stderr.trim(),
|
|
1101
|
+
stdout: stdout.trim()
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
body.on(
|
|
1107
|
+
"error",
|
|
1108
|
+
(e) => finish(() => {
|
|
1109
|
+
try {
|
|
1110
|
+
stream.close();
|
|
1111
|
+
} catch {
|
|
1112
|
+
}
|
|
1113
|
+
reject(e);
|
|
1114
|
+
})
|
|
1115
|
+
);
|
|
1116
|
+
stream.on("error", (e) => finish(() => reject(e)));
|
|
1117
|
+
body.pipe(stream);
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
);
|
|
1121
|
+
})();
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Open an interactive PTY shell on the remote host. The returned
|
|
1125
|
+
* ShellSession wraps an ssh2 ClientChannel: writes go to stdin,
|
|
1126
|
+
* stdout/stderr emit on the readable streams, setWindow forwards to
|
|
1127
|
+
* channel.setWindow, close ends the channel. Lifetime is bound to the
|
|
1128
|
+
* channel - the underlying ssh2.Client stays cached by sshManager, so
|
|
1129
|
+
* callers must wrap with `sshManager.retain(serverId)` / `release()`
|
|
1130
|
+
* to avoid the 5-minute idle drop on the parent connection.
|
|
1131
|
+
*/
|
|
1132
|
+
async openShell(opts) {
|
|
1133
|
+
const client = await this.connect();
|
|
1134
|
+
const cols = clampWindow(opts?.cols, 80, 1, 1e3);
|
|
1135
|
+
const rows = clampWindow(opts?.rows, 24, 1, 500);
|
|
1136
|
+
const term = opts?.term || "xterm-256color";
|
|
1137
|
+
const channel = await new Promise(
|
|
1138
|
+
(resolve, reject) => {
|
|
1139
|
+
client.shell(
|
|
1140
|
+
{ term, cols, rows, width: 0, height: 0, modes: {} },
|
|
1141
|
+
(err, ch) => err ? reject(err) : resolve(ch)
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
);
|
|
1145
|
+
const closeListeners = [];
|
|
1146
|
+
let closed = false;
|
|
1147
|
+
const fireClose = (code, signal) => {
|
|
1148
|
+
if (closed) return;
|
|
1149
|
+
closed = true;
|
|
1150
|
+
for (const cb of closeListeners) {
|
|
1151
|
+
try {
|
|
1152
|
+
cb(code, signal);
|
|
1153
|
+
} catch {
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
channel.on("exit", (code, signal) => {
|
|
1158
|
+
fireClose(code, signal);
|
|
1159
|
+
});
|
|
1160
|
+
channel.on("close", () => fireClose(null));
|
|
1161
|
+
channel.on("error", () => fireClose(null));
|
|
1162
|
+
return {
|
|
1163
|
+
stdin: channel,
|
|
1164
|
+
stdout: channel,
|
|
1165
|
+
stderr: channel.stderr,
|
|
1166
|
+
setWindow: (c, r) => {
|
|
1167
|
+
const sc = clampWindow(c, 80, 1, 1e3);
|
|
1168
|
+
const sr = clampWindow(r, 24, 1, 500);
|
|
1169
|
+
try {
|
|
1170
|
+
channel.setWindow(sr, sc, 0, 0);
|
|
1171
|
+
} catch {
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
close: (_signal) => {
|
|
1175
|
+
try {
|
|
1176
|
+
channel.end();
|
|
1177
|
+
} catch {
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
channel.close();
|
|
1181
|
+
} catch {
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
onClose: (cb) => {
|
|
1185
|
+
closeListeners.push(cb);
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
async forwardUnixSocket(socketPath) {
|
|
1190
|
+
const client = await this.connect();
|
|
1191
|
+
return openSshUnixSocket(client, socketPath);
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Carry the Docker Engine API over a `docker system dial-stdio` exec channel
|
|
1195
|
+
* on the pooled connection — the streamlocal-free transport used when the
|
|
1196
|
+
* SSH server (or the Bun-compiled desktop runtime) can't do socket
|
|
1197
|
+
* forwarding.
|
|
1198
|
+
*
|
|
1199
|
+
* Deliberately WITHOUT `ENV_PREFIX`: that prefix only exports apt's
|
|
1200
|
+
* non-interactive variables (it sets no PATH, despite what the comment here
|
|
1201
|
+
* used to claim), and the bridge's own ephemeral dial-stdio client opens the
|
|
1202
|
+
* bare command. Two dial-stdio channels that differ by a shell prefix are two
|
|
1203
|
+
* transports, and the bridge picks one by probing the other.
|
|
1204
|
+
*
|
|
1205
|
+
* Failure diagnostics (daemon down, socket permission, `docker: command not
|
|
1206
|
+
* found`, an sshd ForceCommand banner) ride ON the returned channel — see
|
|
1207
|
+
* `attachDialStdioDiagnostics`. They used to go to `console.warn` while the
|
|
1208
|
+
* caller got a bare socket reset, which is how a host with no running dockerd
|
|
1209
|
+
* reported itself as `socket hang up`.
|
|
1210
|
+
*/
|
|
1211
|
+
async openDockerDialStdio() {
|
|
1212
|
+
const client = await this.connect();
|
|
1213
|
+
return openDockerDialStdioChannel(client);
|
|
1214
|
+
}
|
|
1215
|
+
async forwardPort(remoteHost, remotePort) {
|
|
1216
|
+
const client = await this.connect();
|
|
1217
|
+
return new Promise((resolve, reject) => {
|
|
1218
|
+
client.forwardOut(
|
|
1219
|
+
"127.0.0.1",
|
|
1220
|
+
0,
|
|
1221
|
+
remoteHost,
|
|
1222
|
+
remotePort,
|
|
1223
|
+
(err, stream) => {
|
|
1224
|
+
if (err) return reject(err);
|
|
1225
|
+
resolve(stream);
|
|
1226
|
+
}
|
|
1227
|
+
);
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Open a reverse tunnel: the remote listens on an ephemeral 127.0.0.1 port
|
|
1232
|
+
* and every connection to it is handed to `onConnection` as a duplex stream
|
|
1233
|
+
* over this SSH connection. ssh2's 'tcp connection' event is client-wide, so
|
|
1234
|
+
* a single dispatcher routes by the bound `destPort` to the right handler.
|
|
1235
|
+
*/
|
|
1236
|
+
async reverseForward(onConnection) {
|
|
1237
|
+
const client = await this.connect();
|
|
1238
|
+
this.attachReverseListener(client);
|
|
1239
|
+
const port = await new Promise((resolve, reject) => {
|
|
1240
|
+
client.forwardIn("127.0.0.1", 0, (err, boundPort) => {
|
|
1241
|
+
if (err) return reject(err);
|
|
1242
|
+
resolve(boundPort);
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
this.reverseHandlers.set(port, onConnection);
|
|
1246
|
+
return {
|
|
1247
|
+
port,
|
|
1248
|
+
close: async () => {
|
|
1249
|
+
this.reverseHandlers.delete(port);
|
|
1250
|
+
await new Promise((resolve) => {
|
|
1251
|
+
try {
|
|
1252
|
+
client.unforwardIn("127.0.0.1", port, () => resolve());
|
|
1253
|
+
} catch {
|
|
1254
|
+
resolve();
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
/** Attach the single client-wide 'tcp connection' dispatcher (idempotent per client). */
|
|
1261
|
+
attachReverseListener(client) {
|
|
1262
|
+
if (this.reverseListenerClient === client) return;
|
|
1263
|
+
this.reverseListenerClient = client;
|
|
1264
|
+
client.on("tcp connection", (details, accept, reject) => {
|
|
1265
|
+
const handler = this.reverseHandlers.get(details.destPort);
|
|
1266
|
+
if (!handler) {
|
|
1267
|
+
try {
|
|
1268
|
+
reject();
|
|
1269
|
+
} catch {
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
const channel = accept();
|
|
1274
|
+
handler(channel);
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
async dispose() {
|
|
1278
|
+
this.connecting = null;
|
|
1279
|
+
this.sftpChannel = null;
|
|
1280
|
+
this.sftpWrapper = null;
|
|
1281
|
+
this.reverseHandlers.clear();
|
|
1282
|
+
this.reverseListenerClient = null;
|
|
1283
|
+
if (this.client) {
|
|
1284
|
+
this.client.end();
|
|
1285
|
+
this.client = null;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
async transferIn(localPath, remotePath, onLog, options) {
|
|
1289
|
+
const deps = {
|
|
1290
|
+
config: this.config,
|
|
1291
|
+
hasRemoteCommand: (c) => this.hasRemoteCommand(c)
|
|
1292
|
+
};
|
|
1293
|
+
const excludes = options?.excludes ?? [...TRANSFER_EXCLUDES];
|
|
1294
|
+
const { args: tarArgs, cleanup: cleanupTarList } = await prepareSourceTarArgs(localPath, {
|
|
1295
|
+
excludes,
|
|
1296
|
+
includes: options?.includes,
|
|
1297
|
+
alsoInclude: options?.alsoInclude
|
|
1298
|
+
});
|
|
1299
|
+
const tmpLocalDir = await mkdtemp2(join2(tmpdir2(), "kraft-xfer-"));
|
|
1300
|
+
const localArchive = join2(tmpLocalDir, "context.tar.gz");
|
|
1301
|
+
const remoteArchive = `${remotePath}.kraft-xfer.tar.gz`;
|
|
1302
|
+
try {
|
|
1303
|
+
onLog?.(logEntry("Packing source into a single archive..."));
|
|
1304
|
+
await packLocalArchive(tarArgs, localArchive);
|
|
1305
|
+
const totalBytes = (await stat(localArchive)).size;
|
|
1306
|
+
await this.exec(`mkdir -p ${sq(remoteDirname(remoteArchive))}`);
|
|
1307
|
+
const rsync = await canUseRemoteRsync(deps);
|
|
1308
|
+
if (rsync.ok) {
|
|
1309
|
+
onLog?.(
|
|
1310
|
+
logEntry(
|
|
1311
|
+
`Uploading ${formatBytes(totalBytes)} archive via rsync (resumable)...`
|
|
1312
|
+
)
|
|
1313
|
+
);
|
|
1314
|
+
await uploadFileWithRsync(localArchive, remoteArchive, deps, onLog);
|
|
1315
|
+
} else {
|
|
1316
|
+
onLog?.(
|
|
1317
|
+
logEntry(
|
|
1318
|
+
`Uploading ${formatBytes(totalBytes)} archive via SFTP (resumable) \u2014 ${rsync.reason}.`
|
|
1319
|
+
)
|
|
1320
|
+
);
|
|
1321
|
+
await this.sftpUploadResumable(
|
|
1322
|
+
localArchive,
|
|
1323
|
+
remoteArchive,
|
|
1324
|
+
totalBytes,
|
|
1325
|
+
onLog
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
await extractRemoteArchive(
|
|
1329
|
+
(command) => this.exec(command),
|
|
1330
|
+
remoteArchive,
|
|
1331
|
+
remotePath,
|
|
1332
|
+
totalBytes,
|
|
1333
|
+
onLog
|
|
1334
|
+
);
|
|
1335
|
+
} finally {
|
|
1336
|
+
await cleanupTarList().catch(() => {
|
|
1337
|
+
});
|
|
1338
|
+
await fsRm2(tmpLocalDir, { recursive: true, force: true }).catch(() => {
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async hasRemoteCommand(command) {
|
|
1343
|
+
try {
|
|
1344
|
+
await this.exec(`command -v ${command} >/dev/null 2>&1 && echo ok`, {
|
|
1345
|
+
timeout: 5e3
|
|
1346
|
+
});
|
|
1347
|
+
return true;
|
|
1348
|
+
} catch {
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Resumable SFTP upload (the fallback when rsync isn't available — password
|
|
1354
|
+
* auth with no `sshpass`). Each attempt `stat`s the remote to learn how much
|
|
1355
|
+
* already landed and streams the REST from that offset (append), so a dropped
|
|
1356
|
+
* or stalled connection continues instead of restarting from 0. A watchdog
|
|
1357
|
+
* aborts the attempt if no bytes flow for `STALL_MS`, and the loop retries
|
|
1358
|
+
* (resuming) up to `MAX_ATTEMPTS`.
|
|
1359
|
+
*/
|
|
1360
|
+
async sftpUploadResumable(localArchive, remoteArchive, totalBytes, onLog) {
|
|
1361
|
+
const MAX_ATTEMPTS = 4;
|
|
1362
|
+
const STALL_MS = 3e4;
|
|
1363
|
+
let lastErr = null;
|
|
1364
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
1365
|
+
const sftp = await this.sftp();
|
|
1366
|
+
let offset = 0;
|
|
1367
|
+
try {
|
|
1368
|
+
const size = await new Promise(
|
|
1369
|
+
(resolve, reject) => sftp.stat(
|
|
1370
|
+
remoteArchive,
|
|
1371
|
+
(err, stats) => err ? reject(err) : resolve(stats.size)
|
|
1372
|
+
)
|
|
1373
|
+
);
|
|
1374
|
+
if (size === totalBytes) return;
|
|
1375
|
+
if (size < totalBytes) offset = size;
|
|
1376
|
+
} catch {
|
|
1377
|
+
offset = 0;
|
|
1378
|
+
}
|
|
1379
|
+
if (attempt > 1 || offset > 0) {
|
|
1380
|
+
onLog?.(
|
|
1381
|
+
logEntry(
|
|
1382
|
+
`Resuming SFTP upload from ${formatBytes(offset)} (attempt ${attempt}/${MAX_ATTEMPTS})...`,
|
|
1383
|
+
"warn"
|
|
1384
|
+
)
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
await this.sftpStreamFrom(
|
|
1389
|
+
sftp,
|
|
1390
|
+
localArchive,
|
|
1391
|
+
remoteArchive,
|
|
1392
|
+
offset,
|
|
1393
|
+
totalBytes,
|
|
1394
|
+
STALL_MS,
|
|
1395
|
+
onLog
|
|
1396
|
+
);
|
|
1397
|
+
return;
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
1400
|
+
onLog?.(
|
|
1401
|
+
logEntry(`SFTP upload interrupted: ${lastErr.message}`, "warn")
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
throw lastErr ?? new Error("SFTP upload failed");
|
|
1406
|
+
}
|
|
1407
|
+
/** Stream `localArchive` (from `offset`) into `remoteArchive`, appending when
|
|
1408
|
+
* resuming. Rejects on error, on a stall (no bytes for `stallMs`), or if the
|
|
1409
|
+
* stream closes before `totalBytes` land. */
|
|
1410
|
+
sftpStreamFrom(sftp, localArchive, remoteArchive, offset, totalBytes, stallMs, onLog) {
|
|
1411
|
+
return new Promise((resolve, reject) => {
|
|
1412
|
+
const read = createReadStream(localArchive, { start: offset });
|
|
1413
|
+
const write = sftp.createWriteStream(remoteArchive, {
|
|
1414
|
+
flags: offset > 0 ? "a" : "w"
|
|
1415
|
+
});
|
|
1416
|
+
let transferred = offset;
|
|
1417
|
+
let lastProgressAt = Date.now();
|
|
1418
|
+
const startedAt = Date.now();
|
|
1419
|
+
let lastReportedAt = startedAt;
|
|
1420
|
+
let settled = false;
|
|
1421
|
+
const finish = (fn) => {
|
|
1422
|
+
if (settled) return;
|
|
1423
|
+
settled = true;
|
|
1424
|
+
clearInterval(watch);
|
|
1425
|
+
fn();
|
|
1426
|
+
};
|
|
1427
|
+
const watch = setInterval(() => {
|
|
1428
|
+
if (Date.now() - lastProgressAt > stallMs) {
|
|
1429
|
+
read.destroy();
|
|
1430
|
+
write.end();
|
|
1431
|
+
finish(
|
|
1432
|
+
() => reject(
|
|
1433
|
+
new Error(`stalled \u2014 no data for ${Math.round(stallMs / 1e3)}s`)
|
|
1434
|
+
)
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
}, 5e3);
|
|
1438
|
+
watch.unref?.();
|
|
1439
|
+
read.on("data", (chunk) => {
|
|
1440
|
+
transferred += chunk.length;
|
|
1441
|
+
lastProgressAt = Date.now();
|
|
1442
|
+
const now = Date.now();
|
|
1443
|
+
if (now - lastReportedAt >= 2500) {
|
|
1444
|
+
lastReportedAt = now;
|
|
1445
|
+
const elapsed = (now - startedAt) / 1e3;
|
|
1446
|
+
const mbps = elapsed > 0 ? (transferred - offset) / 1024 / 1024 / elapsed : 0;
|
|
1447
|
+
const pct = totalBytes > 0 ? Math.min(Math.floor(transferred / totalBytes * 100), 100) : 0;
|
|
1448
|
+
onLog?.(
|
|
1449
|
+
logEntry(
|
|
1450
|
+
` ~${pct}% \xB7 ${formatBytes(transferred)} \xB7 ${mbps.toFixed(1)} MB/s`
|
|
1451
|
+
)
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
read.on("error", (e) => finish(() => reject(e)));
|
|
1456
|
+
write.on("error", (e) => finish(() => reject(e)));
|
|
1457
|
+
write.on(
|
|
1458
|
+
"close",
|
|
1459
|
+
() => finish(
|
|
1460
|
+
() => transferred >= totalBytes ? resolve() : reject(
|
|
1461
|
+
new Error(
|
|
1462
|
+
`incomplete upload: ${transferred}/${totalBytes} bytes`
|
|
1463
|
+
)
|
|
1464
|
+
)
|
|
1465
|
+
)
|
|
1466
|
+
);
|
|
1467
|
+
read.pipe(write);
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
// ../../packages/adapters/src/system/system-ssh-executor.ts
|
|
1473
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
1474
|
+
import { execFile as execFile2, spawn as spawn2 } from "child_process";
|
|
1475
|
+
import { once } from "events";
|
|
1476
|
+
import { mkdtemp as mkdtemp3, rm as fsRm3, stat as stat2, unlink } from "fs/promises";
|
|
1477
|
+
import { connect as netConnect } from "net";
|
|
1478
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1479
|
+
import { join as join3, posix as posix2 } from "path";
|
|
1480
|
+
import { Duplex } from "stream";
|
|
1481
|
+
import { promisify as promisify2 } from "util";
|
|
1482
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1483
|
+
|
|
1484
|
+
// ../../packages/adapters/src/system/system-ssh.ts
|
|
1485
|
+
import { randomBytes } from "crypto";
|
|
1486
|
+
var CONNECT_TIMEOUT_SECONDS = 15;
|
|
1487
|
+
function makeControlPath() {
|
|
1488
|
+
return `/tmp/kraft-ssh-${process.pid}-${randomBytes(6).toString("hex")}.sock`;
|
|
1489
|
+
}
|
|
1490
|
+
function buildBaseSshArgs(config, controlPath) {
|
|
1491
|
+
const args = [
|
|
1492
|
+
"-p",
|
|
1493
|
+
String(config.port ?? 22),
|
|
1494
|
+
// BatchMode keeps the OS ssh non-interactive: agent/keys only, never a
|
|
1495
|
+
// password/passphrase prompt that would hang a headless API process.
|
|
1496
|
+
"-o",
|
|
1497
|
+
"BatchMode=yes",
|
|
1498
|
+
"-o",
|
|
1499
|
+
"StrictHostKeyChecking=accept-new",
|
|
1500
|
+
"-o",
|
|
1501
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
|
|
1502
|
+
"-o",
|
|
1503
|
+
"ServerAliveInterval=15",
|
|
1504
|
+
"-o",
|
|
1505
|
+
"ServerAliveCountMax=3",
|
|
1506
|
+
// One authenticated master; subsequent ssh calls attach to it.
|
|
1507
|
+
"-o",
|
|
1508
|
+
"ControlMaster=auto",
|
|
1509
|
+
"-o",
|
|
1510
|
+
`ControlPath=${controlPath}`,
|
|
1511
|
+
// Keep the master alive across brief idle gaps (the connection manager
|
|
1512
|
+
// caches the executor for ~5 min); dispose() tears it down explicitly.
|
|
1513
|
+
"-o",
|
|
1514
|
+
"ControlPersist=300"
|
|
1515
|
+
];
|
|
1516
|
+
if (config.sshJumpHost?.trim()) {
|
|
1517
|
+
args.push("-J", config.sshJumpHost.trim());
|
|
1518
|
+
}
|
|
1519
|
+
if (config.sshArgs?.trim()) {
|
|
1520
|
+
for (const token of config.sshArgs.trim().split(/\s+/)) {
|
|
1521
|
+
if (token) args.push(token);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return args;
|
|
1525
|
+
}
|
|
1526
|
+
function sshTarget(config) {
|
|
1527
|
+
return `${config.username ?? "root"}@${config.host}`;
|
|
1528
|
+
}
|
|
1529
|
+
function sshChildEnv(config) {
|
|
1530
|
+
const env = { ...process.env };
|
|
1531
|
+
if (config.sshAgent) {
|
|
1532
|
+
env.SSH_AUTH_SOCK = config.sshAgent;
|
|
1533
|
+
}
|
|
1534
|
+
return env;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ../../packages/adapters/src/system/reverse-tunnel.ts
|
|
1538
|
+
import { execFile } from "child_process";
|
|
1539
|
+
import { createServer } from "net";
|
|
1540
|
+
import { promisify } from "util";
|
|
1541
|
+
var execFileAsync = promisify(execFile);
|
|
1542
|
+
async function openSystemSshReverseTunnel(opts) {
|
|
1543
|
+
const server = createServer((socket) => opts.onConnection(socket));
|
|
1544
|
+
await new Promise((resolve, reject) => {
|
|
1545
|
+
server.once("error", reject);
|
|
1546
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1547
|
+
server.removeListener("error", reject);
|
|
1548
|
+
resolve();
|
|
1549
|
+
});
|
|
1550
|
+
});
|
|
1551
|
+
const localPort = server.address().port;
|
|
1552
|
+
const closeServer = () => new Promise((resolve) => server.close(() => resolve()));
|
|
1553
|
+
const sshBin = opts.sshBin ?? "ssh";
|
|
1554
|
+
const forwardSpec = (remote) => `127.0.0.1:${remote}:127.0.0.1:${localPort}`;
|
|
1555
|
+
let remotePort;
|
|
1556
|
+
try {
|
|
1557
|
+
const { stdout } = await execFileAsync(
|
|
1558
|
+
sshBin,
|
|
1559
|
+
[...opts.baseArgs, "-O", "forward", "-R", forwardSpec(0), opts.target],
|
|
1560
|
+
{ env: opts.env }
|
|
1561
|
+
);
|
|
1562
|
+
remotePort = Number.parseInt(stdout.trim(), 10);
|
|
1563
|
+
if (!Number.isInteger(remotePort) || remotePort <= 0) {
|
|
1564
|
+
throw new Error(
|
|
1565
|
+
`ssh -O forward returned no port (stdout: ${JSON.stringify(stdout.trim())})`
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
await closeServer();
|
|
1570
|
+
throw err;
|
|
1571
|
+
}
|
|
1572
|
+
let closed = false;
|
|
1573
|
+
const close = async () => {
|
|
1574
|
+
if (closed) return;
|
|
1575
|
+
closed = true;
|
|
1576
|
+
await execFileAsync(
|
|
1577
|
+
sshBin,
|
|
1578
|
+
[
|
|
1579
|
+
...opts.baseArgs,
|
|
1580
|
+
"-O",
|
|
1581
|
+
"cancel",
|
|
1582
|
+
"-R",
|
|
1583
|
+
forwardSpec(remotePort),
|
|
1584
|
+
opts.target
|
|
1585
|
+
],
|
|
1586
|
+
{ env: opts.env }
|
|
1587
|
+
).catch(() => {
|
|
1588
|
+
});
|
|
1589
|
+
await closeServer();
|
|
1590
|
+
};
|
|
1591
|
+
return { port: remotePort, close };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ../../packages/adapters/src/system/system-ssh-executor.ts
|
|
1595
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
1596
|
+
var remoteDirname2 = posix2.dirname;
|
|
1597
|
+
function abortError2(operation, signal) {
|
|
1598
|
+
const reason = signal.reason;
|
|
1599
|
+
const suffix = reason instanceof Error && reason.message ? `: ${reason.message}` : "";
|
|
1600
|
+
const error = new Error(`SSH ${operation} cancelled${suffix}`);
|
|
1601
|
+
error.name = "AbortError";
|
|
1602
|
+
return error;
|
|
1603
|
+
}
|
|
1604
|
+
function clampWindow2(value, fallback, min, max) {
|
|
1605
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
1606
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
1607
|
+
}
|
|
1608
|
+
function describeSshFailure(stderr, fallback) {
|
|
1609
|
+
const text = stderr.trim() || fallback;
|
|
1610
|
+
if (/permission denied|authentication failed|too many authentication failures/i.test(
|
|
1611
|
+
text
|
|
1612
|
+
)) {
|
|
1613
|
+
return new Error(`All configured authentication methods failed: ${text}`);
|
|
1614
|
+
}
|
|
1615
|
+
return new Error(text);
|
|
1616
|
+
}
|
|
1617
|
+
var SystemSshExecutor = class _SystemSshExecutor {
|
|
1618
|
+
config;
|
|
1619
|
+
abortScope = new AsyncLocalStorage2();
|
|
1620
|
+
controlPath = makeControlPath();
|
|
1621
|
+
/** Resolves once the ControlMaster connection is established. */
|
|
1622
|
+
masterPromise = null;
|
|
1623
|
+
/** Remote-socket → local-forward-socket, one StreamLocal forward per target. */
|
|
1624
|
+
socketForwards = /* @__PURE__ */ new Map();
|
|
1625
|
+
localSockets = /* @__PURE__ */ new Set();
|
|
1626
|
+
disconnectListeners = /* @__PURE__ */ new Set();
|
|
1627
|
+
disposed = false;
|
|
1628
|
+
/** Prefix applied to every remote command - keeps dpkg non-interactive. */
|
|
1629
|
+
static ENV_PREFIX = "export DEBIAN_FRONTEND=noninteractive DPKG_FORCE=confnew && ";
|
|
1630
|
+
constructor(config) {
|
|
1631
|
+
if (!config.host) {
|
|
1632
|
+
throw new Error("System SSH executor requires a host.");
|
|
1633
|
+
}
|
|
1634
|
+
this.config = config;
|
|
1635
|
+
}
|
|
1636
|
+
runWithAbortSignal(signal, fn) {
|
|
1637
|
+
if (signal.aborted) return Promise.reject(abortError2("operation", signal));
|
|
1638
|
+
return this.abortScope.run(signal, async () => {
|
|
1639
|
+
this.throwIfAborted("operation");
|
|
1640
|
+
return fn();
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
operationSignal() {
|
|
1644
|
+
return this.abortScope.getStore();
|
|
1645
|
+
}
|
|
1646
|
+
resolvedSignal(explicit) {
|
|
1647
|
+
const ambient = this.operationSignal();
|
|
1648
|
+
if (!explicit || explicit === ambient) return ambient ?? explicit;
|
|
1649
|
+
if (!ambient) return explicit;
|
|
1650
|
+
return AbortSignal.any([explicit, ambient]);
|
|
1651
|
+
}
|
|
1652
|
+
throwIfAborted(operation, signal = this.operationSignal()) {
|
|
1653
|
+
if (signal?.aborted) throw abortError2(operation, signal);
|
|
1654
|
+
}
|
|
1655
|
+
baseArgs() {
|
|
1656
|
+
return buildBaseSshArgs(this.config, this.controlPath);
|
|
1657
|
+
}
|
|
1658
|
+
onDisconnect(cb) {
|
|
1659
|
+
this.disconnectListeners.add(cb);
|
|
1660
|
+
return () => {
|
|
1661
|
+
this.disconnectListeners.delete(cb);
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* On an SSH-level failure (exit 255 — which OpenSSH uses for ANY ssh-level
|
|
1666
|
+
* error: a dropped transport AND an auth failure alike, with no granular
|
|
1667
|
+
* code to tell them apart), authoritatively probe the ControlMaster with
|
|
1668
|
+
* `ssh -O check` instead of guessing from locale/version-dependent stderr.
|
|
1669
|
+
* Only a genuinely-dead master counts as a disconnect: we null it so the
|
|
1670
|
+
* next op reopens, and notify subscribers so the manager can react.
|
|
1671
|
+
*/
|
|
1672
|
+
async maybeSignalDisconnect(code) {
|
|
1673
|
+
if (code !== 255 || this.disposed) return;
|
|
1674
|
+
if (!this.masterPromise) return;
|
|
1675
|
+
if (await this.isMasterAlive()) return;
|
|
1676
|
+
this.masterPromise = null;
|
|
1677
|
+
const err = new SshDisconnectedError("SSH master connection lost");
|
|
1678
|
+
for (const cb of [...this.disconnectListeners]) {
|
|
1679
|
+
try {
|
|
1680
|
+
cb(err);
|
|
1681
|
+
} catch {
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
/** Authoritative ControlMaster liveness check: `ssh -O check` exits 0
|
|
1686
|
+
* ("Master running") when the multiplex socket is alive, non-zero when it's
|
|
1687
|
+
* missing/dead. Immune to stderr locale/version drift. */
|
|
1688
|
+
async isMasterAlive() {
|
|
1689
|
+
try {
|
|
1690
|
+
await execFileAsync2(
|
|
1691
|
+
"ssh",
|
|
1692
|
+
[...this.baseArgs(), "-O", "check", sshTarget(this.config)],
|
|
1693
|
+
{
|
|
1694
|
+
timeout: 5e3,
|
|
1695
|
+
env: sshChildEnv(this.config)
|
|
1696
|
+
}
|
|
1697
|
+
);
|
|
1698
|
+
return true;
|
|
1699
|
+
} catch {
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
/** Open (once) the multiplexed master connection. Authenticates here. */
|
|
1704
|
+
async ensureMaster() {
|
|
1705
|
+
if (this.masterPromise) return this.masterPromise;
|
|
1706
|
+
this.masterPromise = (async () => {
|
|
1707
|
+
try {
|
|
1708
|
+
await execFileAsync2(
|
|
1709
|
+
"ssh",
|
|
1710
|
+
[...this.baseArgs(), "-fN", sshTarget(this.config)],
|
|
1711
|
+
{
|
|
1712
|
+
timeout: 3e4,
|
|
1713
|
+
env: sshChildEnv(this.config)
|
|
1714
|
+
}
|
|
1715
|
+
);
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
this.masterPromise = null;
|
|
1718
|
+
const e = err;
|
|
1719
|
+
throw describeSshFailure(e.stderr ?? "", safeErrorMessage(err));
|
|
1720
|
+
}
|
|
1721
|
+
})();
|
|
1722
|
+
return this.masterPromise;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Open a reverse tunnel over the ControlMaster: the remote binds an ephemeral
|
|
1726
|
+
* 127.0.0.1 port whose connections arrive here as duplex streams. The
|
|
1727
|
+
* system-ssh counterpart to `SshExecutor.reverseForward` (ssh2) — same shape,
|
|
1728
|
+
* so `CommandExecutor.reverseForward` consumers (the git-credential relay,
|
|
1729
|
+
* future callers) work over agent auth too. Delegates to the generic
|
|
1730
|
+
* `openSystemSshReverseTunnel`.
|
|
1731
|
+
*/
|
|
1732
|
+
async reverseForward(onConnection) {
|
|
1733
|
+
await this.ensureMaster();
|
|
1734
|
+
return openSystemSshReverseTunnel({
|
|
1735
|
+
baseArgs: this.baseArgs(),
|
|
1736
|
+
target: sshTarget(this.config),
|
|
1737
|
+
env: sshChildEnv(this.config),
|
|
1738
|
+
onConnection
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
/** Run a remote command, resolving with stdout/stderr/exit code (never rejects on non-zero). */
|
|
1742
|
+
async runSsh(remoteCommand, opts) {
|
|
1743
|
+
const signal = this.operationSignal();
|
|
1744
|
+
this.throwIfAborted("command", signal);
|
|
1745
|
+
await this.ensureMaster();
|
|
1746
|
+
this.throwIfAborted("command", signal);
|
|
1747
|
+
return new Promise((resolve, reject) => {
|
|
1748
|
+
const child = spawn2(
|
|
1749
|
+
"ssh",
|
|
1750
|
+
[...this.baseArgs(), sshTarget(this.config), remoteCommand],
|
|
1751
|
+
{
|
|
1752
|
+
env: sshChildEnv(this.config),
|
|
1753
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1754
|
+
}
|
|
1755
|
+
);
|
|
1756
|
+
let stdout = "";
|
|
1757
|
+
let stderr = "";
|
|
1758
|
+
let timedOut = false;
|
|
1759
|
+
let settled = false;
|
|
1760
|
+
let timer = null;
|
|
1761
|
+
const cleanup = () => {
|
|
1762
|
+
if (timer) clearTimeout(timer);
|
|
1763
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1764
|
+
};
|
|
1765
|
+
const finish = (act) => {
|
|
1766
|
+
if (settled) return;
|
|
1767
|
+
settled = true;
|
|
1768
|
+
cleanup();
|
|
1769
|
+
act();
|
|
1770
|
+
};
|
|
1771
|
+
const onAbort = () => {
|
|
1772
|
+
try {
|
|
1773
|
+
child.kill("SIGKILL");
|
|
1774
|
+
} catch {
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1778
|
+
if (opts?.timeout) {
|
|
1779
|
+
timer = setTimeout(() => {
|
|
1780
|
+
timedOut = true;
|
|
1781
|
+
child.kill("SIGKILL");
|
|
1782
|
+
}, opts.timeout);
|
|
1783
|
+
timer.unref?.();
|
|
1784
|
+
}
|
|
1785
|
+
child.stdout.on("data", (d) => {
|
|
1786
|
+
stdout += d.toString();
|
|
1787
|
+
});
|
|
1788
|
+
child.stderr.on("data", (d) => {
|
|
1789
|
+
stderr += d.toString();
|
|
1790
|
+
});
|
|
1791
|
+
child.on("error", (e) => {
|
|
1792
|
+
finish(() => reject(new Error(`ssh failed to start: ${e.message}`)));
|
|
1793
|
+
});
|
|
1794
|
+
child.on("close", (code) => {
|
|
1795
|
+
const c = code ?? 1;
|
|
1796
|
+
void this.maybeSignalDisconnect(c).catch(() => {
|
|
1797
|
+
});
|
|
1798
|
+
finish(() => {
|
|
1799
|
+
if (signal?.aborted) reject(abortError2("command", signal));
|
|
1800
|
+
else resolve({ stdout, stderr, code: c, timedOut });
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
if (signal?.aborted) {
|
|
1804
|
+
onAbort();
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (opts?.input !== void 0) {
|
|
1808
|
+
child.stdin.write(opts.input);
|
|
1809
|
+
}
|
|
1810
|
+
child.stdin.end();
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
async exec(command, opts) {
|
|
1814
|
+
const timeout = opts?.timeout ?? 3e4;
|
|
1815
|
+
const res = await this.runSsh(_SystemSshExecutor.ENV_PREFIX + command, {
|
|
1816
|
+
timeout
|
|
1817
|
+
});
|
|
1818
|
+
if (res.timedOut) {
|
|
1819
|
+
throw new Error(
|
|
1820
|
+
`Command timed out after ${timeout}ms: ${commandForError(command)}`
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
if (res.code !== 0) {
|
|
1824
|
+
if (res.code === 255)
|
|
1825
|
+
throw describeSshFailure(res.stderr, `Exit code ${res.code}`);
|
|
1826
|
+
throw new Error(res.stderr.trim() || `Exit code ${res.code}`);
|
|
1827
|
+
}
|
|
1828
|
+
return res.stdout.trim();
|
|
1829
|
+
}
|
|
1830
|
+
async streamExec(command, onLog, opts) {
|
|
1831
|
+
await this.ensureMaster();
|
|
1832
|
+
const signal = this.resolvedSignal(opts?.signal);
|
|
1833
|
+
if (signal?.aborted) return { code: 0, output: "" };
|
|
1834
|
+
return new Promise((resolve) => {
|
|
1835
|
+
const child = spawn2(
|
|
1836
|
+
"ssh",
|
|
1837
|
+
[
|
|
1838
|
+
...this.baseArgs(),
|
|
1839
|
+
sshTarget(this.config),
|
|
1840
|
+
_SystemSshExecutor.ENV_PREFIX + command
|
|
1841
|
+
],
|
|
1842
|
+
{ env: sshChildEnv(this.config), stdio: ["ignore", "pipe", "pipe"] }
|
|
1843
|
+
);
|
|
1844
|
+
let aborted = false;
|
|
1845
|
+
const onAbort = () => {
|
|
1846
|
+
aborted = true;
|
|
1847
|
+
child.kill("SIGKILL");
|
|
1848
|
+
};
|
|
1849
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1850
|
+
const chunks = [];
|
|
1851
|
+
const onChunk = (chunk, level) => {
|
|
1852
|
+
const text = chunk.toString();
|
|
1853
|
+
if (!text) return;
|
|
1854
|
+
chunks.push(text);
|
|
1855
|
+
onLog(logEntry(text, level, chunk.toString("base64")));
|
|
1856
|
+
};
|
|
1857
|
+
child.stdout.on("data", (chunk) => onChunk(chunk, "info"));
|
|
1858
|
+
child.stderr.on("data", (chunk) => onChunk(chunk, "warn"));
|
|
1859
|
+
child.on("error", (err) => {
|
|
1860
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1861
|
+
onLog(logEntry(`Process error: ${err.message}`, "error"));
|
|
1862
|
+
resolve({ code: 1, output: err.message });
|
|
1863
|
+
});
|
|
1864
|
+
child.on("close", (code) => {
|
|
1865
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1866
|
+
const c = aborted ? 0 : code ?? 1;
|
|
1867
|
+
void this.maybeSignalDisconnect(c).catch(() => {
|
|
1868
|
+
});
|
|
1869
|
+
resolve({ code: c, output: chunks.join("") });
|
|
1870
|
+
});
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
async writeFile(path, content, opts) {
|
|
1874
|
+
const target = sq(path);
|
|
1875
|
+
const prepare = opts?.mode === void 0 ? `cat > ${target}` : `: > ${target} && chmod ${opts.mode.toString(8)} ${target} && cat > ${target}`;
|
|
1876
|
+
const remoteCommand = `mkdir -p ${sq(remoteDirname2(path))} && ${prepare}`;
|
|
1877
|
+
const res = await this.runSsh(remoteCommand, { input: content });
|
|
1878
|
+
if (res.code !== 0) {
|
|
1879
|
+
throw new Error(
|
|
1880
|
+
res.stderr.trim() || `Failed to write ${path} (exit ${res.code})`
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
async rename(from, to) {
|
|
1885
|
+
await this.exec(`mv ${sq(from)} ${sq(to)}`);
|
|
1886
|
+
}
|
|
1887
|
+
async readFile(path) {
|
|
1888
|
+
const res = await this.runSsh(`cat ${sq(path)}`);
|
|
1889
|
+
if (res.code !== 0) {
|
|
1890
|
+
throw new Error(
|
|
1891
|
+
res.stderr.trim() || `Failed to read ${path} (exit ${res.code})`
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
return res.stdout;
|
|
1895
|
+
}
|
|
1896
|
+
async exists(path) {
|
|
1897
|
+
const res = await this.runSsh(`test -e ${sq(path)}`);
|
|
1898
|
+
return res.code === 0;
|
|
1899
|
+
}
|
|
1900
|
+
async mkdir(path) {
|
|
1901
|
+
await this.exec(`mkdir -p ${sq(path)}`);
|
|
1902
|
+
}
|
|
1903
|
+
async rm(path) {
|
|
1904
|
+
try {
|
|
1905
|
+
await this.exec(`rm -rf ${sq(path)}`);
|
|
1906
|
+
} catch {
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
/** Pipe a local command's stdout into a remote command's stdin over ssh. */
|
|
1910
|
+
async pipeLocal(localCmd, remoteCmd, onLog, onBytes) {
|
|
1911
|
+
await this.ensureMaster();
|
|
1912
|
+
return new Promise((resolve, reject) => {
|
|
1913
|
+
onLog?.(logEntry(`local: ${localCmd}`));
|
|
1914
|
+
const remote = spawn2(
|
|
1915
|
+
"ssh",
|
|
1916
|
+
[...this.baseArgs(), sshTarget(this.config), remoteCmd],
|
|
1917
|
+
{
|
|
1918
|
+
env: sshChildEnv(this.config),
|
|
1919
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1920
|
+
}
|
|
1921
|
+
);
|
|
1922
|
+
const local = spawn2("sh", ["-c", localCmd], {
|
|
1923
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1924
|
+
});
|
|
1925
|
+
if (onBytes) {
|
|
1926
|
+
local.stdout.on("data", (chunk) => onBytes(chunk.length));
|
|
1927
|
+
}
|
|
1928
|
+
local.stdout.pipe(remote.stdin);
|
|
1929
|
+
local.stderr.on("data", (d) => {
|
|
1930
|
+
const text = d.toString().trim();
|
|
1931
|
+
if (text) onLog?.(logEntry(`local stderr: ${text}`, "warn"));
|
|
1932
|
+
});
|
|
1933
|
+
remote.stderr.on("data", (d) => {
|
|
1934
|
+
const text = d.toString().trim();
|
|
1935
|
+
if (text) onLog?.(logEntry(`remote stderr: ${text}`, "warn"));
|
|
1936
|
+
});
|
|
1937
|
+
local.on(
|
|
1938
|
+
"error",
|
|
1939
|
+
(e) => reject(new Error(`Local process failed to start: ${e.message}`))
|
|
1940
|
+
);
|
|
1941
|
+
remote.on(
|
|
1942
|
+
"error",
|
|
1943
|
+
(e) => reject(new Error(`ssh failed to start: ${e.message}`))
|
|
1944
|
+
);
|
|
1945
|
+
remote.on("close", (code) => resolve({ code: code ?? 1 }));
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
async transferIn(localPath, remotePath, onLog, options) {
|
|
1949
|
+
const deps = {
|
|
1950
|
+
config: this.config,
|
|
1951
|
+
hasRemoteCommand: (c) => this.hasRemoteCommand(c)
|
|
1952
|
+
};
|
|
1953
|
+
const excludes = options?.excludes ?? [...TRANSFER_EXCLUDES];
|
|
1954
|
+
const { args: tarArgs, cleanup: cleanupTarList } = await prepareSourceTarArgs(localPath, {
|
|
1955
|
+
excludes,
|
|
1956
|
+
includes: options?.includes,
|
|
1957
|
+
alsoInclude: options?.alsoInclude
|
|
1958
|
+
});
|
|
1959
|
+
const tmpLocalDir = await mkdtemp3(join3(tmpdir3(), "kraft-xfer-"));
|
|
1960
|
+
const localArchive = join3(tmpLocalDir, "context.tar.gz");
|
|
1961
|
+
const remoteArchive = `${remotePath}.kraft-xfer.tar.gz`;
|
|
1962
|
+
try {
|
|
1963
|
+
onLog?.(logEntry("Packing source into a single archive..."));
|
|
1964
|
+
await packLocalArchive(tarArgs, localArchive);
|
|
1965
|
+
const totalBytes = (await stat2(localArchive)).size;
|
|
1966
|
+
await this.exec(`mkdir -p ${sq(remoteDirname2(remoteArchive))}`);
|
|
1967
|
+
const rsync = await canUseRemoteRsync(deps);
|
|
1968
|
+
if (rsync.ok) {
|
|
1969
|
+
onLog?.(
|
|
1970
|
+
logEntry(
|
|
1971
|
+
`Uploading ${formatBytes(totalBytes)} archive via rsync (resumable)...`
|
|
1972
|
+
)
|
|
1973
|
+
);
|
|
1974
|
+
await uploadFileWithRsync(localArchive, remoteArchive, deps, onLog);
|
|
1975
|
+
} else {
|
|
1976
|
+
onLog?.(
|
|
1977
|
+
logEntry(
|
|
1978
|
+
`Uploading ${formatBytes(totalBytes)} archive over the SSH connection \u2014 ${rsync.reason}.`
|
|
1979
|
+
)
|
|
1980
|
+
);
|
|
1981
|
+
await this.streamArchiveWithRetry(localArchive, remoteArchive, onLog);
|
|
1982
|
+
}
|
|
1983
|
+
await extractRemoteArchive(
|
|
1984
|
+
(command) => this.exec(command),
|
|
1985
|
+
remoteArchive,
|
|
1986
|
+
remotePath,
|
|
1987
|
+
totalBytes,
|
|
1988
|
+
onLog
|
|
1989
|
+
);
|
|
1990
|
+
} finally {
|
|
1991
|
+
await cleanupTarList().catch(() => {
|
|
1992
|
+
});
|
|
1993
|
+
await fsRm3(tmpLocalDir, { recursive: true, force: true }).catch(() => {
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/** Stream a single local file over the ControlMaster, retrying from 0 on
|
|
1998
|
+
* failure (the `cat >` truncates, so there is no partial to resume). */
|
|
1999
|
+
async streamArchiveWithRetry(localArchive, remoteArchive, onLog) {
|
|
2000
|
+
const MAX_ATTEMPTS = 3;
|
|
2001
|
+
let lastErr = null;
|
|
2002
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
2003
|
+
if (attempt > 1)
|
|
2004
|
+
onLog?.(
|
|
2005
|
+
logEntry(
|
|
2006
|
+
`Retrying upload (attempt ${attempt}/${MAX_ATTEMPTS})...`,
|
|
2007
|
+
"warn"
|
|
2008
|
+
)
|
|
2009
|
+
);
|
|
2010
|
+
try {
|
|
2011
|
+
const { code } = await this.pipeLocal(
|
|
2012
|
+
`cat ${sq(localArchive)}`,
|
|
2013
|
+
`cat > ${sq(remoteArchive)}`,
|
|
2014
|
+
onLog
|
|
2015
|
+
);
|
|
2016
|
+
if (code === 0) return;
|
|
2017
|
+
lastErr = new Error(`archive upload failed (exit ${code})`);
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
throw lastErr ?? new Error("archive upload failed");
|
|
2023
|
+
}
|
|
2024
|
+
async hasRemoteCommand(command) {
|
|
2025
|
+
try {
|
|
2026
|
+
const out = await this.exec(
|
|
2027
|
+
`command -v ${command} >/dev/null 2>&1 && echo ok`,
|
|
2028
|
+
{ timeout: 5e3 }
|
|
2029
|
+
);
|
|
2030
|
+
return out.trim() === "ok";
|
|
2031
|
+
} catch {
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
async forwardPort(remoteHost, remotePort) {
|
|
2036
|
+
await this.ensureMaster();
|
|
2037
|
+
const child = spawn2(
|
|
2038
|
+
"ssh",
|
|
2039
|
+
[
|
|
2040
|
+
...this.baseArgs(),
|
|
2041
|
+
"-W",
|
|
2042
|
+
`${remoteHost}:${remotePort}`,
|
|
2043
|
+
sshTarget(this.config)
|
|
2044
|
+
],
|
|
2045
|
+
{ env: sshChildEnv(this.config), stdio: ["pipe", "pipe", "ignore"] }
|
|
2046
|
+
);
|
|
2047
|
+
const duplex = Duplex.from({
|
|
2048
|
+
writable: child.stdin,
|
|
2049
|
+
readable: child.stdout
|
|
2050
|
+
});
|
|
2051
|
+
duplex.on("close", () => {
|
|
2052
|
+
try {
|
|
2053
|
+
child.kill();
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
child.on("exit", () => {
|
|
2058
|
+
duplex.destroy();
|
|
2059
|
+
});
|
|
2060
|
+
child.on("error", (e) => {
|
|
2061
|
+
duplex.destroy(e);
|
|
2062
|
+
});
|
|
2063
|
+
return duplex;
|
|
2064
|
+
}
|
|
2065
|
+
/** Ensure a StreamLocal forward (local unix socket → remote socket) on the master. */
|
|
2066
|
+
async ensureSocketForward(remoteSocket) {
|
|
2067
|
+
let pending = this.socketForwards.get(remoteSocket);
|
|
2068
|
+
if (!pending) {
|
|
2069
|
+
pending = (async () => {
|
|
2070
|
+
await this.ensureMaster();
|
|
2071
|
+
const localSocket = `/tmp/kraft-fwd-${process.pid}-${randomBytes2(6).toString("hex")}.sock`;
|
|
2072
|
+
await execFileAsync2(
|
|
2073
|
+
"ssh",
|
|
2074
|
+
[
|
|
2075
|
+
...this.baseArgs(),
|
|
2076
|
+
"-O",
|
|
2077
|
+
"forward",
|
|
2078
|
+
"-L",
|
|
2079
|
+
`${localSocket}:${remoteSocket}`,
|
|
2080
|
+
sshTarget(this.config)
|
|
2081
|
+
],
|
|
2082
|
+
{ timeout: 15e3, env: sshChildEnv(this.config) }
|
|
2083
|
+
);
|
|
2084
|
+
this.localSockets.add(localSocket);
|
|
2085
|
+
return localSocket;
|
|
2086
|
+
})();
|
|
2087
|
+
pending.catch(() => {
|
|
2088
|
+
if (this.socketForwards.get(remoteSocket) === pending) {
|
|
2089
|
+
this.socketForwards.delete(remoteSocket);
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
this.socketForwards.set(remoteSocket, pending);
|
|
2093
|
+
}
|
|
2094
|
+
return pending;
|
|
2095
|
+
}
|
|
2096
|
+
async forwardUnixSocket(socketPath) {
|
|
2097
|
+
const attempt = async () => {
|
|
2098
|
+
const localSocket = await this.ensureSocketForward(socketPath);
|
|
2099
|
+
const sock = netConnect(localSocket);
|
|
2100
|
+
await once(sock, "connect");
|
|
2101
|
+
return sock;
|
|
2102
|
+
};
|
|
2103
|
+
try {
|
|
2104
|
+
return await attempt();
|
|
2105
|
+
} catch {
|
|
2106
|
+
this.socketForwards.delete(socketPath);
|
|
2107
|
+
return attempt();
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Run a best-effort, fire-and-forget control command on the remote over the
|
|
2112
|
+
* existing master (no output captured, errors swallowed). Used for PTY
|
|
2113
|
+
* resize and marker cleanup.
|
|
2114
|
+
*/
|
|
2115
|
+
fireAndForget(command) {
|
|
2116
|
+
try {
|
|
2117
|
+
spawn2("ssh", [...this.baseArgs(), sshTarget(this.config), command], {
|
|
2118
|
+
env: sshChildEnv(this.config),
|
|
2119
|
+
stdio: "ignore"
|
|
2120
|
+
}).on("error", () => {
|
|
2121
|
+
});
|
|
2122
|
+
} catch {
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
async openShell(opts) {
|
|
2126
|
+
await this.ensureMaster();
|
|
2127
|
+
const cols = clampWindow2(opts?.cols, 80, 1, 1e3);
|
|
2128
|
+
const rows = clampWindow2(opts?.rows, 24, 1, 500);
|
|
2129
|
+
const term = opts?.term || "xterm-256color";
|
|
2130
|
+
const ptyMarker = `/tmp/kraft-pty-${process.pid}-${randomBytes2(6).toString("hex")}`;
|
|
2131
|
+
const remoteInit = `tty > ${ptyMarker} 2>/dev/null; stty sane 2>/dev/null; stty cols ${cols} rows ${rows} 2>/dev/null; exec "\${SHELL:-/bin/sh}" -l`;
|
|
2132
|
+
const env = { ...sshChildEnv(this.config), TERM: term };
|
|
2133
|
+
const child = spawn2(
|
|
2134
|
+
"ssh",
|
|
2135
|
+
[...this.baseArgs(), "-tt", sshTarget(this.config), remoteInit],
|
|
2136
|
+
{
|
|
2137
|
+
env,
|
|
2138
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2139
|
+
}
|
|
2140
|
+
);
|
|
2141
|
+
child.stdin.on("error", () => {
|
|
2142
|
+
});
|
|
2143
|
+
child.stdout.on("error", () => {
|
|
2144
|
+
});
|
|
2145
|
+
child.stderr.on("error", () => {
|
|
2146
|
+
});
|
|
2147
|
+
const closeListeners = [];
|
|
2148
|
+
let closed = false;
|
|
2149
|
+
let resizeTimer = null;
|
|
2150
|
+
let pendingSize = null;
|
|
2151
|
+
const fireClose = (code, signal) => {
|
|
2152
|
+
if (closed) return;
|
|
2153
|
+
closed = true;
|
|
2154
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
2155
|
+
this.fireAndForget(`rm -f ${ptyMarker}`);
|
|
2156
|
+
for (const cb of closeListeners) {
|
|
2157
|
+
try {
|
|
2158
|
+
cb(code, signal);
|
|
2159
|
+
} catch {
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
child.on("exit", (code, signal) => fireClose(code, signal ?? void 0));
|
|
2164
|
+
child.on("error", () => fireClose(null));
|
|
2165
|
+
return {
|
|
2166
|
+
stdin: child.stdin,
|
|
2167
|
+
stdout: child.stdout,
|
|
2168
|
+
stderr: child.stderr,
|
|
2169
|
+
setWindow: (c, r) => {
|
|
2170
|
+
pendingSize = {
|
|
2171
|
+
c: clampWindow2(c, 80, 1, 1e3),
|
|
2172
|
+
r: clampWindow2(r, 24, 1, 500)
|
|
2173
|
+
};
|
|
2174
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
2175
|
+
resizeTimer = setTimeout(() => {
|
|
2176
|
+
const size = pendingSize;
|
|
2177
|
+
pendingSize = null;
|
|
2178
|
+
if (!size || closed) return;
|
|
2179
|
+
this.fireAndForget(
|
|
2180
|
+
`for i in 1 2 3 4 5 6; do P=$(cat ${ptyMarker} 2>/dev/null); if [ -n "$P" ]; then stty -F "$P" cols ${size.c} rows ${size.r} 2>/dev/null || stty -f "$P" cols ${size.c} rows ${size.r} 2>/dev/null; break; fi; sleep 0.2; done`
|
|
2181
|
+
);
|
|
2182
|
+
}, 60);
|
|
2183
|
+
resizeTimer.unref?.();
|
|
2184
|
+
},
|
|
2185
|
+
close: (_signal) => {
|
|
2186
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
2187
|
+
try {
|
|
2188
|
+
child.kill();
|
|
2189
|
+
} catch {
|
|
2190
|
+
}
|
|
2191
|
+
},
|
|
2192
|
+
onClose: (cb) => {
|
|
2193
|
+
closeListeners.push(cb);
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
async dispose() {
|
|
2198
|
+
if (this.disposed) return;
|
|
2199
|
+
this.disposed = true;
|
|
2200
|
+
if (this.masterPromise) {
|
|
2201
|
+
try {
|
|
2202
|
+
await execFileAsync2(
|
|
2203
|
+
"ssh",
|
|
2204
|
+
[...this.baseArgs(), "-O", "exit", sshTarget(this.config)],
|
|
2205
|
+
{
|
|
2206
|
+
timeout: 5e3,
|
|
2207
|
+
env: sshChildEnv(this.config)
|
|
2208
|
+
}
|
|
2209
|
+
);
|
|
2210
|
+
} catch {
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
this.masterPromise = null;
|
|
2214
|
+
this.socketForwards.clear();
|
|
2215
|
+
for (const socket of this.localSockets) {
|
|
2216
|
+
await unlink(socket).catch(() => {
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
this.localSockets.clear();
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
|
|
2223
|
+
// ../../packages/adapters/src/system/executor.ts
|
|
2224
|
+
var localExecutor = new LocalExecutor();
|
|
2225
|
+
function createExecutor(ssh) {
|
|
2226
|
+
if (ssh) {
|
|
2227
|
+
if (ssh.useSystemSsh) return new SystemSshExecutor(ssh);
|
|
2228
|
+
return new SshExecutor(ssh);
|
|
2229
|
+
}
|
|
2230
|
+
return localExecutor;
|
|
2231
|
+
}
|
|
2232
|
+
var hostControlOverride = null;
|
|
2233
|
+
function setHostControlOverride(disabled) {
|
|
2234
|
+
hostControlOverride = disabled;
|
|
2235
|
+
}
|
|
2236
|
+
function hostControlDisabled() {
|
|
2237
|
+
return hostControlOverride ?? process.env.KRAFT_HOST_CONTROL?.trim().toLowerCase() === "false";
|
|
2238
|
+
}
|
|
2239
|
+
function runningInContainer() {
|
|
2240
|
+
if (process.env.KRAFT_IN_CONTAINER?.trim().toLowerCase() === "true")
|
|
2241
|
+
return true;
|
|
2242
|
+
try {
|
|
2243
|
+
if (existsSync("/.dockerenv")) return true;
|
|
2244
|
+
} catch {
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
return /\b(docker|containerd|podman|kubepods)\b/.test(
|
|
2248
|
+
readFileSync("/proc/1/cgroup", "utf8")
|
|
2249
|
+
);
|
|
2250
|
+
} catch {
|
|
2251
|
+
return false;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
function hostChannelPort() {
|
|
2255
|
+
const raw = Number(process.env.KRAFT_HOST_SSH_PORT || "22");
|
|
2256
|
+
return Number.isInteger(raw) && raw > 0 && raw < 65536 ? raw : 22;
|
|
2257
|
+
}
|
|
2258
|
+
function hostChannelUser() {
|
|
2259
|
+
return hostChannelAccount(process.env);
|
|
2260
|
+
}
|
|
2261
|
+
function readHostChannelKey(keyPath) {
|
|
2262
|
+
let contents;
|
|
2263
|
+
try {
|
|
2264
|
+
contents = readFileSync(keyPath, "utf8");
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2267
|
+
return {
|
|
2268
|
+
reason: `Cannot read the host SSH key at ${keyPath} (${detail}).`
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
if (!contents.trim())
|
|
2272
|
+
return { reason: `The host SSH key at ${keyPath} is empty.` };
|
|
2273
|
+
return { key: contents };
|
|
2274
|
+
}
|
|
2275
|
+
function containerBridgeCidr() {
|
|
2276
|
+
for (const addrs of Object.values(networkInterfaces())) {
|
|
2277
|
+
for (const addr of addrs ?? []) {
|
|
2278
|
+
if (addr.family !== "IPv4" || addr.internal) continue;
|
|
2279
|
+
const prefix = Number(addr.cidr?.split("/")[1]);
|
|
2280
|
+
if (!Number.isInteger(prefix) || prefix < 8 || prefix > 32) continue;
|
|
2281
|
+
const octets = addr.address.split(".").map(Number);
|
|
2282
|
+
if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255))
|
|
2283
|
+
continue;
|
|
2284
|
+
const asInt = (octets[0] << 24 | octets[1] << 16 | octets[2] << 8 | octets[3]) >>> 0;
|
|
2285
|
+
const mask = 4294967295 << 32 - prefix >>> 0;
|
|
2286
|
+
const net = (asInt & mask) >>> 0;
|
|
2287
|
+
return `${net >>> 24 & 255}.${net >>> 16 & 255}.${net >>> 8 & 255}.${net & 255}/${prefix}`;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
return null;
|
|
2291
|
+
}
|
|
2292
|
+
function probeDetail(probe) {
|
|
2293
|
+
const { code, message } = probe;
|
|
2294
|
+
if (code && message && !message.includes(code)) return `${code}: ${message}`;
|
|
2295
|
+
return code ?? message;
|
|
2296
|
+
}
|
|
2297
|
+
function explainDialFailure(probe, host, port, target) {
|
|
2298
|
+
const { headline, body, firewallShaped } = explainHostChannelCause(
|
|
2299
|
+
probe.reason,
|
|
2300
|
+
{
|
|
2301
|
+
target,
|
|
2302
|
+
host,
|
|
2303
|
+
detail: probeDetail(probe)
|
|
2304
|
+
}
|
|
2305
|
+
);
|
|
2306
|
+
const hint = body ? `${headline}. ${body}` : `${headline}.`;
|
|
2307
|
+
if (!firewallShaped) return { hint };
|
|
2308
|
+
const cidr = containerBridgeCidr();
|
|
2309
|
+
return { hint, rule: hostFirewallRule("unknown", cidr ? [cidr] : [], port) };
|
|
2310
|
+
}
|
|
2311
|
+
var AUTH_MEMO_OK_MS = 3e4;
|
|
2312
|
+
var AUTH_MEMO_FAIL_MS = 5e3;
|
|
2313
|
+
var authMemo = null;
|
|
2314
|
+
function invalidateHostChannelAuth() {
|
|
2315
|
+
authMemo = null;
|
|
2316
|
+
}
|
|
2317
|
+
function probeForwarding(client, timeoutMs) {
|
|
2318
|
+
return new Promise((resolve) => {
|
|
2319
|
+
let settled = false;
|
|
2320
|
+
const finish = (verdict) => {
|
|
2321
|
+
if (settled) return;
|
|
2322
|
+
settled = true;
|
|
2323
|
+
clearTimeout(timer);
|
|
2324
|
+
resolve(verdict);
|
|
2325
|
+
};
|
|
2326
|
+
const timer = setTimeout(() => finish("unknown"), timeoutMs);
|
|
2327
|
+
timer.unref?.();
|
|
2328
|
+
try {
|
|
2329
|
+
client.forwardOut("127.0.0.1", 0, "127.0.0.1", 1, (err, stream) => {
|
|
2330
|
+
if (stream) {
|
|
2331
|
+
stream.destroy();
|
|
2332
|
+
finish("ok");
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
const reason = err?.reason;
|
|
2336
|
+
const message = err?.message;
|
|
2337
|
+
if (reason === 1 || reason === 3) return finish("blocked");
|
|
2338
|
+
if (typeof message === "string" && /administratively prohibited/i.test(message)) {
|
|
2339
|
+
return finish("blocked");
|
|
2340
|
+
}
|
|
2341
|
+
finish("ok");
|
|
2342
|
+
});
|
|
2343
|
+
} catch {
|
|
2344
|
+
finish("unknown");
|
|
2345
|
+
}
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
async function verifyHostChannelAuth(config) {
|
|
2349
|
+
const memoKey = `${config.username}@${config.host}:${config.port}#${config.privateKey.length}`;
|
|
2350
|
+
if (authMemo?.key === memoKey && authMemo.expires > Date.now()) {
|
|
2351
|
+
return authMemo.hint ? { hint: authMemo.hint, forwarding: authMemo.forwarding } : { forwarding: authMemo.forwarding };
|
|
2352
|
+
}
|
|
2353
|
+
let hint = null;
|
|
2354
|
+
let forwarding = "unknown";
|
|
2355
|
+
try {
|
|
2356
|
+
const { connectSshClient: connectSshClient2 } = await import("./ssh-client-NWQVRCV7.js");
|
|
2357
|
+
const client = await connectSshClient2({
|
|
2358
|
+
host: config.host,
|
|
2359
|
+
port: config.port,
|
|
2360
|
+
username: config.username,
|
|
2361
|
+
privateKey: config.privateKey,
|
|
2362
|
+
readyTimeoutMs: config.timeoutMs,
|
|
2363
|
+
// Load-bearing: it selects describeSshAuthFailure's host-channel wording, so the
|
|
2364
|
+
// hint an operator reads here is the same sentence the deploy log gives them.
|
|
2365
|
+
hostChannel: true
|
|
2366
|
+
});
|
|
2367
|
+
forwarding = await probeForwarding(
|
|
2368
|
+
client,
|
|
2369
|
+
config.timeoutMs
|
|
2370
|
+
);
|
|
2371
|
+
client.end();
|
|
2372
|
+
} catch (err) {
|
|
2373
|
+
if (isSshAuthError(err)) {
|
|
2374
|
+
hint = err instanceof Error ? err.message : HOST_CHANNEL_AUTH_REJECTED;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
authMemo = {
|
|
2378
|
+
key: memoKey,
|
|
2379
|
+
hint,
|
|
2380
|
+
forwarding,
|
|
2381
|
+
expires: Date.now() + (hint ? AUTH_MEMO_FAIL_MS : AUTH_MEMO_OK_MS)
|
|
2382
|
+
};
|
|
2383
|
+
return hint ? { hint, forwarding } : { forwarding };
|
|
2384
|
+
}
|
|
2385
|
+
async function hostChannelHealth(timeoutMs = 2500) {
|
|
2386
|
+
if (hostControlDisabled()) {
|
|
2387
|
+
return {
|
|
2388
|
+
ok: false,
|
|
2389
|
+
code: "disabled",
|
|
2390
|
+
hint: "Host control is off (KRAFT_HOST_CONTROL=false). Re-run `kraft up` without --no-host-control."
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
const host = process.env.KRAFT_HOST_SSH_HOST?.trim();
|
|
2394
|
+
if (!host) {
|
|
2395
|
+
if (runningInContainer()) {
|
|
2396
|
+
return {
|
|
2397
|
+
ok: false,
|
|
2398
|
+
code: "not_configured",
|
|
2399
|
+
hint: `${HOST_CHANNEL_UNPROVISIONED} ${HOST_CHANNEL_NOT_PROVISIONED}`
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
return { ok: true, code: "not_applicable" };
|
|
2403
|
+
}
|
|
2404
|
+
const port = hostChannelPort();
|
|
2405
|
+
const target = `${hostChannelUser()}@${host}:${port}`;
|
|
2406
|
+
const keyPath = process.env.KRAFT_HOST_SSH_KEY?.trim();
|
|
2407
|
+
let privateKey;
|
|
2408
|
+
if (keyPath) {
|
|
2409
|
+
const read = readHostChannelKey(keyPath);
|
|
2410
|
+
if (read.reason) {
|
|
2411
|
+
return {
|
|
2412
|
+
ok: false,
|
|
2413
|
+
code: "key_unreadable",
|
|
2414
|
+
host,
|
|
2415
|
+
port,
|
|
2416
|
+
target,
|
|
2417
|
+
hint: `${read.reason} ${HOST_CHANNEL_NOT_PROVISIONED}`
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
privateKey = read.key;
|
|
2421
|
+
}
|
|
2422
|
+
const probe = await probeTcpDetailed(host, port, timeoutMs);
|
|
2423
|
+
if (!probe.ok) {
|
|
2424
|
+
return {
|
|
2425
|
+
ok: false,
|
|
2426
|
+
code: "unreachable",
|
|
2427
|
+
host,
|
|
2428
|
+
port,
|
|
2429
|
+
target,
|
|
2430
|
+
cause: probe.reason,
|
|
2431
|
+
...explainDialFailure(probe, host, port, target)
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
let forwarding = "unknown";
|
|
2435
|
+
if (privateKey) {
|
|
2436
|
+
const verdict = await verifyHostChannelAuth({
|
|
2437
|
+
host,
|
|
2438
|
+
port,
|
|
2439
|
+
username: hostChannelUser(),
|
|
2440
|
+
privateKey,
|
|
2441
|
+
timeoutMs
|
|
2442
|
+
});
|
|
2443
|
+
if (verdict.hint) {
|
|
2444
|
+
return {
|
|
2445
|
+
ok: false,
|
|
2446
|
+
code: "auth_rejected",
|
|
2447
|
+
host,
|
|
2448
|
+
port,
|
|
2449
|
+
target,
|
|
2450
|
+
hint: verdict.hint
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
forwarding = verdict.forwarding;
|
|
2454
|
+
}
|
|
2455
|
+
return { ok: true, code: "ok", host, port, target, forwarding };
|
|
2456
|
+
}
|
|
2457
|
+
function unavailableExecutor(reason, code = "not_configured") {
|
|
2458
|
+
const refuse = async () => {
|
|
2459
|
+
throw new HostChannelUnavailableError(code, reason);
|
|
2460
|
+
};
|
|
2461
|
+
return {
|
|
2462
|
+
exec: refuse,
|
|
2463
|
+
streamExec: refuse,
|
|
2464
|
+
writeFile: refuse,
|
|
2465
|
+
readFile: refuse,
|
|
2466
|
+
exists: refuse,
|
|
2467
|
+
mkdir: refuse,
|
|
2468
|
+
rm: refuse,
|
|
2469
|
+
transferIn: refuse,
|
|
2470
|
+
// Nothing was ever opened, so releasing must not be the one call that throws.
|
|
2471
|
+
dispose: async () => {
|
|
2472
|
+
}
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
function createHostExecutor() {
|
|
2476
|
+
if (hostControlDisabled()) {
|
|
2477
|
+
throw new HostChannelUnavailableError(
|
|
2478
|
+
"disabled",
|
|
2479
|
+
"Host control is disabled on this instance (KRAFT_HOST_CONTROL=false). Re-run `kraft up` without --no-host-control to allow host operations."
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2482
|
+
const host = process.env.KRAFT_HOST_SSH_HOST?.trim();
|
|
2483
|
+
if (!host) {
|
|
2484
|
+
if (runningInContainer()) {
|
|
2485
|
+
throw new HostChannelUnavailableError(
|
|
2486
|
+
"not_configured",
|
|
2487
|
+
`This operation targets the HOST machine, but no host channel is configured (KRAFT_HOST_SSH_HOST is unset) and Kraft is running in a container \u2014 so it would have run inside the container instead, against the wrong filesystem. ${HOST_CHANNEL_NOT_PROVISIONED}`
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
return localExecutor;
|
|
2491
|
+
}
|
|
2492
|
+
const keyPath = process.env.KRAFT_HOST_SSH_KEY?.trim();
|
|
2493
|
+
if (!keyPath) {
|
|
2494
|
+
throw new HostChannelUnavailableError(
|
|
2495
|
+
"not_configured",
|
|
2496
|
+
`A host channel is configured (KRAFT_HOST_SSH_HOST) but KRAFT_HOST_SSH_KEY is unset, so there is no credential to connect to the host with. ${HOST_CHANNEL_NOT_PROVISIONED}`
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
const read = readHostChannelKey(keyPath);
|
|
2500
|
+
if (read.reason) {
|
|
2501
|
+
throw new HostChannelUnavailableError(
|
|
2502
|
+
"not_configured",
|
|
2503
|
+
`${read.reason} ${HOST_CHANNEL_NOT_PROVISIONED}`
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
return new SshExecutor({
|
|
2507
|
+
host,
|
|
2508
|
+
port: hostChannelPort(),
|
|
2509
|
+
username: hostChannelUser(),
|
|
2510
|
+
privateKey: read.key,
|
|
2511
|
+
// One hop over the docker bridge: it answers at once or it is being filtered.
|
|
2512
|
+
// 20s of silence per host operation reads as a hang, not as a failure.
|
|
2513
|
+
readyTimeoutMs: 8e3,
|
|
2514
|
+
hostChannel: true
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
export {
|
|
2519
|
+
SshExecutor,
|
|
2520
|
+
SystemSshExecutor,
|
|
2521
|
+
createExecutor,
|
|
2522
|
+
setHostControlOverride,
|
|
2523
|
+
hostControlDisabled,
|
|
2524
|
+
containerBridgeCidr,
|
|
2525
|
+
invalidateHostChannelAuth,
|
|
2526
|
+
hostChannelHealth,
|
|
2527
|
+
unavailableExecutor,
|
|
2528
|
+
createHostExecutor
|
|
2529
|
+
};
|