dsh-remote 0.8.46 → 0.8.48
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/LICENSE +21 -21
- package/README.en.md +361 -361
- package/README.md +368 -368
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +3637 -3637
- package/lib/credential.js +177 -177
- package/lib/deploy-tasks.js +142 -142
- package/lib/errors.js +87 -87
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -156
- package/lib/index.js +3779 -3747
- package/lib/openssh.js +838 -838
- package/lib/paths.js +225 -225
- package/lib/pool.js +757 -757
- package/lib/registry.js +95 -95
- package/lib/remote-fs.js +148 -143
- package/lib/routes-fs.js +233 -233
- package/lib/search.js +235 -230
- package/lib/sftp-surface.js +76 -76
- package/lib/sshconfig.js +454 -454
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/lib/web-deploy-skill.js +205 -205
- package/lib/web-deploy.js +694 -694
- package/node_modules/asn1/Jenkinsfile +65 -0
- package/node_modules/asn1/LICENSE +19 -0
- package/node_modules/asn1/README.md +50 -0
- package/node_modules/asn1/lib/ber/errors.js +13 -0
- package/node_modules/asn1/lib/ber/index.js +27 -0
- package/node_modules/asn1/lib/ber/reader.js +262 -0
- package/node_modules/asn1/lib/ber/types.js +36 -0
- package/node_modules/asn1/lib/ber/writer.js +317 -0
- package/node_modules/asn1/lib/index.js +20 -0
- package/node_modules/asn1/package.json +31 -0
- package/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/node_modules/buildcheck/.eslintrc.js +5 -0
- package/node_modules/buildcheck/.github/workflows/ci.yml +88 -0
- package/node_modules/buildcheck/.github/workflows/lint.yml +27 -0
- package/node_modules/buildcheck/.npmignore +3 -0
- package/node_modules/buildcheck/LICENSE +19 -0
- package/node_modules/buildcheck/README.md +106 -0
- package/node_modules/buildcheck/deps/Find-VisualStudio.cs +250 -0
- package/node_modules/buildcheck/lib/findvs.js +361 -0
- package/node_modules/buildcheck/lib/index.js +738 -0
- package/node_modules/buildcheck/package.json +36 -0
- package/node_modules/buildcheck/test/test.js +8 -0
- package/node_modules/cpu-features/.eslintrc.js +5 -0
- package/node_modules/cpu-features/.github/workflows/ci.yml +74 -0
- package/node_modules/cpu-features/.github/workflows/lint.yml +27 -0
- package/node_modules/cpu-features/LICENSE +19 -0
- package/node_modules/cpu-features/README.md +59 -0
- package/node_modules/cpu-features/binding.gyp +16 -0
- package/node_modules/cpu-features/buildcheck.js +32 -0
- package/node_modules/cpu-features/deps/cpu_features/.clang-format +4 -0
- package/node_modules/cpu-features/deps/cpu_features/.dockerignore +31 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/Dockerfile +5 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/aarch64_linux_cmake.yml +30 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_freebsd_cmake.yml +22 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_bazel.yml +26 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_cmake.yml +31 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_macos_cmake.yml +43 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_windows_cmake.yml +25 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/arm_linux_cmake.yml +31 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/clang_format.yml +24 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/mips_linux_cmake.yml +30 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/power_linux_cmake.yml +29 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/riscv_linux_cmake.yml +28 -0
- package/node_modules/cpu-features/deps/cpu_features/.github/workflows/s390x_linux_cmake.yml +27 -0
- package/node_modules/cpu-features/deps/cpu_features/.grenrc.yml +21 -0
- package/node_modules/cpu-features/deps/cpu_features/BUILD.bazel +329 -0
- package/node_modules/cpu-features/deps/cpu_features/CMakeLists.txt +261 -0
- package/node_modules/cpu-features/deps/cpu_features/CONTRIBUTING.md +23 -0
- package/node_modules/cpu-features/deps/cpu_features/LICENSE +230 -0
- package/node_modules/cpu-features/deps/cpu_features/README.md +272 -0
- package/node_modules/cpu-features/deps/cpu_features/WORKSPACE +19 -0
- package/node_modules/cpu-features/deps/cpu_features/bazel/ci/README.md +5 -0
- package/node_modules/cpu-features/deps/cpu_features/bazel/platforms.bzl +11 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesConfig.cmake.in +3 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesNdkCompatConfig.cmake.in +3 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/README.md +30 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/Makefile +252 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/README.md +40 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.dot +64 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.svg +312 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/generate_image.sh +7 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/amd64/Dockerfile +48 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/toolchain/Dockerfile +34 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/CMakeLists.txt +22 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/main.cpp +11 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/ci/vagrant/freebsd/Vagrantfile +107 -0
- package/node_modules/cpu-features/deps/cpu_features/cmake/googletest.CMakeLists.txt.in +15 -0
- package/node_modules/cpu-features/deps/cpu_features/cpu_features.gyp +105 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_cache_info.h +54 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_macros.h +384 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_aarch64.h +259 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_arm.h +121 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_mips.h +74 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_ppc.h +149 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_riscv.h +72 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_s390x.h +108 -0
- package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_x86.h +288 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/bit_utils.h +40 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/cpuid_x86.h +37 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/filesystem.h +39 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/hwcaps.h +240 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/stack_line_reader.h +49 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/string_view.h +110 -0
- package/node_modules/cpu-features/deps/cpu_features/include/internal/windows_utils.h +70 -0
- package/node_modules/cpu-features/deps/cpu_features/ndk_compat/CMakeLists.txt +60 -0
- package/node_modules/cpu-features/deps/cpu_features/ndk_compat/README.md +4 -0
- package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.c +205 -0
- package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.h +320 -0
- package/node_modules/cpu-features/deps/cpu_features/ndk_compat/ndk-compat-test.c +12 -0
- package/node_modules/cpu-features/deps/cpu_features/patches/0001-Add-Apple-Silicon-Support.patch +456 -0
- package/node_modules/cpu-features/deps/cpu_features/scripts/generate_badges.d +165 -0
- package/node_modules/cpu-features/deps/cpu_features/scripts/make_release.sh +75 -0
- package/node_modules/cpu-features/deps/cpu_features/scripts/run_integration.sh +490 -0
- package/node_modules/cpu-features/deps/cpu_features/scripts/test_integration.sh +79 -0
- package/node_modules/cpu-features/deps/cpu_features/src/copy.inl +19 -0
- package/node_modules/cpu-features/deps/cpu_features/src/define_introspection.inl +86 -0
- package/node_modules/cpu-features/deps/cpu_features/src/define_introspection_and_hwcaps.inl +26 -0
- package/node_modules/cpu-features/deps/cpu_features/src/equals.inl +22 -0
- package/node_modules/cpu-features/deps/cpu_features/src/filesystem.c +62 -0
- package/node_modules/cpu-features/deps/cpu_features/src/hwcaps.c +169 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64__base_implementation.inl +88 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_linux_or_android.c +79 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_macos_or_iphone.c +82 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_windows.c +138 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_arm_linux_or_android.c +212 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_mips_linux_or_android.c +93 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_ppc_linux.c +163 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_riscv_linux.c +111 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_s390x_linux.c +120 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_x86__base_implementation.inl +2055 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_freebsd.c +68 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_linux_or_android.c +58 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_macos.c +57 -0
- package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_windows.c +58 -0
- package/node_modules/cpu-features/deps/cpu_features/src/stack_line_reader.c +132 -0
- package/node_modules/cpu-features/deps/cpu_features/src/string_view.c +192 -0
- package/node_modules/cpu-features/deps/cpu_features/src/utils/list_cpu_features.c +461 -0
- package/node_modules/cpu-features/deps/cpu_features/test/CMakeLists.txt +114 -0
- package/node_modules/cpu-features/deps/cpu_features/test/bit_utils_test.cc +53 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_aarch64_test.cc +396 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_arm_test.cc +384 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_mips_test.cc +176 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_ppc_test.cc +131 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_riscv_test.cc +180 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_s390x_test.cc +82 -0
- package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_x86_test.cc +1729 -0
- package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.cc +103 -0
- package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.h +61 -0
- package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.cc +52 -0
- package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.h +31 -0
- package/node_modules/cpu-features/deps/cpu_features/test/stack_line_reader_test.cc +132 -0
- package/node_modules/cpu-features/deps/cpu_features/test/string_view_test.cc +202 -0
- package/node_modules/cpu-features/lib/index.js +5 -0
- package/node_modules/cpu-features/package.json +40 -0
- package/node_modules/cpu-features/src/binding.cc +151 -0
- package/node_modules/cpu-features/test/test.js +12 -0
- package/node_modules/nan/.github/workflows/ci.yml +46 -0
- package/node_modules/nan/.pre-commit-config.yaml +8 -0
- package/node_modules/nan/CHANGELOG.md +605 -0
- package/node_modules/nan/CMakeLists.txt +138 -0
- package/node_modules/nan/LICENSE.md +9 -0
- package/node_modules/nan/README.md +457 -0
- package/node_modules/nan/doc/asyncworker.md +146 -0
- package/node_modules/nan/doc/buffers.md +54 -0
- package/node_modules/nan/doc/callback.md +76 -0
- package/node_modules/nan/doc/converters.md +41 -0
- package/node_modules/nan/doc/errors.md +226 -0
- package/node_modules/nan/doc/json.md +62 -0
- package/node_modules/nan/doc/maybe_types.md +583 -0
- package/node_modules/nan/doc/methods.md +689 -0
- package/node_modules/nan/doc/new.md +147 -0
- package/node_modules/nan/doc/node_misc.md +123 -0
- package/node_modules/nan/doc/object_wrappers.md +263 -0
- package/node_modules/nan/doc/persistent.md +296 -0
- package/node_modules/nan/doc/scopes.md +73 -0
- package/node_modules/nan/doc/script.md +58 -0
- package/node_modules/nan/doc/string_bytes.md +81 -0
- package/node_modules/nan/doc/v8_internals.md +211 -0
- package/node_modules/nan/doc/v8_misc.md +85 -0
- package/node_modules/nan/include_dirs.js +1 -0
- package/node_modules/nan/nan.h +3202 -0
- package/node_modules/nan/nan_callbacks.h +141 -0
- package/node_modules/nan/nan_callbacks_12_inl.h +730 -0
- package/node_modules/nan/nan_callbacks_pre_12_inl.h +528 -0
- package/node_modules/nan/nan_converters.h +72 -0
- package/node_modules/nan/nan_converters_43_inl.h +68 -0
- package/node_modules/nan/nan_converters_pre_43_inl.h +42 -0
- package/node_modules/nan/nan_define_own_property_helper.h +29 -0
- package/node_modules/nan/nan_implementation_12_inl.h +446 -0
- package/node_modules/nan/nan_implementation_pre_12_inl.h +263 -0
- package/node_modules/nan/nan_json.h +166 -0
- package/node_modules/nan/nan_maybe_43_inl.h +360 -0
- package/node_modules/nan/nan_maybe_pre_43_inl.h +268 -0
- package/node_modules/nan/nan_new.h +340 -0
- package/node_modules/nan/nan_object_wrap.h +156 -0
- package/node_modules/nan/nan_persistent_12_inl.h +132 -0
- package/node_modules/nan/nan_persistent_pre_12_inl.h +242 -0
- package/node_modules/nan/nan_private.h +73 -0
- package/node_modules/nan/nan_scriptorigin.h +97 -0
- package/node_modules/nan/nan_string_bytes.h +305 -0
- package/node_modules/nan/nan_typedarray_contents.h +96 -0
- package/node_modules/nan/nan_weak.h +453 -0
- package/node_modules/nan/package.json +38 -0
- package/node_modules/nan/tools/1to2.js +412 -0
- package/node_modules/nan/tools/README.md +14 -0
- package/node_modules/nan/tools/package.json +19 -0
- package/node_modules/safer-buffer/LICENSE +21 -0
- package/node_modules/safer-buffer/Porting-Buffer.md +268 -0
- package/node_modules/safer-buffer/Readme.md +156 -0
- package/node_modules/safer-buffer/dangerous.js +58 -0
- package/node_modules/safer-buffer/package.json +34 -0
- package/node_modules/safer-buffer/safer.js +77 -0
- package/node_modules/safer-buffer/tests.js +406 -0
- package/node_modules/ssh2/.eslintignore +4 -0
- package/node_modules/ssh2/.eslintrc.js +5 -0
- package/node_modules/ssh2/.github/workflows/ci.yml +110 -0
- package/node_modules/ssh2/.github/workflows/lint.yml +27 -0
- package/node_modules/ssh2/LICENSE +19 -0
- package/node_modules/ssh2/README.md +1529 -0
- package/node_modules/ssh2/SFTP.md +413 -0
- package/node_modules/ssh2/examples/server-chat.js +238 -0
- package/node_modules/ssh2/examples/sftp-server-download-only.js +134 -0
- package/node_modules/ssh2/install.js +27 -0
- package/node_modules/ssh2/lib/Channel.js +295 -0
- package/node_modules/ssh2/lib/agent.js +1123 -0
- package/node_modules/ssh2/lib/client.js +2176 -0
- package/node_modules/ssh2/lib/http-agents.js +84 -0
- package/node_modules/ssh2/lib/index.js +44 -0
- package/node_modules/ssh2/lib/keygen.js +582 -0
- package/node_modules/ssh2/lib/protocol/Protocol.js +2136 -0
- package/node_modules/ssh2/lib/protocol/SFTP.js +4052 -0
- package/node_modules/ssh2/lib/protocol/constants.js +356 -0
- package/node_modules/ssh2/lib/protocol/crypto/binding.gyp +23 -0
- package/node_modules/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/node_modules/ssh2/lib/protocol/crypto/src/binding.cc +2300 -0
- package/node_modules/ssh2/lib/protocol/crypto.js +1602 -0
- package/node_modules/ssh2/lib/protocol/handlers.js +16 -0
- package/node_modules/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/node_modules/ssh2/lib/protocol/kex.js +1908 -0
- package/node_modules/ssh2/lib/protocol/keyParser.js +1484 -0
- package/node_modules/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/node_modules/ssh2/lib/protocol/utils.js +356 -0
- package/node_modules/ssh2/lib/protocol/zlib.js +255 -0
- package/node_modules/ssh2/lib/server.js +1380 -0
- package/node_modules/ssh2/lib/utils.js +336 -0
- package/node_modules/ssh2/package.json +49 -0
- package/node_modules/ssh2/test/common.js +316 -0
- package/node_modules/ssh2/test/fixtures/bad_rsa_private_key +26 -0
- package/node_modules/ssh2/test/fixtures/https_cert.pem +33 -0
- package/node_modules/ssh2/test/fixtures/https_key.pem +52 -0
- package/node_modules/ssh2/test/fixtures/id_dsa +12 -0
- package/node_modules/ssh2/test/fixtures/id_ecdsa +5 -0
- package/node_modules/ssh2/test/fixtures/id_rsa +15 -0
- package/node_modules/ssh2/test/fixtures/id_rsa.ppk +26 -0
- package/node_modules/ssh2/test/fixtures/id_rsa_enc +30 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa +21 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc +22 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm +23 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_dsa_enc_gcm.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa +9 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc +10 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm +10 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ecdsa_enc_gcm.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519 +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_ed25519.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa +27 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc +28 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm +29 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_new_rsa_enc_gcm.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa +12 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc +15 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_dsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa +5 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_ecdsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa +27 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc +30 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc.pub.result +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256 +54 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256.pub +1 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256.pub.result +6 -0
- package/node_modules/ssh2/test/fixtures/keyParser/openssh_old_rsa_enc_aes256.result +6 -0
- package/node_modules/ssh2/test/fixtures/keyParser/ppk_dsa_enc +17 -0
- package/node_modules/ssh2/test/fixtures/keyParser/ppk_dsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa +26 -0
- package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa_enc +18 -0
- package/node_modules/ssh2/test/fixtures/keyParser/ppk_rsa_enc.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa.pub +9 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa2.pub +10 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa2.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa3.pub +11 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa3.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa4.pub +11 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa4.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa5.pub +8 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa5.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa6.pub +13 -0
- package/node_modules/ssh2/test/fixtures/keyParser/rfc4716_rsa6.pub.result +7 -0
- package/node_modules/ssh2/test/fixtures/openssh_new_rsa +27 -0
- package/node_modules/ssh2/test/fixtures/ssh_host_dsa_key +12 -0
- package/node_modules/ssh2/test/fixtures/ssh_host_ecdsa_key +5 -0
- package/node_modules/ssh2/test/fixtures/ssh_host_rsa_key +15 -0
- package/node_modules/ssh2/test/test-exec.js +578 -0
- package/node_modules/ssh2/test/test-integration-openssh.js +486 -0
- package/node_modules/ssh2/test/test-keygen.js +60 -0
- package/node_modules/ssh2/test/test-misc-client-server.js +1460 -0
- package/node_modules/ssh2/test/test-openssh.js +261 -0
- package/node_modules/ssh2/test/test-protocol-crypto.js +631 -0
- package/node_modules/ssh2/test/test-protocol-keyparser.js +177 -0
- package/node_modules/ssh2/test/test-server-hostkeys.js +138 -0
- package/node_modules/ssh2/test/test-sftp.js +842 -0
- package/node_modules/ssh2/test/test-shell.js +109 -0
- package/node_modules/ssh2/test/test-userauth-agent-openssh.js +110 -0
- package/node_modules/ssh2/test/test-userauth-agent.js +171 -0
- package/node_modules/ssh2/test/test-userauth.js +611 -0
- package/node_modules/ssh2/test/test-worker-imports.js +25 -0
- package/node_modules/ssh2/test/test.js +20 -0
- package/node_modules/ssh2/util/build_pagent.bat +2 -0
- package/node_modules/ssh2/util/pagent.c +88 -0
- package/node_modules/ssh2/util/pagent.exe +0 -0
- package/node_modules/tweetnacl/.npmignore +4 -0
- package/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/node_modules/tweetnacl/CHANGELOG.md +221 -0
- package/node_modules/tweetnacl/LICENSE +24 -0
- package/node_modules/tweetnacl/PULL_REQUEST_TEMPLATE.md +20 -0
- package/node_modules/tweetnacl/README.md +459 -0
- package/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/node_modules/tweetnacl/nacl-fast.min.js +2 -0
- package/node_modules/tweetnacl/nacl.d.ts +98 -0
- package/node_modules/tweetnacl/nacl.js +1175 -0
- package/node_modules/tweetnacl/nacl.min.js +1 -0
- package/node_modules/tweetnacl/package.json +58 -0
- package/package.json +96 -89
package/lib/pool.js
CHANGED
|
@@ -1,757 +1,757 @@
|
|
|
1
|
-
// Persistent SSH/SFTP connection pool (one identity → one live client).
|
|
2
|
-
import ssh2 from 'ssh2'
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
|
-
import path from 'node:path'
|
|
6
|
-
import { truncate } from './paths.js'
|
|
7
|
-
import { createHostKeyGuard } from './hostkey.js'
|
|
8
|
-
import { buildSftpSurface } from './sftp-surface.js'
|
|
9
|
-
import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
|
|
10
|
-
|
|
11
|
-
const { Client } = ssh2
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Build the pool for a config.
|
|
15
|
-
*
|
|
16
|
-
* Two backends exist (issue #51): the native one below, which speaks SSH itself
|
|
17
|
-
* via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
|
|
18
|
-
* the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
|
|
19
|
-
* `~/.ssh/config` are honoured. Both expose one identical surface, so every
|
|
20
|
-
* caller can keep using `new SshPool(...)`-shaped code — this factory only has
|
|
21
|
-
* to decide which class to instantiate.
|
|
22
|
-
*/
|
|
23
|
-
export function createPool(config, opts) {
|
|
24
|
-
return isSystemOpenSshBackend(config)
|
|
25
|
-
? new SystemOpenSshPool(config, opts)
|
|
26
|
-
: new SshPool(config, opts)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Run one `forwardOut` and resolve with the resulting channel.
|
|
31
|
-
*
|
|
32
|
-
* ssh2 reports a dead connection by THROWING synchronously —
|
|
33
|
-
* `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
|
|
34
|
-
* (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
|
|
35
|
-
* callback-only error path cannot guard this call (issue #54). Two further
|
|
36
|
-
* consequences justify a shared helper rather than an inline try/catch:
|
|
37
|
-
*
|
|
38
|
-
* • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
|
|
39
|
-
* an unhandled REJECTION and leaves the promise pending forever;
|
|
40
|
-
* • called from a `net.Server` connection handler the throw escapes to the
|
|
41
|
-
* process top level and kills the host.
|
|
42
|
-
*
|
|
43
|
-
* This guards the CALLER's side of the contract rather than assuming one
|
|
44
|
-
* backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
|
|
45
|
-
* already converts its own spawn failure into a callback error, so for it the
|
|
46
|
-
* try/catch below is dead code — but a backend is free to throw, and the one
|
|
47
|
-
* that does must not be able to take the process down.
|
|
48
|
-
*
|
|
49
|
-
* @param {object} client - an ssh2 Client or OpenSshConnection.
|
|
50
|
-
* @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
|
|
51
|
-
* @returns {Promise<object>} the forwarded channel / duplex stream.
|
|
52
|
-
*/
|
|
53
|
-
export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
|
|
54
|
-
return new Promise((resolve, reject) => {
|
|
55
|
-
// `reject` is called at most once: a callback that fires after a successful
|
|
56
|
-
// throw-path rejection would be a no-op, which Promise guarantees.
|
|
57
|
-
try {
|
|
58
|
-
client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
|
|
59
|
-
} catch (err) {
|
|
60
|
-
reject(err)
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The ACTIVE pool: one long-lived object whose concrete implementation can be
|
|
67
|
-
* swapped when the current machine's transport backend changes.
|
|
68
|
-
*
|
|
69
|
-
* Why a delegating facade instead of just constructing the right class once:
|
|
70
|
-
* `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
|
|
71
|
-
* `openssh` while every other machine stays on `native`), but the plugin keeps
|
|
72
|
-
* exactly one active pool for its whole lifetime and hangs `passwordResolver` /
|
|
73
|
-
* `onReady` / `onCloseHook` off it once at startup. Replacing the object
|
|
74
|
-
* wholesale on every machine switch would silently drop those hooks — the port
|
|
75
|
-
* forwards and keychain passwords would stop working after the first switch.
|
|
76
|
-
*
|
|
77
|
-
* So the facade owns the hooks and the shared `config` object, and forwards
|
|
78
|
-
* them to whichever concrete pool is right for the current config. Everything
|
|
79
|
-
* else is a straight delegation, which is why callers cannot tell the
|
|
80
|
-
* difference (and why the class is deliberately not part of the public story).
|
|
81
|
-
*/
|
|
82
|
-
export class ActivePool {
|
|
83
|
-
constructor(config, opts = {}) {
|
|
84
|
-
this.config = config
|
|
85
|
-
this.opts = opts
|
|
86
|
-
// The hooks are assigned by the plugin AFTER construction, so they are
|
|
87
|
-
// stored here and re-applied to every delegate this facade ever creates.
|
|
88
|
-
this._passwordResolver = null
|
|
89
|
-
this._onReady = null
|
|
90
|
-
this._onCloseHook = null
|
|
91
|
-
this._delegate = null
|
|
92
|
-
this._sync()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Which concrete class the current config asks for. */
|
|
96
|
-
_wantsSystemOpenSsh() {
|
|
97
|
-
return isSystemOpenSshBackend(this.config)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
_delegateIsCurrent() {
|
|
101
|
-
if (!this._delegate) return false
|
|
102
|
-
const isOpenssh = this._delegate instanceof SystemOpenSshPool
|
|
103
|
-
return isOpenssh === this._wantsSystemOpenSsh()
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Create (or replace) the delegate so it matches the current config. */
|
|
107
|
-
_sync() {
|
|
108
|
-
if (this._delegateIsCurrent()) return this._delegate
|
|
109
|
-
const previous = this._delegate
|
|
110
|
-
const next = createPool(this.config, this.opts)
|
|
111
|
-
next.passwordResolver = this._passwordResolver
|
|
112
|
-
next.onReady = this._onReady
|
|
113
|
-
next.onCloseHook = this._onCloseHook
|
|
114
|
-
this._delegate = next
|
|
115
|
-
// Tear the old backend down LAST, and never let a teardown error mask the
|
|
116
|
-
// new pool: the connection it was holding belongs to the old host anyway.
|
|
117
|
-
if (previous) { try { previous.close() } catch { /* already gone */ } }
|
|
118
|
-
return next
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
get delegate() { return this._sync() }
|
|
122
|
-
|
|
123
|
-
// ── hooks: stored here, mirrored onto every delegate ──────────────────────
|
|
124
|
-
get passwordResolver() { return this._passwordResolver }
|
|
125
|
-
set passwordResolver(fn) {
|
|
126
|
-
this._passwordResolver = fn
|
|
127
|
-
if (this._delegate) this._delegate.passwordResolver = fn
|
|
128
|
-
}
|
|
129
|
-
get onReady() { return this._onReady }
|
|
130
|
-
set onReady(fn) {
|
|
131
|
-
this._onReady = fn
|
|
132
|
-
if (this._delegate) this._delegate.onReady = fn
|
|
133
|
-
}
|
|
134
|
-
get onCloseHook() { return this._onCloseHook }
|
|
135
|
-
set onCloseHook(fn) {
|
|
136
|
-
this._onCloseHook = fn
|
|
137
|
-
if (this._delegate) this._delegate.onCloseHook = fn
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ── pass-through state the plugin reads directly ──────────────────────────
|
|
141
|
-
get client() { return this._delegate ? this._delegate.client : null }
|
|
142
|
-
// Assignable because the plugin's own tests arm a fake live connection on the
|
|
143
|
-
// active pool; a getter-only facade would make that assignment a silent
|
|
144
|
-
// no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
|
|
145
|
-
set client(v) { if (this._sync()) this._delegate.client = v }
|
|
146
|
-
get connecting() { return this._delegate ? this._delegate.connecting : null }
|
|
147
|
-
get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
|
|
148
|
-
get epoch() { return this._delegate ? this._delegate.epoch : 0 }
|
|
149
|
-
get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
|
|
150
|
-
set platform(v) { if (this._sync()) this._delegate.platform = v }
|
|
151
|
-
get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
|
|
152
|
-
set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
|
|
153
|
-
get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
|
|
154
|
-
set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
|
|
155
|
-
|
|
156
|
-
// ── delegated operations ──────────────────────────────────────────────────
|
|
157
|
-
/** Re-point at a new target, switching backend first if the machine asks. */
|
|
158
|
-
setTarget(fields, previous) {
|
|
159
|
-
// The backend may have changed with the machine, so apply that part of the
|
|
160
|
-
// request to `config` BEFORE picking the delegate: _sync() chooses the class
|
|
161
|
-
// from config.backend, and the old backend must not be handed a host it can
|
|
162
|
-
// no longer reach. The delegate then receives the whole request as usual.
|
|
163
|
-
if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
|
|
164
|
-
if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
|
|
165
|
-
const d = this._sync()
|
|
166
|
-
return d.setTarget(fields, previous)
|
|
167
|
-
}
|
|
168
|
-
connect() { return this.delegate.connect() }
|
|
169
|
-
exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
|
|
170
|
-
sftp() { return this.delegate.sftp() }
|
|
171
|
-
detect() { return this.delegate.detect() }
|
|
172
|
-
invalidate() { return this.delegate.invalidate() }
|
|
173
|
-
close() { return this.delegate.close() }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export class SshPool {
|
|
177
|
-
constructor(config, opts = {}) {
|
|
178
|
-
this.config = config
|
|
179
|
-
this._knownHostsFile = opts.knownHostsFile
|
|
180
|
-
this.client = null
|
|
181
|
-
this.connecting = null
|
|
182
|
-
this.proxyPool = null
|
|
183
|
-
// Generational token: bumped on every target change / close so a stale
|
|
184
|
-
// in-flight connect can never hand this pool a connection to an old host.
|
|
185
|
-
this.epoch = 0
|
|
186
|
-
/** Auto-detected remote platform: unknown | windows | posix (per target). */
|
|
187
|
-
this.platform = 'unknown'
|
|
188
|
-
/** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
|
|
189
|
-
this.gitBashPath = ''
|
|
190
|
-
/** Resolved terminal strategy: native | git-bash. */
|
|
191
|
-
this.shellMode = 'native'
|
|
192
|
-
/** In-flight platform detection promise (cached). */
|
|
193
|
-
this._detecting = null
|
|
194
|
-
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
195
|
-
this.passwordResolver = null
|
|
196
|
-
/** Optional hook called with the live client after a successful connect. */
|
|
197
|
-
this.onReady = null
|
|
198
|
-
/** Optional hook called when the pool closes. */
|
|
199
|
-
this.onCloseHook = null
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
resolveKeyPath() {
|
|
203
|
-
const p = this.config.privateKeyPath
|
|
204
|
-
if (!p) return ''
|
|
205
|
-
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
206
|
-
return p
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
|
|
210
|
-
// Collect the requested changes first: an UNCHANGED target is a no-op. The
|
|
211
|
-
// old behavior closed the live connection and re-ran platform detection
|
|
212
|
-
// even for an identical target, so every picker keystroke
|
|
213
|
-
// (POST /current → applyActiveMachine → setTarget) paid a full SSH
|
|
214
|
-
// reconnect + re-detect — the "slow remote path autocomplete" bug.
|
|
215
|
-
//
|
|
216
|
-
// `previous` is the diff BASELINE. Callers whose shared config was already
|
|
217
|
-
// mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
|
|
218
|
-
// live config IN PLACE, and pool.config IS that config object) pass a
|
|
219
|
-
// pre-mutation snapshot — otherwise the comparison runs the new values
|
|
220
|
-
// against themselves, is always equal and never closes even for a machine
|
|
221
|
-
// switch (the wrong-host hazard of issue #25). Defaults to the current
|
|
222
|
-
// config for callers that pass fresh values read independently of it.
|
|
223
|
-
const base = previous || this.config
|
|
224
|
-
const wanted = []
|
|
225
|
-
if (host !== undefined) wanted.push(['host', String(host)])
|
|
226
|
-
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
227
|
-
if (username !== undefined) wanted.push(['username', String(username)])
|
|
228
|
-
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
229
|
-
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
230
|
-
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
231
|
-
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
232
|
-
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
233
|
-
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
234
|
-
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
235
|
-
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
236
|
-
// Issue #51: the transport backend is part of the target identity — changing
|
|
237
|
-
// it must close the live connection exactly like changing the host does.
|
|
238
|
-
if (backend !== undefined) wanted.push(['backend', String(backend)])
|
|
239
|
-
if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
|
|
240
|
-
const differs = ([k, v]) => (k === 'proxy'
|
|
241
|
-
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
242
|
-
: base[k] !== v)
|
|
243
|
-
// Something actually changed → apply it and force a fresh connection.
|
|
244
|
-
// (A caller that WANTS a reconnect for an identical target uses
|
|
245
|
-
// invalidate(); setTarget semantics stay "re-point when moved".)
|
|
246
|
-
if (wanted.some(differs)) {
|
|
247
|
-
for (const [k, v] of wanted) this.config[k] = v
|
|
248
|
-
// the new target may be a different OS — re-detect on the next command
|
|
249
|
-
this.platform = 'unknown'
|
|
250
|
-
this.gitBashPath = ''
|
|
251
|
-
this.shellMode = 'native'
|
|
252
|
-
this._detecting = null
|
|
253
|
-
this.close()
|
|
254
|
-
}
|
|
255
|
-
return this
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
connect() {
|
|
259
|
-
if (this.client) return Promise.resolve(this.client)
|
|
260
|
-
if (this.connecting) return this.connecting
|
|
261
|
-
const epoch = this.epoch
|
|
262
|
-
const pending = this._doConnect(epoch)
|
|
263
|
-
this.connecting = pending
|
|
264
|
-
const clear = () => {
|
|
265
|
-
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
266
|
-
}
|
|
267
|
-
pending.then(clear, clear)
|
|
268
|
-
return pending
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async _doConnect(epoch) {
|
|
272
|
-
const isCurrent = () => this.epoch === epoch
|
|
273
|
-
const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
|
|
274
|
-
const guard = createHostKeyGuard(this.config, knownHostsFile)
|
|
275
|
-
const client = new Client()
|
|
276
|
-
let settled = false
|
|
277
|
-
const fail = (err) => {
|
|
278
|
-
if (settled) return
|
|
279
|
-
settled = true
|
|
280
|
-
if (isCurrent() && this.client === client) this.client = null
|
|
281
|
-
throw guard.lastError ? new Error(guard.lastError) : err
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
285
|
-
let sock = null
|
|
286
|
-
const proxyCfg = this.config.proxy
|
|
287
|
-
if (proxyCfg && proxyCfg.host) {
|
|
288
|
-
try {
|
|
289
|
-
this.proxyPool = new SshPool({
|
|
290
|
-
...this.config,
|
|
291
|
-
host: proxyCfg.host,
|
|
292
|
-
port: Number(proxyCfg.port) || 22,
|
|
293
|
-
username: proxyCfg.username || this.config.username || 'root',
|
|
294
|
-
password: proxyCfg.password || '',
|
|
295
|
-
privateKeyPath: proxyCfg.privateKeyPath || '',
|
|
296
|
-
passphrase: proxyCfg.passphrase || '',
|
|
297
|
-
proxy: undefined,
|
|
298
|
-
}, { knownHostsFile: this._knownHostsFile })
|
|
299
|
-
const pclient = await this.proxyPool.connect()
|
|
300
|
-
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
301
|
-
try {
|
|
302
|
-
sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
|
|
303
|
-
} catch (e) {
|
|
304
|
-
throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
|
|
305
|
-
}
|
|
306
|
-
} catch (err) {
|
|
307
|
-
return fail(err)
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return new Promise((resolve, reject) => {
|
|
312
|
-
const rejectOnce = (err) => {
|
|
313
|
-
if (settled) return
|
|
314
|
-
settled = true
|
|
315
|
-
if (isCurrent() && this.client === client) this.client = null
|
|
316
|
-
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
317
|
-
}
|
|
318
|
-
client.on('ready', () => {
|
|
319
|
-
if (settled) return
|
|
320
|
-
settled = true
|
|
321
|
-
if (!isCurrent()) {
|
|
322
|
-
try { client.end() } catch {}
|
|
323
|
-
reject(new Error('ssh target changed during connect'))
|
|
324
|
-
return
|
|
325
|
-
}
|
|
326
|
-
this.client = client
|
|
327
|
-
resolve(client)
|
|
328
|
-
if (this.onReady) { try { this.onReady(client) } catch {} }
|
|
329
|
-
})
|
|
330
|
-
client.on('error', (e) => rejectOnce(e))
|
|
331
|
-
client.on('close', () => {
|
|
332
|
-
if (isCurrent() && this.client === client) this.client = null
|
|
333
|
-
rejectOnce(new Error('ssh connection closed'))
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
const buildOpts = async () => {
|
|
337
|
-
const opts = {
|
|
338
|
-
host: this.config.host,
|
|
339
|
-
port: this.config.port,
|
|
340
|
-
username: this.config.username,
|
|
341
|
-
readyTimeout: this.config.connectTimeoutMs,
|
|
342
|
-
keepaliveInterval: 15000,
|
|
343
|
-
keepaliveCountMax: 3,
|
|
344
|
-
hostVerifier: (key) => guard.verifier(key),
|
|
345
|
-
}
|
|
346
|
-
if (sock) opts.sock = sock
|
|
347
|
-
if (this.config.useAgent) {
|
|
348
|
-
const sockPath = process.env.SSH_AUTH_SOCK
|
|
349
|
-
if (sockPath) opts.agent = sockPath
|
|
350
|
-
}
|
|
351
|
-
let password = this.config.password || ''
|
|
352
|
-
if (!password && this.passwordResolver) {
|
|
353
|
-
try { password = (await this.passwordResolver()) || '' } catch {}
|
|
354
|
-
}
|
|
355
|
-
if (password) {
|
|
356
|
-
opts.password = password
|
|
357
|
-
opts.tryKeyboard = true
|
|
358
|
-
} else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
|
|
359
|
-
opts.tryKeyboard = true
|
|
360
|
-
}
|
|
361
|
-
if (this.config.privateKeyPath) {
|
|
362
|
-
const keyPath = this.resolveKeyPath()
|
|
363
|
-
if (!keyPath) {
|
|
364
|
-
throw new Error('no credentials: set a password or a privateKeyPath to connect')
|
|
365
|
-
}
|
|
366
|
-
let key
|
|
367
|
-
try {
|
|
368
|
-
key = readFileSync(keyPath)
|
|
369
|
-
} catch (err) {
|
|
370
|
-
throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
|
|
371
|
-
}
|
|
372
|
-
opts.privateKey = key
|
|
373
|
-
opts.passphrase = this.config.passphrase || undefined
|
|
374
|
-
} else if (!password && !opts.agent) {
|
|
375
|
-
throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
|
|
376
|
-
}
|
|
377
|
-
return opts
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
buildOpts().then(
|
|
381
|
-
(opts) => {
|
|
382
|
-
if (opts.tryKeyboard) {
|
|
383
|
-
client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
|
384
|
-
finish(prompts.map(() => this.config.password || ''))
|
|
385
|
-
})
|
|
386
|
-
}
|
|
387
|
-
// ssh2 parses the private key SYNCHRONOUSLY inside connect() and
|
|
388
|
-
// throws on an unparseable key (the classic case: an encrypted
|
|
389
|
-
// OpenSSH key without a passphrase). Left uncaught, that throw
|
|
390
|
-
// rejects this orphaned .then chain — an unhandled rejection that
|
|
391
|
-
// crashes the whole host process (issue #48: "dsh: fatal load
|
|
392
|
-
// failure" on every boot, because boot-restore re-dials the bad
|
|
393
|
-
// machine). It also leaves the outer promise pending forever.
|
|
394
|
-
// Convert it into a normal rejection so every caller's try/catch —
|
|
395
|
-
// boot restore, test-connect, rw_connect — sees a regular failure.
|
|
396
|
-
try {
|
|
397
|
-
client.connect(opts)
|
|
398
|
-
} catch (err) {
|
|
399
|
-
rejectOnce(err)
|
|
400
|
-
}
|
|
401
|
-
},
|
|
402
|
-
(err) => rejectOnce(err),
|
|
403
|
-
)
|
|
404
|
-
})
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
408
|
-
detect() {
|
|
409
|
-
if (this.platform !== 'unknown') return Promise.resolve()
|
|
410
|
-
if (this._detecting) return this._detecting
|
|
411
|
-
this._detecting = this._detect().finally(() => {
|
|
412
|
-
this._detecting = null
|
|
413
|
-
})
|
|
414
|
-
return this._detecting
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async _detect() {
|
|
418
|
-
let res
|
|
419
|
-
try {
|
|
420
|
-
// `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
|
|
421
|
-
// "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
|
|
422
|
-
res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
423
|
-
} catch (err) {
|
|
424
|
-
this.platform = 'unknown'
|
|
425
|
-
this.shellMode = 'native'
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
429
|
-
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
430
|
-
this.platform = 'windows'
|
|
431
|
-
await this._resolveGitBash()
|
|
432
|
-
return
|
|
433
|
-
}
|
|
434
|
-
// inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
|
|
435
|
-
try {
|
|
436
|
-
const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
437
|
-
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
438
|
-
this.platform = 'windows'
|
|
439
|
-
await this._resolveGitBash()
|
|
440
|
-
return
|
|
441
|
-
}
|
|
442
|
-
} catch {}
|
|
443
|
-
this.platform = 'posix'
|
|
444
|
-
this.shellMode = 'native'
|
|
445
|
-
this.gitBashPath = ''
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
|
|
449
|
-
async _resolveGitBash() {
|
|
450
|
-
const cfg = String(this.config.shell || '').trim()
|
|
451
|
-
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
452
|
-
if (await this._cmdExists(cfg)) {
|
|
453
|
-
this.gitBashPath = cfg
|
|
454
|
-
this.shellMode = 'git-bash'
|
|
455
|
-
return
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
if (cfg === 'native') {
|
|
459
|
-
this.shellMode = 'native'
|
|
460
|
-
this.gitBashPath = ''
|
|
461
|
-
return
|
|
462
|
-
}
|
|
463
|
-
try {
|
|
464
|
-
const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
|
|
465
|
-
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
466
|
-
if (first) {
|
|
467
|
-
this.gitBashPath = first
|
|
468
|
-
this.shellMode = 'git-bash'
|
|
469
|
-
return
|
|
470
|
-
}
|
|
471
|
-
} catch {}
|
|
472
|
-
const candidates = [
|
|
473
|
-
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
474
|
-
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
475
|
-
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
476
|
-
'%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
|
|
477
|
-
]
|
|
478
|
-
for (const c of candidates) {
|
|
479
|
-
if (await this._cmdExists(c)) {
|
|
480
|
-
this.gitBashPath = c
|
|
481
|
-
this.shellMode = 'git-bash'
|
|
482
|
-
return
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
|
|
486
|
-
this.gitBashPath = ''
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async _cmdExists(p) {
|
|
490
|
-
try {
|
|
491
|
-
const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
|
|
492
|
-
return /Y/.test(String(r.stdout || ''))
|
|
493
|
-
} catch {
|
|
494
|
-
return false
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
499
|
-
* On Windows remotes with Git Bash the script is piped to `bash -s` over the
|
|
500
|
-
* exec-channel stdin — no shell quoting round-trip, so any content (quotes,
|
|
501
|
-
* backslashes, newlines) survives verbatim. */
|
|
502
|
-
exec(command, timeoutMsOrOpts) {
|
|
503
|
-
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
504
|
-
return this.detect().then(() => {
|
|
505
|
-
if (this.platform === 'windows' && this.gitBashPath) {
|
|
506
|
-
const script = String(command)
|
|
507
|
-
return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
|
|
508
|
-
try { stream.end(script) } catch {}
|
|
509
|
-
})
|
|
510
|
-
}
|
|
511
|
-
return this._execRaw(command, opts)
|
|
512
|
-
})
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
|
|
516
|
-
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
517
|
-
_execRaw(command, opts, stdinWriter) {
|
|
518
|
-
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
519
|
-
const signal = opts && opts.signal
|
|
520
|
-
return this.connect().then(
|
|
521
|
-
(client) =>
|
|
522
|
-
new Promise((resolve, reject) => {
|
|
523
|
-
let retried = false
|
|
524
|
-
// Caller cancellation (issue #44): a tool that hands us exec.signal
|
|
525
|
-
// expects the remote command to stop when the turn is cancelled.
|
|
526
|
-
// Resolve the same shape as a timeout so callers need no new branch.
|
|
527
|
-
let onAbort = null
|
|
528
|
-
const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
|
|
529
|
-
const runOn = (c) => {
|
|
530
|
-
const execOpts = {}
|
|
531
|
-
if (opts && opts.pty) execOpts.pty = true
|
|
532
|
-
if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
533
|
-
c.exec(command, execOpts, (err, stream) => {
|
|
534
|
-
if (err) {
|
|
535
|
-
// Channel-open failure — or a session termination — usually
|
|
536
|
-
// means the pooled connection died server-side (idle timeout /
|
|
537
|
-
// network reset) while keepalive hadn't noticed. Drop it and
|
|
538
|
-
// retry ONCE on a fresh connection.
|
|
539
|
-
if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
540
|
-
retried = true
|
|
541
|
-
this.invalidate()
|
|
542
|
-
return this.connect().then(
|
|
543
|
-
(fresh) => runOn(fresh),
|
|
544
|
-
(e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
545
|
-
)
|
|
546
|
-
}
|
|
547
|
-
return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
548
|
-
}
|
|
549
|
-
let stdout = ''
|
|
550
|
-
let stderr = ''
|
|
551
|
-
let settled = false
|
|
552
|
-
let exitCode = null
|
|
553
|
-
let exitSignal = null
|
|
554
|
-
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
555
|
-
const settle = () => {
|
|
556
|
-
if (settled) return
|
|
557
|
-
settled = true
|
|
558
|
-
clearTimeout(timer)
|
|
559
|
-
cleanupAbort()
|
|
560
|
-
resolve({
|
|
561
|
-
code: exitCode,
|
|
562
|
-
signal: exitSignal,
|
|
563
|
-
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
564
|
-
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
565
|
-
})
|
|
566
|
-
}
|
|
567
|
-
const timer = setTimeout(() => {
|
|
568
|
-
if (settled) return
|
|
569
|
-
exitCode = -1
|
|
570
|
-
exitSignal = 'TIMEOUT'
|
|
571
|
-
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
572
|
-
// channel, so a runaway process cannot keep running and holding
|
|
573
|
-
// the SSH connection after we've given up on its output.
|
|
574
|
-
try {
|
|
575
|
-
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
576
|
-
} catch {}
|
|
577
|
-
const hardClose = setTimeout(() => {
|
|
578
|
-
try { stream.close() } catch {}
|
|
579
|
-
}, 800)
|
|
580
|
-
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
581
|
-
settle()
|
|
582
|
-
}, timeoutMs)
|
|
583
|
-
if (signal) {
|
|
584
|
-
if (signal.aborted) {
|
|
585
|
-
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
586
|
-
try { stream.close() } catch {}
|
|
587
|
-
exitCode = -1
|
|
588
|
-
exitSignal = 'ABORTED'
|
|
589
|
-
return settle()
|
|
590
|
-
}
|
|
591
|
-
onAbort = () => {
|
|
592
|
-
if (settled) return
|
|
593
|
-
exitCode = -1
|
|
594
|
-
exitSignal = 'ABORTED'
|
|
595
|
-
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
596
|
-
const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
|
|
597
|
-
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
598
|
-
settle()
|
|
599
|
-
}
|
|
600
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
601
|
-
}
|
|
602
|
-
stream.on('close', (code, sigName) => {
|
|
603
|
-
if (settled) return
|
|
604
|
-
exitCode = code
|
|
605
|
-
// `sigName` is the signal NAME that killed the remote process
|
|
606
|
-
// (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
|
|
607
|
-
// matters: `signal` in this scope is the AbortSignal, and
|
|
608
|
-
// assigning it here would leak an object into the result.
|
|
609
|
-
exitSignal = sigName
|
|
610
|
-
settle()
|
|
611
|
-
})
|
|
612
|
-
stream.on('data', (d) => {
|
|
613
|
-
if (stdout.length < hardCap) stdout += d
|
|
614
|
-
})
|
|
615
|
-
stream.stderr.on('data', (d) => {
|
|
616
|
-
if (stderr.length < hardCap) stderr += d
|
|
617
|
-
})
|
|
618
|
-
stream.on('error', (e) => {
|
|
619
|
-
if (settled) return
|
|
620
|
-
settled = true
|
|
621
|
-
clearTimeout(timer)
|
|
622
|
-
cleanupAbort()
|
|
623
|
-
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
624
|
-
})
|
|
625
|
-
if (stdinWriter) {
|
|
626
|
-
try { stdinWriter(stream) } catch {}
|
|
627
|
-
}
|
|
628
|
-
})
|
|
629
|
-
}
|
|
630
|
-
runOn(client)
|
|
631
|
-
}),
|
|
632
|
-
)
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
|
|
636
|
-
sftp() {
|
|
637
|
-
return this.connect().then(
|
|
638
|
-
(client) =>
|
|
639
|
-
new Promise((resolve, reject) => {
|
|
640
|
-
let retried = false
|
|
641
|
-
const runOn = (c) => {
|
|
642
|
-
// Bound the subchannel open itself, per attempt. Without this, a
|
|
643
|
-
// remote whose sftp subsystem is unresponsive (overloaded sshd,
|
|
644
|
-
// exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
|
|
645
|
-
// forever with no callback — issue #44 point 3. ssh2 only calls back
|
|
646
|
-
// on success or on a channel error, and a wedged peer produces
|
|
647
|
-
// neither.
|
|
648
|
-
//
|
|
649
|
-
// The timer must be created per attempt, not once outside: the
|
|
650
|
-
// callback clears it, so a single outer timer would leave the retry
|
|
651
|
-
// (below) with no timeout at all.
|
|
652
|
-
let timedOut = false
|
|
653
|
-
const attemptTimer = setTimeout(() => {
|
|
654
|
-
timedOut = true
|
|
655
|
-
reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
|
|
656
|
-
}, this.config.commandTimeoutMs)
|
|
657
|
-
// ★ 这里**不能 unref**。
|
|
658
|
-
//
|
|
659
|
-
// 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
|
|
660
|
-
// 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
|
|
661
|
-
// Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
|
|
662
|
-
// 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
|
|
663
|
-
// (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
|
|
664
|
-
// 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
|
|
665
|
-
// 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
|
|
666
|
-
const clearAttempt = () => { clearTimeout(attemptTimer) }
|
|
667
|
-
try {
|
|
668
|
-
c.sftp((err, sftp) => {
|
|
669
|
-
clearAttempt()
|
|
670
|
-
if (timedOut) return
|
|
671
|
-
if (err) {
|
|
672
|
-
// Same dead-connection recovery as exec(): a channel open
|
|
673
|
-
// failure — or a session termination (remote closed the
|
|
674
|
-
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
675
|
-
// drop) — means the pooled connection is stale: drop it and
|
|
676
|
-
// retry ONCE on a fresh connection.
|
|
677
|
-
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
678
|
-
retried = true
|
|
679
|
-
this.invalidate()
|
|
680
|
-
return this.connect().then(
|
|
681
|
-
(fresh) => runOn(fresh),
|
|
682
|
-
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
683
|
-
)
|
|
684
|
-
}
|
|
685
|
-
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
686
|
-
}
|
|
687
|
-
// The promisified surface (timeouts, dead-channel fail-fast,
|
|
688
|
-
// range reads) is shared verbatim with the system-OpenSSH
|
|
689
|
-
// backend — see lib/sftp-surface.js for the rules it encodes.
|
|
690
|
-
resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
|
|
691
|
-
})
|
|
692
|
-
} catch (e) {
|
|
693
|
-
clearAttempt()
|
|
694
|
-
reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
runOn(client)
|
|
698
|
-
}),
|
|
699
|
-
)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Drop the cached client and force a fresh connection on the next call.
|
|
704
|
-
* Called when a channel open fails (e.g. "Channel open failure: open
|
|
705
|
-
* failed") — the pooled SSH connection is usually dead server-side while
|
|
706
|
-
* keepalive has not yet noticed, and reusing it keeps failing. The epoch
|
|
707
|
-
* bump orphans any in-flight connect; the client is ended so ssh2 frees
|
|
708
|
-
* its sockets.
|
|
709
|
-
*/
|
|
710
|
-
invalidate() {
|
|
711
|
-
this.epoch++
|
|
712
|
-
const client = this.client
|
|
713
|
-
this.client = null
|
|
714
|
-
const pending = this.connecting
|
|
715
|
-
this.connecting = null
|
|
716
|
-
if (pending && typeof pending.catch === 'function') {
|
|
717
|
-
try { pending.catch(() => {}) } catch {}
|
|
718
|
-
}
|
|
719
|
-
if (this.proxyPool) {
|
|
720
|
-
try { this.proxyPool.close() } catch {}
|
|
721
|
-
this.proxyPool = null
|
|
722
|
-
}
|
|
723
|
-
if (client) {
|
|
724
|
-
try { client.end() } catch {}
|
|
725
|
-
}
|
|
726
|
-
// Same teardown as close(): the forwards manager must detach from the
|
|
727
|
-
// dying client, or its stale `close -> stopAll()` listener later tears
|
|
728
|
-
// down forwards that were re-attached to the NEW client (onReady does
|
|
729
|
-
// the re-attach on the next successful connect).
|
|
730
|
-
if (this.onCloseHook) {
|
|
731
|
-
try { this.onCloseHook() } catch {}
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
close() {
|
|
736
|
-
this.epoch++
|
|
737
|
-
const client = this.client
|
|
738
|
-
this.client = null
|
|
739
|
-
const pending = this.connecting
|
|
740
|
-
this.connecting = null
|
|
741
|
-
if (pending && typeof pending.catch === 'function') {
|
|
742
|
-
try { pending.catch(() => {}) } catch {}
|
|
743
|
-
}
|
|
744
|
-
if (this.proxyPool) {
|
|
745
|
-
try { this.proxyPool.close() } catch {}
|
|
746
|
-
this.proxyPool = null
|
|
747
|
-
}
|
|
748
|
-
if (client) {
|
|
749
|
-
try {
|
|
750
|
-
client.end()
|
|
751
|
-
} catch {}
|
|
752
|
-
}
|
|
753
|
-
if (this.onCloseHook) {
|
|
754
|
-
try { this.onCloseHook() } catch {}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|
|
1
|
+
// Persistent SSH/SFTP connection pool (one identity → one live client).
|
|
2
|
+
import ssh2 from 'ssh2'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { truncate } from './paths.js'
|
|
7
|
+
import { createHostKeyGuard } from './hostkey.js'
|
|
8
|
+
import { buildSftpSurface } from './sftp-surface.js'
|
|
9
|
+
import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
|
|
10
|
+
|
|
11
|
+
const { Client } = ssh2
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the pool for a config.
|
|
15
|
+
*
|
|
16
|
+
* Two backends exist (issue #51): the native one below, which speaks SSH itself
|
|
17
|
+
* via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
|
|
18
|
+
* the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
|
|
19
|
+
* `~/.ssh/config` are honoured. Both expose one identical surface, so every
|
|
20
|
+
* caller can keep using `new SshPool(...)`-shaped code — this factory only has
|
|
21
|
+
* to decide which class to instantiate.
|
|
22
|
+
*/
|
|
23
|
+
export function createPool(config, opts) {
|
|
24
|
+
return isSystemOpenSshBackend(config)
|
|
25
|
+
? new SystemOpenSshPool(config, opts)
|
|
26
|
+
: new SshPool(config, opts)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run one `forwardOut` and resolve with the resulting channel.
|
|
31
|
+
*
|
|
32
|
+
* ssh2 reports a dead connection by THROWING synchronously —
|
|
33
|
+
* `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
|
|
34
|
+
* (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
|
|
35
|
+
* callback-only error path cannot guard this call (issue #54). Two further
|
|
36
|
+
* consequences justify a shared helper rather than an inline try/catch:
|
|
37
|
+
*
|
|
38
|
+
* • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
|
|
39
|
+
* an unhandled REJECTION and leaves the promise pending forever;
|
|
40
|
+
* • called from a `net.Server` connection handler the throw escapes to the
|
|
41
|
+
* process top level and kills the host.
|
|
42
|
+
*
|
|
43
|
+
* This guards the CALLER's side of the contract rather than assuming one
|
|
44
|
+
* backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
|
|
45
|
+
* already converts its own spawn failure into a callback error, so for it the
|
|
46
|
+
* try/catch below is dead code — but a backend is free to throw, and the one
|
|
47
|
+
* that does must not be able to take the process down.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} client - an ssh2 Client or OpenSshConnection.
|
|
50
|
+
* @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
|
|
51
|
+
* @returns {Promise<object>} the forwarded channel / duplex stream.
|
|
52
|
+
*/
|
|
53
|
+
export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
// `reject` is called at most once: a callback that fires after a successful
|
|
56
|
+
// throw-path rejection would be a no-op, which Promise guarantees.
|
|
57
|
+
try {
|
|
58
|
+
client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
|
|
59
|
+
} catch (err) {
|
|
60
|
+
reject(err)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The ACTIVE pool: one long-lived object whose concrete implementation can be
|
|
67
|
+
* swapped when the current machine's transport backend changes.
|
|
68
|
+
*
|
|
69
|
+
* Why a delegating facade instead of just constructing the right class once:
|
|
70
|
+
* `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
|
|
71
|
+
* `openssh` while every other machine stays on `native`), but the plugin keeps
|
|
72
|
+
* exactly one active pool for its whole lifetime and hangs `passwordResolver` /
|
|
73
|
+
* `onReady` / `onCloseHook` off it once at startup. Replacing the object
|
|
74
|
+
* wholesale on every machine switch would silently drop those hooks — the port
|
|
75
|
+
* forwards and keychain passwords would stop working after the first switch.
|
|
76
|
+
*
|
|
77
|
+
* So the facade owns the hooks and the shared `config` object, and forwards
|
|
78
|
+
* them to whichever concrete pool is right for the current config. Everything
|
|
79
|
+
* else is a straight delegation, which is why callers cannot tell the
|
|
80
|
+
* difference (and why the class is deliberately not part of the public story).
|
|
81
|
+
*/
|
|
82
|
+
export class ActivePool {
|
|
83
|
+
constructor(config, opts = {}) {
|
|
84
|
+
this.config = config
|
|
85
|
+
this.opts = opts
|
|
86
|
+
// The hooks are assigned by the plugin AFTER construction, so they are
|
|
87
|
+
// stored here and re-applied to every delegate this facade ever creates.
|
|
88
|
+
this._passwordResolver = null
|
|
89
|
+
this._onReady = null
|
|
90
|
+
this._onCloseHook = null
|
|
91
|
+
this._delegate = null
|
|
92
|
+
this._sync()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Which concrete class the current config asks for. */
|
|
96
|
+
_wantsSystemOpenSsh() {
|
|
97
|
+
return isSystemOpenSshBackend(this.config)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_delegateIsCurrent() {
|
|
101
|
+
if (!this._delegate) return false
|
|
102
|
+
const isOpenssh = this._delegate instanceof SystemOpenSshPool
|
|
103
|
+
return isOpenssh === this._wantsSystemOpenSsh()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Create (or replace) the delegate so it matches the current config. */
|
|
107
|
+
_sync() {
|
|
108
|
+
if (this._delegateIsCurrent()) return this._delegate
|
|
109
|
+
const previous = this._delegate
|
|
110
|
+
const next = createPool(this.config, this.opts)
|
|
111
|
+
next.passwordResolver = this._passwordResolver
|
|
112
|
+
next.onReady = this._onReady
|
|
113
|
+
next.onCloseHook = this._onCloseHook
|
|
114
|
+
this._delegate = next
|
|
115
|
+
// Tear the old backend down LAST, and never let a teardown error mask the
|
|
116
|
+
// new pool: the connection it was holding belongs to the old host anyway.
|
|
117
|
+
if (previous) { try { previous.close() } catch { /* already gone */ } }
|
|
118
|
+
return next
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get delegate() { return this._sync() }
|
|
122
|
+
|
|
123
|
+
// ── hooks: stored here, mirrored onto every delegate ──────────────────────
|
|
124
|
+
get passwordResolver() { return this._passwordResolver }
|
|
125
|
+
set passwordResolver(fn) {
|
|
126
|
+
this._passwordResolver = fn
|
|
127
|
+
if (this._delegate) this._delegate.passwordResolver = fn
|
|
128
|
+
}
|
|
129
|
+
get onReady() { return this._onReady }
|
|
130
|
+
set onReady(fn) {
|
|
131
|
+
this._onReady = fn
|
|
132
|
+
if (this._delegate) this._delegate.onReady = fn
|
|
133
|
+
}
|
|
134
|
+
get onCloseHook() { return this._onCloseHook }
|
|
135
|
+
set onCloseHook(fn) {
|
|
136
|
+
this._onCloseHook = fn
|
|
137
|
+
if (this._delegate) this._delegate.onCloseHook = fn
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── pass-through state the plugin reads directly ──────────────────────────
|
|
141
|
+
get client() { return this._delegate ? this._delegate.client : null }
|
|
142
|
+
// Assignable because the plugin's own tests arm a fake live connection on the
|
|
143
|
+
// active pool; a getter-only facade would make that assignment a silent
|
|
144
|
+
// no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
|
|
145
|
+
set client(v) { if (this._sync()) this._delegate.client = v }
|
|
146
|
+
get connecting() { return this._delegate ? this._delegate.connecting : null }
|
|
147
|
+
get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
|
|
148
|
+
get epoch() { return this._delegate ? this._delegate.epoch : 0 }
|
|
149
|
+
get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
|
|
150
|
+
set platform(v) { if (this._sync()) this._delegate.platform = v }
|
|
151
|
+
get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
|
|
152
|
+
set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
|
|
153
|
+
get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
|
|
154
|
+
set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
|
|
155
|
+
|
|
156
|
+
// ── delegated operations ──────────────────────────────────────────────────
|
|
157
|
+
/** Re-point at a new target, switching backend first if the machine asks. */
|
|
158
|
+
setTarget(fields, previous) {
|
|
159
|
+
// The backend may have changed with the machine, so apply that part of the
|
|
160
|
+
// request to `config` BEFORE picking the delegate: _sync() chooses the class
|
|
161
|
+
// from config.backend, and the old backend must not be handed a host it can
|
|
162
|
+
// no longer reach. The delegate then receives the whole request as usual.
|
|
163
|
+
if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
|
|
164
|
+
if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
|
|
165
|
+
const d = this._sync()
|
|
166
|
+
return d.setTarget(fields, previous)
|
|
167
|
+
}
|
|
168
|
+
connect() { return this.delegate.connect() }
|
|
169
|
+
exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
|
|
170
|
+
sftp() { return this.delegate.sftp() }
|
|
171
|
+
detect() { return this.delegate.detect() }
|
|
172
|
+
invalidate() { return this.delegate.invalidate() }
|
|
173
|
+
close() { return this.delegate.close() }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class SshPool {
|
|
177
|
+
constructor(config, opts = {}) {
|
|
178
|
+
this.config = config
|
|
179
|
+
this._knownHostsFile = opts.knownHostsFile
|
|
180
|
+
this.client = null
|
|
181
|
+
this.connecting = null
|
|
182
|
+
this.proxyPool = null
|
|
183
|
+
// Generational token: bumped on every target change / close so a stale
|
|
184
|
+
// in-flight connect can never hand this pool a connection to an old host.
|
|
185
|
+
this.epoch = 0
|
|
186
|
+
/** Auto-detected remote platform: unknown | windows | posix (per target). */
|
|
187
|
+
this.platform = 'unknown'
|
|
188
|
+
/** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
|
|
189
|
+
this.gitBashPath = ''
|
|
190
|
+
/** Resolved terminal strategy: native | git-bash. */
|
|
191
|
+
this.shellMode = 'native'
|
|
192
|
+
/** In-flight platform detection promise (cached). */
|
|
193
|
+
this._detecting = null
|
|
194
|
+
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
195
|
+
this.passwordResolver = null
|
|
196
|
+
/** Optional hook called with the live client after a successful connect. */
|
|
197
|
+
this.onReady = null
|
|
198
|
+
/** Optional hook called when the pool closes. */
|
|
199
|
+
this.onCloseHook = null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
resolveKeyPath() {
|
|
203
|
+
const p = this.config.privateKeyPath
|
|
204
|
+
if (!p) return ''
|
|
205
|
+
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
206
|
+
return p
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
|
|
210
|
+
// Collect the requested changes first: an UNCHANGED target is a no-op. The
|
|
211
|
+
// old behavior closed the live connection and re-ran platform detection
|
|
212
|
+
// even for an identical target, so every picker keystroke
|
|
213
|
+
// (POST /current → applyActiveMachine → setTarget) paid a full SSH
|
|
214
|
+
// reconnect + re-detect — the "slow remote path autocomplete" bug.
|
|
215
|
+
//
|
|
216
|
+
// `previous` is the diff BASELINE. Callers whose shared config was already
|
|
217
|
+
// mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
|
|
218
|
+
// live config IN PLACE, and pool.config IS that config object) pass a
|
|
219
|
+
// pre-mutation snapshot — otherwise the comparison runs the new values
|
|
220
|
+
// against themselves, is always equal and never closes even for a machine
|
|
221
|
+
// switch (the wrong-host hazard of issue #25). Defaults to the current
|
|
222
|
+
// config for callers that pass fresh values read independently of it.
|
|
223
|
+
const base = previous || this.config
|
|
224
|
+
const wanted = []
|
|
225
|
+
if (host !== undefined) wanted.push(['host', String(host)])
|
|
226
|
+
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
227
|
+
if (username !== undefined) wanted.push(['username', String(username)])
|
|
228
|
+
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
229
|
+
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
230
|
+
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
231
|
+
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
232
|
+
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
233
|
+
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
234
|
+
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
235
|
+
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
236
|
+
// Issue #51: the transport backend is part of the target identity — changing
|
|
237
|
+
// it must close the live connection exactly like changing the host does.
|
|
238
|
+
if (backend !== undefined) wanted.push(['backend', String(backend)])
|
|
239
|
+
if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
|
|
240
|
+
const differs = ([k, v]) => (k === 'proxy'
|
|
241
|
+
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
242
|
+
: base[k] !== v)
|
|
243
|
+
// Something actually changed → apply it and force a fresh connection.
|
|
244
|
+
// (A caller that WANTS a reconnect for an identical target uses
|
|
245
|
+
// invalidate(); setTarget semantics stay "re-point when moved".)
|
|
246
|
+
if (wanted.some(differs)) {
|
|
247
|
+
for (const [k, v] of wanted) this.config[k] = v
|
|
248
|
+
// the new target may be a different OS — re-detect on the next command
|
|
249
|
+
this.platform = 'unknown'
|
|
250
|
+
this.gitBashPath = ''
|
|
251
|
+
this.shellMode = 'native'
|
|
252
|
+
this._detecting = null
|
|
253
|
+
this.close()
|
|
254
|
+
}
|
|
255
|
+
return this
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
connect() {
|
|
259
|
+
if (this.client) return Promise.resolve(this.client)
|
|
260
|
+
if (this.connecting) return this.connecting
|
|
261
|
+
const epoch = this.epoch
|
|
262
|
+
const pending = this._doConnect(epoch)
|
|
263
|
+
this.connecting = pending
|
|
264
|
+
const clear = () => {
|
|
265
|
+
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
266
|
+
}
|
|
267
|
+
pending.then(clear, clear)
|
|
268
|
+
return pending
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async _doConnect(epoch) {
|
|
272
|
+
const isCurrent = () => this.epoch === epoch
|
|
273
|
+
const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
|
|
274
|
+
const guard = createHostKeyGuard(this.config, knownHostsFile)
|
|
275
|
+
const client = new Client()
|
|
276
|
+
let settled = false
|
|
277
|
+
const fail = (err) => {
|
|
278
|
+
if (settled) return
|
|
279
|
+
settled = true
|
|
280
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
281
|
+
throw guard.lastError ? new Error(guard.lastError) : err
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
285
|
+
let sock = null
|
|
286
|
+
const proxyCfg = this.config.proxy
|
|
287
|
+
if (proxyCfg && proxyCfg.host) {
|
|
288
|
+
try {
|
|
289
|
+
this.proxyPool = new SshPool({
|
|
290
|
+
...this.config,
|
|
291
|
+
host: proxyCfg.host,
|
|
292
|
+
port: Number(proxyCfg.port) || 22,
|
|
293
|
+
username: proxyCfg.username || this.config.username || 'root',
|
|
294
|
+
password: proxyCfg.password || '',
|
|
295
|
+
privateKeyPath: proxyCfg.privateKeyPath || '',
|
|
296
|
+
passphrase: proxyCfg.passphrase || '',
|
|
297
|
+
proxy: undefined,
|
|
298
|
+
}, { knownHostsFile: this._knownHostsFile })
|
|
299
|
+
const pclient = await this.proxyPool.connect()
|
|
300
|
+
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
301
|
+
try {
|
|
302
|
+
sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
|
|
303
|
+
} catch (e) {
|
|
304
|
+
throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return fail(err)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const rejectOnce = (err) => {
|
|
313
|
+
if (settled) return
|
|
314
|
+
settled = true
|
|
315
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
316
|
+
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
317
|
+
}
|
|
318
|
+
client.on('ready', () => {
|
|
319
|
+
if (settled) return
|
|
320
|
+
settled = true
|
|
321
|
+
if (!isCurrent()) {
|
|
322
|
+
try { client.end() } catch {}
|
|
323
|
+
reject(new Error('ssh target changed during connect'))
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
this.client = client
|
|
327
|
+
resolve(client)
|
|
328
|
+
if (this.onReady) { try { this.onReady(client) } catch {} }
|
|
329
|
+
})
|
|
330
|
+
client.on('error', (e) => rejectOnce(e))
|
|
331
|
+
client.on('close', () => {
|
|
332
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
333
|
+
rejectOnce(new Error('ssh connection closed'))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const buildOpts = async () => {
|
|
337
|
+
const opts = {
|
|
338
|
+
host: this.config.host,
|
|
339
|
+
port: this.config.port,
|
|
340
|
+
username: this.config.username,
|
|
341
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
342
|
+
keepaliveInterval: 15000,
|
|
343
|
+
keepaliveCountMax: 3,
|
|
344
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
345
|
+
}
|
|
346
|
+
if (sock) opts.sock = sock
|
|
347
|
+
if (this.config.useAgent) {
|
|
348
|
+
const sockPath = process.env.SSH_AUTH_SOCK
|
|
349
|
+
if (sockPath) opts.agent = sockPath
|
|
350
|
+
}
|
|
351
|
+
let password = this.config.password || ''
|
|
352
|
+
if (!password && this.passwordResolver) {
|
|
353
|
+
try { password = (await this.passwordResolver()) || '' } catch {}
|
|
354
|
+
}
|
|
355
|
+
if (password) {
|
|
356
|
+
opts.password = password
|
|
357
|
+
opts.tryKeyboard = true
|
|
358
|
+
} else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
|
|
359
|
+
opts.tryKeyboard = true
|
|
360
|
+
}
|
|
361
|
+
if (this.config.privateKeyPath) {
|
|
362
|
+
const keyPath = this.resolveKeyPath()
|
|
363
|
+
if (!keyPath) {
|
|
364
|
+
throw new Error('no credentials: set a password or a privateKeyPath to connect')
|
|
365
|
+
}
|
|
366
|
+
let key
|
|
367
|
+
try {
|
|
368
|
+
key = readFileSync(keyPath)
|
|
369
|
+
} catch (err) {
|
|
370
|
+
throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
|
|
371
|
+
}
|
|
372
|
+
opts.privateKey = key
|
|
373
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
374
|
+
} else if (!password && !opts.agent) {
|
|
375
|
+
throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
|
|
376
|
+
}
|
|
377
|
+
return opts
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
buildOpts().then(
|
|
381
|
+
(opts) => {
|
|
382
|
+
if (opts.tryKeyboard) {
|
|
383
|
+
client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
|
384
|
+
finish(prompts.map(() => this.config.password || ''))
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
// ssh2 parses the private key SYNCHRONOUSLY inside connect() and
|
|
388
|
+
// throws on an unparseable key (the classic case: an encrypted
|
|
389
|
+
// OpenSSH key without a passphrase). Left uncaught, that throw
|
|
390
|
+
// rejects this orphaned .then chain — an unhandled rejection that
|
|
391
|
+
// crashes the whole host process (issue #48: "dsh: fatal load
|
|
392
|
+
// failure" on every boot, because boot-restore re-dials the bad
|
|
393
|
+
// machine). It also leaves the outer promise pending forever.
|
|
394
|
+
// Convert it into a normal rejection so every caller's try/catch —
|
|
395
|
+
// boot restore, test-connect, rw_connect — sees a regular failure.
|
|
396
|
+
try {
|
|
397
|
+
client.connect(opts)
|
|
398
|
+
} catch (err) {
|
|
399
|
+
rejectOnce(err)
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
(err) => rejectOnce(err),
|
|
403
|
+
)
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
408
|
+
detect() {
|
|
409
|
+
if (this.platform !== 'unknown') return Promise.resolve()
|
|
410
|
+
if (this._detecting) return this._detecting
|
|
411
|
+
this._detecting = this._detect().finally(() => {
|
|
412
|
+
this._detecting = null
|
|
413
|
+
})
|
|
414
|
+
return this._detecting
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async _detect() {
|
|
418
|
+
let res
|
|
419
|
+
try {
|
|
420
|
+
// `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
|
|
421
|
+
// "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
|
|
422
|
+
res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
423
|
+
} catch (err) {
|
|
424
|
+
this.platform = 'unknown'
|
|
425
|
+
this.shellMode = 'native'
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
429
|
+
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
430
|
+
this.platform = 'windows'
|
|
431
|
+
await this._resolveGitBash()
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
// inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
|
|
435
|
+
try {
|
|
436
|
+
const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
437
|
+
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
438
|
+
this.platform = 'windows'
|
|
439
|
+
await this._resolveGitBash()
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
} catch {}
|
|
443
|
+
this.platform = 'posix'
|
|
444
|
+
this.shellMode = 'native'
|
|
445
|
+
this.gitBashPath = ''
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
|
|
449
|
+
async _resolveGitBash() {
|
|
450
|
+
const cfg = String(this.config.shell || '').trim()
|
|
451
|
+
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
452
|
+
if (await this._cmdExists(cfg)) {
|
|
453
|
+
this.gitBashPath = cfg
|
|
454
|
+
this.shellMode = 'git-bash'
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (cfg === 'native') {
|
|
459
|
+
this.shellMode = 'native'
|
|
460
|
+
this.gitBashPath = ''
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
|
|
465
|
+
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
466
|
+
if (first) {
|
|
467
|
+
this.gitBashPath = first
|
|
468
|
+
this.shellMode = 'git-bash'
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
} catch {}
|
|
472
|
+
const candidates = [
|
|
473
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
474
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
475
|
+
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
476
|
+
'%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
|
|
477
|
+
]
|
|
478
|
+
for (const c of candidates) {
|
|
479
|
+
if (await this._cmdExists(c)) {
|
|
480
|
+
this.gitBashPath = c
|
|
481
|
+
this.shellMode = 'git-bash'
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
|
|
486
|
+
this.gitBashPath = ''
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async _cmdExists(p) {
|
|
490
|
+
try {
|
|
491
|
+
const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
|
|
492
|
+
return /Y/.test(String(r.stdout || ''))
|
|
493
|
+
} catch {
|
|
494
|
+
return false
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
499
|
+
* On Windows remotes with Git Bash the script is piped to `bash -s` over the
|
|
500
|
+
* exec-channel stdin — no shell quoting round-trip, so any content (quotes,
|
|
501
|
+
* backslashes, newlines) survives verbatim. */
|
|
502
|
+
exec(command, timeoutMsOrOpts) {
|
|
503
|
+
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
504
|
+
return this.detect().then(() => {
|
|
505
|
+
if (this.platform === 'windows' && this.gitBashPath) {
|
|
506
|
+
const script = String(command)
|
|
507
|
+
return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
|
|
508
|
+
try { stream.end(script) } catch {}
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
return this._execRaw(command, opts)
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
|
|
516
|
+
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
517
|
+
_execRaw(command, opts, stdinWriter) {
|
|
518
|
+
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
519
|
+
const signal = opts && opts.signal
|
|
520
|
+
return this.connect().then(
|
|
521
|
+
(client) =>
|
|
522
|
+
new Promise((resolve, reject) => {
|
|
523
|
+
let retried = false
|
|
524
|
+
// Caller cancellation (issue #44): a tool that hands us exec.signal
|
|
525
|
+
// expects the remote command to stop when the turn is cancelled.
|
|
526
|
+
// Resolve the same shape as a timeout so callers need no new branch.
|
|
527
|
+
let onAbort = null
|
|
528
|
+
const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
|
|
529
|
+
const runOn = (c) => {
|
|
530
|
+
const execOpts = {}
|
|
531
|
+
if (opts && opts.pty) execOpts.pty = true
|
|
532
|
+
if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
533
|
+
c.exec(command, execOpts, (err, stream) => {
|
|
534
|
+
if (err) {
|
|
535
|
+
// Channel-open failure — or a session termination — usually
|
|
536
|
+
// means the pooled connection died server-side (idle timeout /
|
|
537
|
+
// network reset) while keepalive hadn't noticed. Drop it and
|
|
538
|
+
// retry ONCE on a fresh connection.
|
|
539
|
+
if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
540
|
+
retried = true
|
|
541
|
+
this.invalidate()
|
|
542
|
+
return this.connect().then(
|
|
543
|
+
(fresh) => runOn(fresh),
|
|
544
|
+
(e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
548
|
+
}
|
|
549
|
+
let stdout = ''
|
|
550
|
+
let stderr = ''
|
|
551
|
+
let settled = false
|
|
552
|
+
let exitCode = null
|
|
553
|
+
let exitSignal = null
|
|
554
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
555
|
+
const settle = () => {
|
|
556
|
+
if (settled) return
|
|
557
|
+
settled = true
|
|
558
|
+
clearTimeout(timer)
|
|
559
|
+
cleanupAbort()
|
|
560
|
+
resolve({
|
|
561
|
+
code: exitCode,
|
|
562
|
+
signal: exitSignal,
|
|
563
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
564
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
const timer = setTimeout(() => {
|
|
568
|
+
if (settled) return
|
|
569
|
+
exitCode = -1
|
|
570
|
+
exitSignal = 'TIMEOUT'
|
|
571
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
572
|
+
// channel, so a runaway process cannot keep running and holding
|
|
573
|
+
// the SSH connection after we've given up on its output.
|
|
574
|
+
try {
|
|
575
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
576
|
+
} catch {}
|
|
577
|
+
const hardClose = setTimeout(() => {
|
|
578
|
+
try { stream.close() } catch {}
|
|
579
|
+
}, 800)
|
|
580
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
581
|
+
settle()
|
|
582
|
+
}, timeoutMs)
|
|
583
|
+
if (signal) {
|
|
584
|
+
if (signal.aborted) {
|
|
585
|
+
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
586
|
+
try { stream.close() } catch {}
|
|
587
|
+
exitCode = -1
|
|
588
|
+
exitSignal = 'ABORTED'
|
|
589
|
+
return settle()
|
|
590
|
+
}
|
|
591
|
+
onAbort = () => {
|
|
592
|
+
if (settled) return
|
|
593
|
+
exitCode = -1
|
|
594
|
+
exitSignal = 'ABORTED'
|
|
595
|
+
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
596
|
+
const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
|
|
597
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
598
|
+
settle()
|
|
599
|
+
}
|
|
600
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
601
|
+
}
|
|
602
|
+
stream.on('close', (code, sigName) => {
|
|
603
|
+
if (settled) return
|
|
604
|
+
exitCode = code
|
|
605
|
+
// `sigName` is the signal NAME that killed the remote process
|
|
606
|
+
// (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
|
|
607
|
+
// matters: `signal` in this scope is the AbortSignal, and
|
|
608
|
+
// assigning it here would leak an object into the result.
|
|
609
|
+
exitSignal = sigName
|
|
610
|
+
settle()
|
|
611
|
+
})
|
|
612
|
+
stream.on('data', (d) => {
|
|
613
|
+
if (stdout.length < hardCap) stdout += d
|
|
614
|
+
})
|
|
615
|
+
stream.stderr.on('data', (d) => {
|
|
616
|
+
if (stderr.length < hardCap) stderr += d
|
|
617
|
+
})
|
|
618
|
+
stream.on('error', (e) => {
|
|
619
|
+
if (settled) return
|
|
620
|
+
settled = true
|
|
621
|
+
clearTimeout(timer)
|
|
622
|
+
cleanupAbort()
|
|
623
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
624
|
+
})
|
|
625
|
+
if (stdinWriter) {
|
|
626
|
+
try { stdinWriter(stream) } catch {}
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
runOn(client)
|
|
631
|
+
}),
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
|
|
636
|
+
sftp() {
|
|
637
|
+
return this.connect().then(
|
|
638
|
+
(client) =>
|
|
639
|
+
new Promise((resolve, reject) => {
|
|
640
|
+
let retried = false
|
|
641
|
+
const runOn = (c) => {
|
|
642
|
+
// Bound the subchannel open itself, per attempt. Without this, a
|
|
643
|
+
// remote whose sftp subsystem is unresponsive (overloaded sshd,
|
|
644
|
+
// exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
|
|
645
|
+
// forever with no callback — issue #44 point 3. ssh2 only calls back
|
|
646
|
+
// on success or on a channel error, and a wedged peer produces
|
|
647
|
+
// neither.
|
|
648
|
+
//
|
|
649
|
+
// The timer must be created per attempt, not once outside: the
|
|
650
|
+
// callback clears it, so a single outer timer would leave the retry
|
|
651
|
+
// (below) with no timeout at all.
|
|
652
|
+
let timedOut = false
|
|
653
|
+
const attemptTimer = setTimeout(() => {
|
|
654
|
+
timedOut = true
|
|
655
|
+
reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
|
|
656
|
+
}, this.config.commandTimeoutMs)
|
|
657
|
+
// ★ 这里**不能 unref**。
|
|
658
|
+
//
|
|
659
|
+
// 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
|
|
660
|
+
// 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
|
|
661
|
+
// Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
|
|
662
|
+
// 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
|
|
663
|
+
// (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
|
|
664
|
+
// 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
|
|
665
|
+
// 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
|
|
666
|
+
const clearAttempt = () => { clearTimeout(attemptTimer) }
|
|
667
|
+
try {
|
|
668
|
+
c.sftp((err, sftp) => {
|
|
669
|
+
clearAttempt()
|
|
670
|
+
if (timedOut) return
|
|
671
|
+
if (err) {
|
|
672
|
+
// Same dead-connection recovery as exec(): a channel open
|
|
673
|
+
// failure — or a session termination (remote closed the
|
|
674
|
+
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
675
|
+
// drop) — means the pooled connection is stale: drop it and
|
|
676
|
+
// retry ONCE on a fresh connection.
|
|
677
|
+
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
678
|
+
retried = true
|
|
679
|
+
this.invalidate()
|
|
680
|
+
return this.connect().then(
|
|
681
|
+
(fresh) => runOn(fresh),
|
|
682
|
+
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
686
|
+
}
|
|
687
|
+
// The promisified surface (timeouts, dead-channel fail-fast,
|
|
688
|
+
// range reads) is shared verbatim with the system-OpenSSH
|
|
689
|
+
// backend — see lib/sftp-surface.js for the rules it encodes.
|
|
690
|
+
resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
|
|
691
|
+
})
|
|
692
|
+
} catch (e) {
|
|
693
|
+
clearAttempt()
|
|
694
|
+
reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
runOn(client)
|
|
698
|
+
}),
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Drop the cached client and force a fresh connection on the next call.
|
|
704
|
+
* Called when a channel open fails (e.g. "Channel open failure: open
|
|
705
|
+
* failed") — the pooled SSH connection is usually dead server-side while
|
|
706
|
+
* keepalive has not yet noticed, and reusing it keeps failing. The epoch
|
|
707
|
+
* bump orphans any in-flight connect; the client is ended so ssh2 frees
|
|
708
|
+
* its sockets.
|
|
709
|
+
*/
|
|
710
|
+
invalidate() {
|
|
711
|
+
this.epoch++
|
|
712
|
+
const client = this.client
|
|
713
|
+
this.client = null
|
|
714
|
+
const pending = this.connecting
|
|
715
|
+
this.connecting = null
|
|
716
|
+
if (pending && typeof pending.catch === 'function') {
|
|
717
|
+
try { pending.catch(() => {}) } catch {}
|
|
718
|
+
}
|
|
719
|
+
if (this.proxyPool) {
|
|
720
|
+
try { this.proxyPool.close() } catch {}
|
|
721
|
+
this.proxyPool = null
|
|
722
|
+
}
|
|
723
|
+
if (client) {
|
|
724
|
+
try { client.end() } catch {}
|
|
725
|
+
}
|
|
726
|
+
// Same teardown as close(): the forwards manager must detach from the
|
|
727
|
+
// dying client, or its stale `close -> stopAll()` listener later tears
|
|
728
|
+
// down forwards that were re-attached to the NEW client (onReady does
|
|
729
|
+
// the re-attach on the next successful connect).
|
|
730
|
+
if (this.onCloseHook) {
|
|
731
|
+
try { this.onCloseHook() } catch {}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
close() {
|
|
736
|
+
this.epoch++
|
|
737
|
+
const client = this.client
|
|
738
|
+
this.client = null
|
|
739
|
+
const pending = this.connecting
|
|
740
|
+
this.connecting = null
|
|
741
|
+
if (pending && typeof pending.catch === 'function') {
|
|
742
|
+
try { pending.catch(() => {}) } catch {}
|
|
743
|
+
}
|
|
744
|
+
if (this.proxyPool) {
|
|
745
|
+
try { this.proxyPool.close() } catch {}
|
|
746
|
+
this.proxyPool = null
|
|
747
|
+
}
|
|
748
|
+
if (client) {
|
|
749
|
+
try {
|
|
750
|
+
client.end()
|
|
751
|
+
} catch {}
|
|
752
|
+
}
|
|
753
|
+
if (this.onCloseHook) {
|
|
754
|
+
try { this.onCloseHook() } catch {}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|