dsh-remote 0.8.46 → 0.8.47
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 +156 -156
- package/lib/index.js +3771 -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 +230 -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 +95 -89
package/lib/web-attach.js
CHANGED
|
@@ -1,850 +1,850 @@
|
|
|
1
|
-
// dsh-remote — "remote DSH web UI" attach sessions (issue #46).
|
|
2
|
-
//
|
|
3
|
-
// The user's intent behind that issue is "use the local DSH as a CLIENT for a
|
|
4
|
-
// DSH running on another machine": they do NOT want the remote to expose an
|
|
5
|
-
// inbound listener. DSH makes that impossible anyway — `dsh-web-app` hard-
|
|
6
|
-
// rejects `--host 0.0.0.0` (startup.js) because it would put remote code
|
|
7
|
-
// execution on the network, and `dsh-host-webserver` only accepts the
|
|
8
|
-
// 127.0.0.1/0.0.0.0 literals.
|
|
9
|
-
//
|
|
10
|
-
// So this module inverts the direction, exactly like the rest of the plugin:
|
|
11
|
-
// we SSH OUT, start (or reuse) a loopback-bound `dsh web` on the remote, and
|
|
12
|
-
// carry its socket back over our own SSH connection with a local TCP forward.
|
|
13
|
-
// The browser then talks to 127.0.0.1:<localPort> on this machine.
|
|
14
|
-
//
|
|
15
|
-
// Verified end-to-end on a real Linux host (dsh 0.1.5-rc.2): a bare TCP forward
|
|
16
|
-
// is sufficient — the SPA, its assets, AND the `/api/remote.mux` WebSocket
|
|
17
|
-
// upgrade all transit it (HTTP/1.1 101), with no reverse proxy and no host
|
|
18
|
-
// rewriting. That works because DSH binds its auth cookie to the request
|
|
19
|
-
// authority, so a cookie minted against `127.0.0.1:<localPort>` is exactly what
|
|
20
|
-
// the browser then sends back to that same authority.
|
|
21
|
-
//
|
|
22
|
-
// Three details drive the design and are worth stating once:
|
|
23
|
-
//
|
|
24
|
-
// 1. The launch token CANNOT be read from anywhere but stdout. It is a
|
|
25
|
-
// `randomBytes` value held in a process-local WeakMap and printed on one
|
|
26
|
-
// line (`dsh web: http://127.0.0.1:<port>/?token=…`). No file, no env var.
|
|
27
|
-
// Therefore the token is captured by redirecting the remote process's
|
|
28
|
-
// output to a log file and parsing that line out of it.
|
|
29
|
-
// 2. `--port 0` lets the remote OS pick a free port, and the same printed line
|
|
30
|
-
// reports which port it chose — so we never have to guess or probe for a
|
|
31
|
-
// free port on the remote.
|
|
32
|
-
// 3. The pairing that must survive is (token, port) from ONE launch. Reading
|
|
33
|
-
// them from one line is what keeps them consistent.
|
|
34
|
-
//
|
|
35
|
-
// Everything here is read-only with respect to the remote filesystem except
|
|
36
|
-
// the log file it creates under the remote `$HOME`.
|
|
37
|
-
import net from 'node:net'
|
|
38
|
-
|
|
39
|
-
/** Remote log file per attach session; `$HOME` is expanded by the remote shell. */
|
|
40
|
-
const LOG_NAME = '.dsh-remote-web'
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Parse the `dsh web:` startup line out of a remote log.
|
|
44
|
-
*
|
|
45
|
-
* The line looks like:
|
|
46
|
-
* dsh web: http://127.0.0.1:43129/?token=Iy7FKuPSI5qJKJUFDyah4B6i8Ul6_hvspiJhiWufYt8
|
|
47
|
-
* and may be followed by a LAN suffix when the remote bound 0.0.0.0 (it never
|
|
48
|
-
* will here — we never pass `--host` — but tolerate it rather than break).
|
|
49
|
-
*
|
|
50
|
-
* @param {string} text - the remote log contents.
|
|
51
|
-
* @returns {{url: string, port: number, token: string}|null} parsed facts, or
|
|
52
|
-
* null when the line has not appeared yet (still booting) or is malformed.
|
|
53
|
-
*/
|
|
54
|
-
export function parseWebStart(text) {
|
|
55
|
-
if (typeof text !== 'string' || text === '') return null
|
|
56
|
-
// Take the LAST match: a reused log file may hold an earlier session's line,
|
|
57
|
-
// and the newest one is the one this launch produced.
|
|
58
|
-
const matches = [...text.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
59
|
-
if (matches.length === 0) return null
|
|
60
|
-
const url = matches[matches.length - 1][1]
|
|
61
|
-
let parsed
|
|
62
|
-
try {
|
|
63
|
-
parsed = new URL(url)
|
|
64
|
-
} catch {
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
const port = Number(parsed.port)
|
|
68
|
-
const token = parsed.searchParams.get('token') || ''
|
|
69
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
70
|
-
if (!token) return null
|
|
71
|
-
return { url, port, token }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** True when the log proves the remote process died instead of booting. */
|
|
75
|
-
export function launchFailed(text) {
|
|
76
|
-
return typeof text === 'string' && text.includes(DEAD_MARKER)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Marker the remote launcher echoes when the process exits before printing. */
|
|
80
|
-
const DEAD_MARKER = '__DSH_REMOTE_DIED__'
|
|
81
|
-
/** Marker separating the parsed line from the raw log tail for diagnostics. */
|
|
82
|
-
export const TAIL_MARKER = '__DSH_TAIL__'
|
|
83
|
-
/** Marker carrying the remote PID so the session can stop its own process. */
|
|
84
|
-
export const PID_MARKER = '__DSH_PID__='
|
|
85
|
-
/** Random ASCII tag so two attach sessions never share a remote log file. */
|
|
86
|
-
function randomTag() {
|
|
87
|
-
return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Build the one-shot remote command that launches `dsh web` detached and waits
|
|
92
|
-
* for its startup line.
|
|
93
|
-
*
|
|
94
|
-
* `setsid` + `nohup` + redirected stdio are all required: the SSH channel closes
|
|
95
|
-
* as soon as this shell exits, and without detaching the SIGHUP would kill the
|
|
96
|
-
* server we just started. The loop is bounded so a wedged remote cannot hang the
|
|
97
|
-
* call past the caller's own timeout, and it reports either the parsed line or
|
|
98
|
-
* the log tail — never silence.
|
|
99
|
-
*
|
|
100
|
-
* @param {object} spec - launch parameters.
|
|
101
|
-
* @param {boolean} [spec.omitNoOpen] - drop `--no-open`. Older DSH (measured on
|
|
102
|
-
* 0.1.0-rc.6) has no such flag and exits with "unknown option '--no-open'";
|
|
103
|
-
* the caller retries with this set rather than assuming a flag exists.
|
|
104
|
-
* @returns {string} a POSIX shell command line (no newlines, ASCII only).
|
|
105
|
-
*/
|
|
106
|
-
export function buildLaunchCommand({ command, profile, logPath, remotePort = 0, dshHome = '', waitSeconds = 45, omitNoOpen = false }) {
|
|
107
|
-
const env = dshHome ? `DSH_HOME="${dshHome}" ` : ''
|
|
108
|
-
const noOpen = omitNoOpen ? '' : ' --no-open'
|
|
109
|
-
const args = `--profile ${profile} --port ${Number(remotePort) || 0}${noOpen}`
|
|
110
|
-
const limit = Number(waitSeconds) || 45
|
|
111
|
-
// The loop is ONE shell element: `do` is a keyword and `do;` is a syntax error
|
|
112
|
-
// in POSIX sh, so it must be glued to the first command of the body rather
|
|
113
|
-
// than separated like the other statements.
|
|
114
|
-
const loop = `while [ $i -lt ${limit} ]; do grep -q 'token=' "$LOG" 2>/dev/null && break`
|
|
115
|
-
+ `; kill -0 $P 2>/dev/null || { echo ${DEAD_MARKER}; break; }`
|
|
116
|
-
+ `; sleep 1; i=$((i+1)); done`
|
|
117
|
-
return [
|
|
118
|
-
`LOG="$HOME/${logPath}"`,
|
|
119
|
-
`: > "$LOG"`,
|
|
120
|
-
// `command -v setsid` guards hosts without util-linux (macOS); plain nohup
|
|
121
|
-
// still detaches from the channel's HUP there.
|
|
122
|
-
`command -v setsid >/dev/null 2>&1 && SET=setsid || SET=`,
|
|
123
|
-
`${env}$SET nohup ${command} ${args} >"$LOG" 2>&1 </dev/null & P=$!`,
|
|
124
|
-
`i=0`,
|
|
125
|
-
loop,
|
|
126
|
-
`echo "${PID_MARKER}$P"`,
|
|
127
|
-
`grep -m1 'dsh web:' "$LOG" 2>/dev/null`,
|
|
128
|
-
`echo ${TAIL_MARKER}`,
|
|
129
|
-
`tail -20 "$LOG" 2>/dev/null`,
|
|
130
|
-
].join('; ')
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Extract the remote PID from launcher output (0 when absent). */
|
|
134
|
-
export function parseRemotePid(output) {
|
|
135
|
-
const m = /__DSH_PID__=(\d+)/.exec(String(output || ''))
|
|
136
|
-
return m ? Number(m[1]) : 0
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Recognize a known remote-side reason the web surface could not start, and
|
|
141
|
-
* return an actionable sentence for it.
|
|
142
|
-
*
|
|
143
|
-
* The point is to keep an operator from reading "the remote DSH did not report a
|
|
144
|
-
* startup token" and concluding the *plugin* is broken. Measured cause: dsh
|
|
145
|
-
* ≤ 0.1.0-rc.6 depends on `node-pty@1.1.0`, whose published tarball ships
|
|
146
|
-
* darwin/win32 prebuilds but **no `linux-x64`**, so `dsh web` cannot boot on
|
|
147
|
-
* Linux at all — it dies loading the native module. 0.1.5-rc.2 moved to
|
|
148
|
-
* `node-pty@1.2.0-beta.15`, which does ship Linux builds.
|
|
149
|
-
*
|
|
150
|
-
* @param {string} output - the remote log tail.
|
|
151
|
-
* @returns {string} a hint, or '' when nothing is recognized.
|
|
152
|
-
*/
|
|
153
|
-
export function explainLaunchFailure(output) {
|
|
154
|
-
const text = String(output || '')
|
|
155
|
-
if (/pty\.node|Failed to load native module/i.test(text)) {
|
|
156
|
-
return ' The remote could not load node-pty\'s native module. Known cause: dsh <= 0.1.0-rc.6'
|
|
157
|
-
+ ' depends on node-pty@1.1.0, which publishes no linux-x64 prebuild, so `dsh web` cannot start'
|
|
158
|
-
+ ' on Linux at all. Upgrade the remote dsh to 0.1.5-rc.2 or newer (node-pty@1.2.0-beta.15 ships'
|
|
159
|
-
+ ' the Linux build), or point `webAttachCommand` at a newer dsh installed elsewhere.'
|
|
160
|
-
}
|
|
161
|
-
if (/is intentionally not supported|--host 0\.0\.0\.0/i.test(text)) {
|
|
162
|
-
return ' The remote dsh refused an all-interfaces bind. Attach deliberately never passes `--host`;'
|
|
163
|
-
+ ' check `webAttachCommand` does not add one.'
|
|
164
|
-
}
|
|
165
|
-
if (/command not found|No such file or directory|not found/i.test(text)) {
|
|
166
|
-
return ' The remote shell could not run the dsh command. If the remote dsh is not on the SSH login'
|
|
167
|
-
+ ' PATH (a version manager or private prefix), set `webAttachCommand` to its absolute path.'
|
|
168
|
-
}
|
|
169
|
-
return ''
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Build the command that stops the remote process THIS session started.
|
|
174
|
-
*
|
|
175
|
-
* Two things make this less obvious than it looks, both found by running it
|
|
176
|
-
* against a real host:
|
|
177
|
-
* 1. The log path cannot identify the process — it appears only as a shell
|
|
178
|
-
* redirect, so it is absent from argv and `pkill -f <logfile>` matches
|
|
179
|
-
* nothing (the process survived, verified live).
|
|
180
|
-
* 2. The PORT cannot identify it either, because we deliberately launch with
|
|
181
|
-
* `--port 0` (the remote picks a free port, and the chosen number is only
|
|
182
|
-
* known afterwards). A `--port 0` substring match therefore never fires.
|
|
183
|
-
*
|
|
184
|
-
* So the recorded PID is the handle, and the kill targets its process GROUP
|
|
185
|
-
* (`kill -- -PID`): the launch goes through `setsid`, making the child a
|
|
186
|
-
* session/group leader whose PGID equals its PID, so the group contains exactly
|
|
187
|
-
* the process tree we started — never a sibling attach session and never the
|
|
188
|
-
* user's own DSH. Before signalling, the command re-verifies that the PID still
|
|
189
|
-
* exists and still looks like a `dsh` process, so a recycled PID belonging to an
|
|
190
|
-
* unrelated program is not killed.
|
|
191
|
-
*
|
|
192
|
-
* @param {number} pid - the PID reported by the launch.
|
|
193
|
-
* @param {string} [logPath] - this session's log file, removed after the kill so
|
|
194
|
-
* repeated attaches do not accumulate files in the remote home.
|
|
195
|
-
* @returns {string} a POSIX shell command (always exits 0, never throws).
|
|
196
|
-
*/
|
|
197
|
-
export function buildStopCommand(pid, logPath = '') {
|
|
198
|
-
const p = Number(pid) || 0
|
|
199
|
-
const cleanup = logPath ? `; rm -f "$HOME/${logPath}" 2>/dev/null` : ''
|
|
200
|
-
if (p < 1) return `true${cleanup}`
|
|
201
|
-
return `P=${p}`
|
|
202
|
-
+ `; if [ -d "/proc/$P" ]; then`
|
|
203
|
-
+ ` C=$(tr '\\0' ' ' < "/proc/$P/cmdline" 2>/dev/null);`
|
|
204
|
-
+ ` case "$C" in *dsh*) kill -- -"$P" 2>/dev/null || kill "$P" 2>/dev/null;; esac;`
|
|
205
|
-
+ ` fi${cleanup}; true`
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* One attached remote DSH Web UI: a local listener whose sockets are carried
|
|
210
|
-
* over SSH to the remote's loopback web port.
|
|
211
|
-
*
|
|
212
|
-
* The listener is a plain `net.Server` + `client.forwardOut` pair — the same
|
|
213
|
-
* mechanism `ForwardManager` uses for user-defined forwards. It is implemented
|
|
214
|
-
* here rather than through that manager because this tunnel is bound to a
|
|
215
|
-
* CHOSEN pool (the machine the user picked), whereas `ForwardManager` is wired
|
|
216
|
-
* to the single "active machine" pool; routing an attach through it would send
|
|
217
|
-
* the tunnel to whichever machine happened to be active.
|
|
218
|
-
*/
|
|
219
|
-
export class WebAttach {
|
|
220
|
-
/**
|
|
221
|
-
* @param {object} opts - injected collaborators (all optional in production).
|
|
222
|
-
* @param {object} [opts.net] - a `node:net`-compatible module (tests).
|
|
223
|
-
* @param {number} [opts.localPortStart] - first local port to try.
|
|
224
|
-
* @param {number} [opts.localPortMax] - how many ports to try before failing.
|
|
225
|
-
* @param {boolean} [opts.trackPool] - follow the pool across reconnects so a
|
|
226
|
-
* dropped SSH connection degrades to "disconnected" and then SELF-HEALS
|
|
227
|
-
* instead of being a permanent dead end (issue #54). `true` in production.
|
|
228
|
-
*/
|
|
229
|
-
constructor(opts = {}) {
|
|
230
|
-
this.net = opts.net || net
|
|
231
|
-
// `??`, not `||`: port 0 is a MEANINGFUL value here (let the OS pick), and
|
|
232
|
-
// `0 || 3088` silently turned "let the OS choose" into a bind on a fixed
|
|
233
|
-
// port. An explicit 0 now survives, which is also what lets a test ask for a
|
|
234
|
-
// port without risking a collision with something really listening.
|
|
235
|
-
this.localPortStart = opts.localPortStart ?? 3088
|
|
236
|
-
this.localPortMax = opts.localPortMax ?? 50
|
|
237
|
-
/** Whether the pool's lifecycle hooks are ours to install. */
|
|
238
|
-
this.trackPool = opts.trackPool !== false
|
|
239
|
-
/** @type {import('node:net').Server|null} */ this.server = null
|
|
240
|
-
/** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
|
|
241
|
-
/** @type {object|null} */ this.client = null
|
|
242
|
-
/** The pool this session borrowed its connection from (may be null). */
|
|
243
|
-
this.pool = null
|
|
244
|
-
/** Pool hooks we installed, so close() can hand them back untouched. */
|
|
245
|
-
this.prevOnReady = null
|
|
246
|
-
this.prevOnCloseHook = null
|
|
247
|
-
this.hookedPool = null
|
|
248
|
-
this._installedOnReady = null
|
|
249
|
-
this._installedOnClose = null
|
|
250
|
-
this.attachedClient = null
|
|
251
|
-
/** Bound listener, kept so it can be removed from the client. */
|
|
252
|
-
this.onClientClose = null
|
|
253
|
-
this.localPort = 0
|
|
254
|
-
this.remotePort = 0
|
|
255
|
-
this.token = ''
|
|
256
|
-
this.tag = ''
|
|
257
|
-
this.logPath = ''
|
|
258
|
-
this.remotePid = 0
|
|
259
|
-
this.machineId = ''
|
|
260
|
-
this.command = ''
|
|
261
|
-
this.startedAt = 0
|
|
262
|
-
/** Launch knobs remembered so a restart after a drop uses the same ones. */
|
|
263
|
-
this.waitSeconds = 0
|
|
264
|
-
this.omittedNoOpen = false
|
|
265
|
-
/** True while a reconnect-triggered re-resolve is in flight. */
|
|
266
|
-
this._resolving = false
|
|
267
|
-
/** True once the borrowed connection has been lost (drives recovery). */
|
|
268
|
-
this.connectionLost = false
|
|
269
|
-
/** undefined = "not asked yet"; false = the remote process is provably gone. */
|
|
270
|
-
this._remoteAlive = undefined
|
|
271
|
-
/** Set by close(): nothing may re-arm the tunnel afterwards. */
|
|
272
|
-
this.closed = false
|
|
273
|
-
/** Set once the borrowed connection has gone away (see _clientLost). */
|
|
274
|
-
this.lastError = ''
|
|
275
|
-
/**
|
|
276
|
-
* A listener that throws EARNS an 'error' event on the emitting object, and
|
|
277
|
-
* an 'error' with no listener is itself fatal — the very crash this module
|
|
278
|
-
* exists to prevent. Nothing here is expected to throw, but a lifecycle
|
|
279
|
-
* callback running on someone else's event loop must never be the reason
|
|
280
|
-
* the host process dies, so both hooks are wrapped.
|
|
281
|
-
*/
|
|
282
|
-
this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
|
|
283
|
-
this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Whether the tunnel is currently listening. */
|
|
287
|
-
get active() {
|
|
288
|
-
return this.server !== null
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Attach to a remote DSH Web UI.
|
|
293
|
-
*
|
|
294
|
-
* @param {object} spec - the attach request.
|
|
295
|
-
* @param {object} spec.pool - the SshPool for the chosen machine.
|
|
296
|
-
* @param {string} spec.machineId - machine id, for bookkeeping.
|
|
297
|
-
* @param {string} [spec.command] - remote dsh executable (default `dsh`).
|
|
298
|
-
* @param {string} [spec.profile] - DSH profile to boot (default `web`).
|
|
299
|
-
* @param {string} [spec.existingUrl] - attach to an ALREADY running remote
|
|
300
|
-
* web UI instead of starting one: its `http://127.0.0.1:<port>/?token=…`.
|
|
301
|
-
* @param {(cmd: string, opts?: object) => Promise<{stdout: string, code: number}>} [spec.exec]
|
|
302
|
-
* - remote exec, defaulting to `pool.exec`.
|
|
303
|
-
* @returns {Promise<{url: string, localPort: number, remotePort: number}>}
|
|
304
|
-
*/
|
|
305
|
-
async open(spec) {
|
|
306
|
-
const {
|
|
307
|
-
pool, machineId = '', command = 'dsh', profile = 'web',
|
|
308
|
-
existingUrl = '', dshHome = '', waitSeconds = 45,
|
|
309
|
-
} = spec || {}
|
|
310
|
-
if (!pool) throw new Error('web-attach: a machine pool is required')
|
|
311
|
-
this.machineId = String(machineId || '')
|
|
312
|
-
this.command = command
|
|
313
|
-
this.waitSeconds = Number(waitSeconds) || 45
|
|
314
|
-
|
|
315
|
-
const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
|
|
316
|
-
// Connecting first also surfaces credential/host-key problems BEFORE we
|
|
317
|
-
// start a remote process we would then have to clean up. `_attach` (rather
|
|
318
|
-
// than a bare assignment) is what subscribes to the connection's 'close',
|
|
319
|
-
// so the loss is noticed the moment it happens instead of on the next
|
|
320
|
-
// request — which is when the old code let ssh2 throw 'Not connected' from
|
|
321
|
-
// inside a socket callback and take the host process with it (issue #54).
|
|
322
|
-
this._attach(await pool.connect())
|
|
323
|
-
this._followPool(pool)
|
|
324
|
-
|
|
325
|
-
if (existingUrl) {
|
|
326
|
-
const parsed = parseWebStart(`dsh web: ${existingUrl}`)
|
|
327
|
-
if (!parsed) {
|
|
328
|
-
throw new Error('web-attach: existing URL must look like http://127.0.0.1:<port>/?token=<token>')
|
|
329
|
-
}
|
|
330
|
-
this.remotePort = parsed.port
|
|
331
|
-
this.token = parsed.token
|
|
332
|
-
} else {
|
|
333
|
-
this.tag = randomTag()
|
|
334
|
-
const logPath = `${LOG_NAME}-${this.tag}.log`
|
|
335
|
-
this.logPath = logPath
|
|
336
|
-
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
337
|
-
let out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, false))
|
|
338
|
-
// Older DSH (0.1.0-rc.6) has no `--no-open` and exits on the unknown flag
|
|
339
|
-
// before doing anything. Retry once without it: `--no-open` is a nicety
|
|
340
|
-
// (it stops the REMOTE from opening its own browser), not a requirement,
|
|
341
|
-
// and a bounded single retry is safe because the first attempt provably
|
|
342
|
-
// never started a server.
|
|
343
|
-
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
344
|
-
out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, true))
|
|
345
|
-
this.omittedNoOpen = true
|
|
346
|
-
}
|
|
347
|
-
this.remotePid = parseRemotePid(out)
|
|
348
|
-
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
349
|
-
// Both failure shapes carry the same diagnostics: the process either died
|
|
350
|
-
// (its output is the whole story) or is still booting after the deadline.
|
|
351
|
-
if (launchFailed(out)) {
|
|
352
|
-
// The remote process is already gone — nothing to clean up.
|
|
353
|
-
throw new Error('web-attach: the remote DSH exited during startup.'
|
|
354
|
-
+ explainLaunchFailure(tail || out)
|
|
355
|
-
+ ` ${tail || 'no output'}`)
|
|
356
|
-
}
|
|
357
|
-
const parsed = parseWebStart(out)
|
|
358
|
-
if (!parsed) {
|
|
359
|
-
// A timed-out launch may STILL be booting: the deadline expiring is not
|
|
360
|
-
// evidence the process died. Kill it before throwing, or every retry
|
|
361
|
-
// leaks another remote server (measured: three leftover `dsh --port 0`
|
|
362
|
-
// processes after three failed attaches). A PID of 0 means the launcher
|
|
363
|
-
// could not report one — nothing to target.
|
|
364
|
-
if (this.remotePid > 0) {
|
|
365
|
-
try {
|
|
366
|
-
await exec(buildStopCommand(this.remotePid, logPath), { timeoutMs: 20000 })
|
|
367
|
-
this.remotePid = 0
|
|
368
|
-
} catch { /* the process may already be gone; a leftover is worse than a failed kill attempt only if it repeats, and the next attach replaces this session */ }
|
|
369
|
-
}
|
|
370
|
-
throw new Error(
|
|
371
|
-
`web-attach: the remote DSH did not report a startup token within ${waitSeconds}s.`
|
|
372
|
-
+ ` Check that \`${command}\` exists on the remote and can boot a web profile.`
|
|
373
|
-
+ explainLaunchFailure(tail || out)
|
|
374
|
-
+ (tail ? ` Remote output: ${tail}` : ''),
|
|
375
|
-
)
|
|
376
|
-
}
|
|
377
|
-
this.remotePort = parsed.port
|
|
378
|
-
this.token = parsed.token
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
await this._listen()
|
|
382
|
-
this.startedAt = Date.now()
|
|
383
|
-
return this.describe()
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Run one launch attempt and return its raw output.
|
|
388
|
-
*
|
|
389
|
-
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
390
|
-
* @param {object} d - launch parameters plus the exec timeout.
|
|
391
|
-
* @param {boolean} omitNoOpen - drop `--no-open` for old DSH.
|
|
392
|
-
* @returns {Promise<string>} the launcher's stdout.
|
|
393
|
-
*/
|
|
394
|
-
async _launch(exec, d, omitNoOpen) {
|
|
395
|
-
const cmd = buildLaunchCommand({ ...d, omitNoOpen })
|
|
396
|
-
const res = await exec(cmd, { timeoutMs: d.timeoutMs })
|
|
397
|
-
return String(res && res.stdout ? res.stdout : '')
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/** Bind the local listener, advancing past ports already in use. */
|
|
401
|
-
async _listen() {
|
|
402
|
-
let lastError = null
|
|
403
|
-
for (let i = 0; i < this.localPortMax; i++) {
|
|
404
|
-
const port = this.localPortStart + i
|
|
405
|
-
try {
|
|
406
|
-
await this._listenOn(port)
|
|
407
|
-
this.localPort = port
|
|
408
|
-
return
|
|
409
|
-
} catch (err) {
|
|
410
|
-
lastError = err
|
|
411
|
-
// Only a taken port is worth retrying; anything else (permissions,
|
|
412
|
-
// an unusable interface) will not be fixed by the next number.
|
|
413
|
-
if (err && err.code !== 'EADDRINUSE') break
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
throw new Error(
|
|
417
|
-
`web-attach: no free local port in ${this.localPortStart}..${this.localPortStart + this.localPortMax - 1}`
|
|
418
|
-
+ (lastError ? ` (${lastError.code || lastError.message})` : ''),
|
|
419
|
-
)
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
_listenOn(port) {
|
|
423
|
-
return new Promise((resolve, reject) => {
|
|
424
|
-
const server = this.net.createServer((socket) => this._pipe(socket))
|
|
425
|
-
const onError = (err) => {
|
|
426
|
-
server.removeListener('listening', onListening)
|
|
427
|
-
try { server.close() } catch { /* never listened */ }
|
|
428
|
-
reject(err)
|
|
429
|
-
}
|
|
430
|
-
const onListening = () => {
|
|
431
|
-
server.removeListener('error', onError)
|
|
432
|
-
// A post-bind error must not crash the host process: report and drop
|
|
433
|
-
// the tunnel so the UI can show "disconnected" instead of dying.
|
|
434
|
-
server.on('error', (err) => {
|
|
435
|
-
this.server = null
|
|
436
|
-
this.lastError = String((err && err.message) || err)
|
|
437
|
-
})
|
|
438
|
-
this.server = server
|
|
439
|
-
resolve()
|
|
440
|
-
}
|
|
441
|
-
server.once('error', onError)
|
|
442
|
-
server.once('listening', onListening)
|
|
443
|
-
// Loopback only: this endpoint reaches a full remote harness, so it must
|
|
444
|
-
// never be reachable from the network even on a LAN-facing machine.
|
|
445
|
-
server.listen(port, '127.0.0.1')
|
|
446
|
-
})
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Borrow the given connection AND subscribe to its lifecycle.
|
|
451
|
-
*
|
|
452
|
-
* Idempotent for the same client (open() calls it once, the pool's `onReady`
|
|
453
|
-
* calls it again on every reconnect), and it always drops the previous
|
|
454
|
-
* client's listener first, so one session never accumulates listeners.
|
|
455
|
-
*
|
|
456
|
-
* @param {object|null} client - the live ssh2 client / OpenSshConnection.
|
|
457
|
-
*/
|
|
458
|
-
_attach(client) {
|
|
459
|
-
if (!client) return
|
|
460
|
-
if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
|
|
461
|
-
// A different client replaced the one we borrowed (pool reconnect). Stop
|
|
462
|
-
// listening to the old one; its own 'close' has either fired already or
|
|
463
|
-
// belongs to a connection nobody uses any more.
|
|
464
|
-
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
465
|
-
}
|
|
466
|
-
this.client = client
|
|
467
|
-
if (this.attachedClient === client) return
|
|
468
|
-
this.attachedClient = client
|
|
469
|
-
this.onClientClose = () => { this._clientLost('ssh connection closed') }
|
|
470
|
-
// Without this, the object keeps handing out a connection that is already
|
|
471
|
-
// closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
|
|
472
|
-
try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** The borrowed connection is gone: keep the session, drop the dead handle. */
|
|
476
|
-
_clientLost(reason) {
|
|
477
|
-
this.client = null
|
|
478
|
-
this.connectionLost = true
|
|
479
|
-
this.lastError = String(reason || 'ssh connection closed')
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Register lifecycle hooks on the chosen pool so this tunnel follows it.
|
|
484
|
-
*
|
|
485
|
-
* The pool is the one place that both NOTICES a dead connection and later
|
|
486
|
-
* obtains a fresh one, and it exposes exactly two hooks for that:
|
|
487
|
-
* • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
|
|
488
|
-
* the "channel open failed, the pooled connection died server-side"
|
|
489
|
-
* self-heal path, which is the likeliest trigger);
|
|
490
|
-
* • `onReady` — fires after a successful (re)connect.
|
|
491
|
-
*
|
|
492
|
-
* Both are SINGLE-SLOT properties that the plugin already uses for
|
|
493
|
-
* `ForwardManager` (index.js), so this session CHAINS them rather than
|
|
494
|
-
* clobbering the forwards manager, and `_detachPool()` restores exactly what
|
|
495
|
-
* was there — the plugin may legitimately have none, one, or both.
|
|
496
|
-
*
|
|
497
|
-
* A real reconnect is the only way the remote port can move, so once the pool
|
|
498
|
-
* reports a new connection we must re-resolve (token, port) from the remote
|
|
499
|
-
* log. `onReady` itself cannot await: the pool fires it with the client in
|
|
500
|
-
* hand, so the re-resolve is kicked off and its failure is recorded rather
|
|
501
|
-
* than thrown into the pool's callback.
|
|
502
|
-
*
|
|
503
|
-
* @param {object} pool - the pool this session attached through.
|
|
504
|
-
*/
|
|
505
|
-
_followPool(pool) {
|
|
506
|
-
if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
|
|
507
|
-
this._detachPool()
|
|
508
|
-
this.pool = pool
|
|
509
|
-
this.hookedPool = pool
|
|
510
|
-
const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
|
|
511
|
-
const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
|
|
512
|
-
this.prevOnReady = prevReady
|
|
513
|
-
this.prevOnCloseHook = prevClose
|
|
514
|
-
const installedReady = (client) => {
|
|
515
|
-
this._attach(client)
|
|
516
|
-
if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
|
|
517
|
-
// A reconnect lands on a NEW remote process only if the old one died with
|
|
518
|
-
// the machine; re-read the log to find out which. The re-resolve runs off
|
|
519
|
-
// the event loop because the pool cannot await it — a hook that throws
|
|
520
|
-
// would earn an 'error' event on the pool, and an unhandled rejection
|
|
521
|
-
// would be fatal.
|
|
522
|
-
if (!this.closed && this.remotePort && !this._resolving) {
|
|
523
|
-
this._resolving = true
|
|
524
|
-
Promise.resolve()
|
|
525
|
-
.then(() => this._refreshRemotePort())
|
|
526
|
-
.catch((err) => { this.lastError = String((err && err.message) || err) })
|
|
527
|
-
.then(() => { this._resolving = false })
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
const installedClose = () => {
|
|
531
|
-
this._clientLost('ssh connection closed')
|
|
532
|
-
if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
|
|
533
|
-
}
|
|
534
|
-
this._installedOnReady = installedReady
|
|
535
|
-
this._installedOnClose = installedClose
|
|
536
|
-
pool.onReady = installedReady
|
|
537
|
-
pool.onCloseHook = installedClose
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Hand the pool's hooks back.
|
|
542
|
-
*
|
|
543
|
-
* Each slot is restored ONLY when it still holds the wrapper this session
|
|
544
|
-
* installed. The pool's hooks are single-slot, so if something else has taken
|
|
545
|
-
* the slot over in the meantime, putting a stale callback back would be worse
|
|
546
|
-
* than leaving it alone — it would silently disconnect a newer owner.
|
|
547
|
-
*/
|
|
548
|
-
_detachPool() {
|
|
549
|
-
const pool = this.hookedPool
|
|
550
|
-
const mineReady = this._installedOnReady
|
|
551
|
-
const mineClose = this._installedOnClose
|
|
552
|
-
this.hookedPool = null
|
|
553
|
-
this._installedOnReady = null
|
|
554
|
-
this._installedOnClose = null
|
|
555
|
-
if (!pool) return
|
|
556
|
-
if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
|
|
557
|
-
if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
|
|
558
|
-
this.prevOnReady = null
|
|
559
|
-
this.prevOnCloseHook = null
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* True when the borrowed connection was LOST and this is a session we can
|
|
564
|
-
* recover: we launched the remote process ourselves (so we hold its PID and
|
|
565
|
-
* log) and the tunnel is listening (so clients can actually be served).
|
|
566
|
-
*
|
|
567
|
-
* The `connectionLost` flag is deliberately separate from `this.client`: the
|
|
568
|
-
* pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
|
|
569
|
-
* check on `!this.client` would be false by then and recovery would never
|
|
570
|
-
* happen.
|
|
571
|
-
*/
|
|
572
|
-
get _needsReResolve() {
|
|
573
|
-
return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
|
|
578
|
-
* when it did not survive.
|
|
579
|
-
*
|
|
580
|
-
* Reaching a remote process needs a live SSH connection, so this reconnects
|
|
581
|
-
* through the pool first — the pool reports `onReady` for that same client in
|
|
582
|
-
* production, and in a test double the callback simply never fires.
|
|
583
|
-
*/
|
|
584
|
-
async _refreshRemotePort() {
|
|
585
|
-
if (!this._needsReResolve) return
|
|
586
|
-
const pool = this.pool
|
|
587
|
-
if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
|
|
588
|
-
const exec = (cmd, opts) => (typeof pool.exec === 'function'
|
|
589
|
-
? pool.exec(cmd, opts)
|
|
590
|
-
: Promise.reject(new Error('web-attach: the pool cannot run commands')))
|
|
591
|
-
if (!this.client) await pool.connect()
|
|
592
|
-
const match = await this._resolveRemotePort(exec)
|
|
593
|
-
this.remotePort = match.port
|
|
594
|
-
if (match.token) this.token = match.token
|
|
595
|
-
this.connectionLost = false
|
|
596
|
-
this.lastError = ''
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Read this session's remote log and decide which port is live.
|
|
601
|
-
*
|
|
602
|
-
* The startup line is the ONLY source of the port (the launch deliberately
|
|
603
|
-
* uses `--port 0`), so after the remote process died and was restarted the log
|
|
604
|
-
* holds BOTH lines — the dead one and the new one — and neither position
|
|
605
|
-
* identifies the live one. Evidence decides:
|
|
606
|
-
* • a process that is still alive on the remote is probed first, and when
|
|
607
|
-
* the port it is LISTENING on is one of the logged ports, that is the
|
|
608
|
-
* answer (the `dsh web: …` line names its own port, so this is a
|
|
609
|
-
* confirmation rather than a second guess);
|
|
610
|
-
* • otherwise the newest logged port wins, which is right in every
|
|
611
|
-
* single-process case and is the best available answer while the remote is
|
|
612
|
-
* still booting (the next reconnect retries).
|
|
613
|
-
*
|
|
614
|
-
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
615
|
-
* @returns {Promise<{port: number, token: string, restarted: boolean}>}
|
|
616
|
-
*/
|
|
617
|
-
async _resolveRemotePort(exec) {
|
|
618
|
-
this._remoteAlive = await this._remoteProcessAlive(exec)
|
|
619
|
-
if (!this._remoteAlive) return this._restartRemote(exec)
|
|
620
|
-
|
|
621
|
-
let out = ''
|
|
622
|
-
try {
|
|
623
|
-
const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
|
|
624
|
-
out = String((res && res.stdout) || '')
|
|
625
|
-
} catch { /* an unreadable log: the listening probe below is the fallback */ }
|
|
626
|
-
const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
627
|
-
.map((m) => parseWebStart(`dsh web: ${m[1]}`))
|
|
628
|
-
.filter(Boolean)
|
|
629
|
-
|
|
630
|
-
// What the recorded process actually listens on beats any guess from the
|
|
631
|
-
// log, because a killed-and-restarted remote leaves BOTH startup lines in
|
|
632
|
-
// the log and neither position says which one is live.
|
|
633
|
-
const listening = await this._listeningRemotePort(exec)
|
|
634
|
-
if (listening) {
|
|
635
|
-
const match = lines.find((l) => l.port === listening)
|
|
636
|
-
return { port: listening, token: (match && match.token) || this.token, restarted: false }
|
|
637
|
-
}
|
|
638
|
-
if (lines.length) {
|
|
639
|
-
const newest = lines[lines.length - 1]
|
|
640
|
-
return { port: newest.port, token: newest.token, restarted: false }
|
|
641
|
-
}
|
|
642
|
-
// Alive but silent and not listening yet: the remote is booting. Keep the
|
|
643
|
-
// last known port and let the next reconnect re-resolve.
|
|
644
|
-
return { port: this.remotePort, token: this.token, restarted: false }
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Whether the remote process THIS session started still exists.
|
|
649
|
-
*
|
|
650
|
-
* Verified through `/proc`, which is Linux-only; a host that cannot answer (or
|
|
651
|
-
* a failed probe) is treated as ALIVE on purpose. Restarting a server that was
|
|
652
|
-
* never dead would spawn a second one and leak a port, whereas assuming life
|
|
653
|
-
* merely retries a port that turns out to be stale.
|
|
654
|
-
*
|
|
655
|
-
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
656
|
-
* @returns {Promise<boolean>}
|
|
657
|
-
*/
|
|
658
|
-
async _remoteProcessAlive(exec) {
|
|
659
|
-
const pid = Number(this.remotePid) || 0
|
|
660
|
-
if (pid < 1) return true
|
|
661
|
-
try {
|
|
662
|
-
const res = await exec(
|
|
663
|
-
`if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
|
|
664
|
-
{ timeoutMs: 15000 },
|
|
665
|
-
)
|
|
666
|
-
return !/GONE/.test(String((res && res.stdout) || ''))
|
|
667
|
-
} catch { return true }
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Ask the remote which loopback port the recorded PID listens on (0 = none).
|
|
672
|
-
*
|
|
673
|
-
* Two `/proc` reads are needed because a TCP listener appears in a socket
|
|
674
|
-
* table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
|
|
675
|
-
* symlinks give the socket INODE, and the table maps that inode to a port.
|
|
676
|
-
* Matching on the inode rather than the port is what makes this an
|
|
677
|
-
* observation instead of a second guess.
|
|
678
|
-
*/
|
|
679
|
-
async _listeningRemotePort(exec) {
|
|
680
|
-
const pid = Number(this.remotePid) || 0
|
|
681
|
-
if (pid < 1) return 0
|
|
682
|
-
let inodes = ''
|
|
683
|
-
try {
|
|
684
|
-
// `ls -l` on a process's own fds needs no privileges for the same uid.
|
|
685
|
-
const res = await exec(
|
|
686
|
-
`ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
|
|
687
|
-
{ timeoutMs: 15000 },
|
|
688
|
-
)
|
|
689
|
-
inodes = String((res && res.stdout) || '').trim()
|
|
690
|
-
} catch { return 0 }
|
|
691
|
-
if (!inodes) return 0
|
|
692
|
-
const want = new Set(inodes.split(/\s+/).filter(Boolean))
|
|
693
|
-
// `/proc/net/tcp` addresses are little-endian hex, so loopback is
|
|
694
|
-
// `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
|
|
695
|
-
const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
|
|
696
|
-
for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
697
|
-
let text = ''
|
|
698
|
-
try {
|
|
699
|
-
const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
|
|
700
|
-
text = String((res && res.stdout) || '')
|
|
701
|
-
} catch { continue }
|
|
702
|
-
for (const line of text.split('\n').slice(1)) {
|
|
703
|
-
const cols = line.trim().split(/\s+/)
|
|
704
|
-
// sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
|
|
705
|
-
if (cols.length < 10) continue
|
|
706
|
-
if (cols[3] !== '0A') continue // TCP_LISTEN
|
|
707
|
-
if (!want.has(cols[9])) continue
|
|
708
|
-
const [addr, portHex] = String(cols[1]).split(':')
|
|
709
|
-
if (!LOOPBACK.has(String(addr).toUpperCase())) continue
|
|
710
|
-
const port = parseInt(portHex, 16)
|
|
711
|
-
if (Number.isInteger(port) && port > 0 && port < 65536) return port
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
return 0
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Relaunch the remote `dsh web` because the previous process is gone.
|
|
719
|
-
*
|
|
720
|
-
* A restart CANNOT change the listening port locally, and a caller holding a
|
|
721
|
-
* stale port silently talks to the wrong place at worst and hangs at best, so
|
|
722
|
-
* the two facts the caller needs are checked: this is a session that launched
|
|
723
|
-
* its own process (an attach onto someone else's DSH stays hands-off and can
|
|
724
|
-
* never restart it), and recovery is possible at all (`_refreshRemotePort`
|
|
725
|
-
* has already returned early when it is not).
|
|
726
|
-
*/
|
|
727
|
-
async _restartRemote(exec) {
|
|
728
|
-
const command = this.command || 'dsh'
|
|
729
|
-
const waitSeconds = this.waitSeconds || 45
|
|
730
|
-
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
731
|
-
let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
|
|
732
|
-
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
733
|
-
out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
|
|
734
|
-
this.omittedNoOpen = true
|
|
735
|
-
}
|
|
736
|
-
this.remotePid = parseRemotePid(out) || this.remotePid
|
|
737
|
-
const parsed = parseWebStart(out)
|
|
738
|
-
if (!parsed) {
|
|
739
|
-
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
740
|
-
throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
|
|
741
|
-
+ ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
|
|
742
|
-
}
|
|
743
|
-
return { port: parsed.port, token: parsed.token, restarted: true }
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/** Carry one local socket to the remote web port over SSH. */
|
|
747
|
-
_pipe(socket) {
|
|
748
|
-
if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
749
|
-
// ssh2's forwardOut does NOT report a dead connection through its callback:
|
|
750
|
-
// when the socket is gone it THROWS 'Not connected' synchronously
|
|
751
|
-
// (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
|
|
752
|
-
// and the callback below is never reached — so `if (err)` cannot guard this
|
|
753
|
-
// path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
|
|
754
|
-
// the whole host process (issue #54: every page refresh after the pooled
|
|
755
|
-
// connection dropped took the session, the browser and the remote orphan
|
|
756
|
-
// down with it). A dead tunnel is a UI state, not a reason to exit.
|
|
757
|
-
try {
|
|
758
|
-
this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
|
|
759
|
-
if (err) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
760
|
-
this.sockets.add(socket)
|
|
761
|
-
socket.pipe(channel)
|
|
762
|
-
channel.pipe(socket)
|
|
763
|
-
const kill = () => {
|
|
764
|
-
try { socket.destroy() } catch { /* already gone */ }
|
|
765
|
-
try { channel.close() } catch { /* already closed */ }
|
|
766
|
-
this.sockets.delete(socket)
|
|
767
|
-
}
|
|
768
|
-
socket.on('error', kill)
|
|
769
|
-
channel.on('error', kill)
|
|
770
|
-
socket.on('close', kill)
|
|
771
|
-
channel.on('close', kill)
|
|
772
|
-
})
|
|
773
|
-
} catch (err) {
|
|
774
|
-
this._clientLost(String((err && err.message) || err))
|
|
775
|
-
try { socket.destroy() } catch { /* already gone */ }
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* The URL to open, carrying the one-time launch token.
|
|
781
|
-
*
|
|
782
|
-
* The token is a credential, so it stays on loopback and is never sent
|
|
783
|
-
* anywhere; DSH exchanges it for a 30-day signed cookie on the first request
|
|
784
|
-
* and redirects to a clean `/`.
|
|
785
|
-
*/
|
|
786
|
-
get url() {
|
|
787
|
-
return `http://127.0.0.1:${this.localPort}/?token=${this.token}`
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/** The clean URL (no token) — what the address bar shows after the exchange. */
|
|
791
|
-
get cleanUrl() {
|
|
792
|
-
return `http://127.0.0.1:${this.localPort}/`
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/** Serializable status for the settings UI. */
|
|
796
|
-
describe() {
|
|
797
|
-
return {
|
|
798
|
-
machineId: this.machineId,
|
|
799
|
-
url: this.url,
|
|
800
|
-
cleanUrl: this.cleanUrl,
|
|
801
|
-
token: this.token,
|
|
802
|
-
localPort: this.localPort,
|
|
803
|
-
remotePort: this.remotePort,
|
|
804
|
-
active: this.active,
|
|
805
|
-
startedAt: this.startedAt,
|
|
806
|
-
// Only a process we launched can be stopped by us; an attach onto the
|
|
807
|
-
// user's own running instance is deliberately hands-off.
|
|
808
|
-
managed: this.remotePid > 0,
|
|
809
|
-
error: this.lastError || '',
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
/**
|
|
814
|
-
* Tear the tunnel down. The remote process is left running by default: the
|
|
815
|
-
* user may be using that DSH elsewhere, and silently killing a process we did
|
|
816
|
-
* not start is worse than a leftover one. `stopRemote` opts in.
|
|
817
|
-
*
|
|
818
|
-
* @param {object} [opts] - close options.
|
|
819
|
-
* @param {boolean} [opts.stopRemote] - also terminate the remote process.
|
|
820
|
-
* @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
|
|
821
|
-
*/
|
|
822
|
-
async close({ stopRemote = false, exec } = {}) {
|
|
823
|
-
// Stop following the pool BEFORE any await: a reconnect landing mid-teardown
|
|
824
|
-
// must not re-arm the tunnel this call is in the middle of closing.
|
|
825
|
-
this._detachPool()
|
|
826
|
-
if (this.attachedClient && this.onClientClose) {
|
|
827
|
-
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
828
|
-
}
|
|
829
|
-
this.attachedClient = null
|
|
830
|
-
this.onClientClose = null
|
|
831
|
-
this.pool = null
|
|
832
|
-
for (const socket of [...this.sockets]) {
|
|
833
|
-
try { socket.destroy() } catch { /* already gone */ }
|
|
834
|
-
}
|
|
835
|
-
this.sockets.clear()
|
|
836
|
-
const server = this.server
|
|
837
|
-
this.server = null
|
|
838
|
-
if (server) await new Promise((resolve) => server.close(() => resolve()))
|
|
839
|
-
// Only a process we started has a recorded PID; an attach to the user's own
|
|
840
|
-
// running instance must never signal anything.
|
|
841
|
-
if (stopRemote && exec && this.remotePid) {
|
|
842
|
-
await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
|
|
843
|
-
}
|
|
844
|
-
this.client = null
|
|
845
|
-
// The recorded PID and log path are FACTS about this session (the UI shows
|
|
846
|
-
// them, and `stopRemote` addresses them), so they survive; `closed` is what
|
|
847
|
-
// stops recovery work from being re-armed after teardown.
|
|
848
|
-
this.closed = true
|
|
849
|
-
}
|
|
850
|
-
}
|
|
1
|
+
// dsh-remote — "remote DSH web UI" attach sessions (issue #46).
|
|
2
|
+
//
|
|
3
|
+
// The user's intent behind that issue is "use the local DSH as a CLIENT for a
|
|
4
|
+
// DSH running on another machine": they do NOT want the remote to expose an
|
|
5
|
+
// inbound listener. DSH makes that impossible anyway — `dsh-web-app` hard-
|
|
6
|
+
// rejects `--host 0.0.0.0` (startup.js) because it would put remote code
|
|
7
|
+
// execution on the network, and `dsh-host-webserver` only accepts the
|
|
8
|
+
// 127.0.0.1/0.0.0.0 literals.
|
|
9
|
+
//
|
|
10
|
+
// So this module inverts the direction, exactly like the rest of the plugin:
|
|
11
|
+
// we SSH OUT, start (or reuse) a loopback-bound `dsh web` on the remote, and
|
|
12
|
+
// carry its socket back over our own SSH connection with a local TCP forward.
|
|
13
|
+
// The browser then talks to 127.0.0.1:<localPort> on this machine.
|
|
14
|
+
//
|
|
15
|
+
// Verified end-to-end on a real Linux host (dsh 0.1.5-rc.2): a bare TCP forward
|
|
16
|
+
// is sufficient — the SPA, its assets, AND the `/api/remote.mux` WebSocket
|
|
17
|
+
// upgrade all transit it (HTTP/1.1 101), with no reverse proxy and no host
|
|
18
|
+
// rewriting. That works because DSH binds its auth cookie to the request
|
|
19
|
+
// authority, so a cookie minted against `127.0.0.1:<localPort>` is exactly what
|
|
20
|
+
// the browser then sends back to that same authority.
|
|
21
|
+
//
|
|
22
|
+
// Three details drive the design and are worth stating once:
|
|
23
|
+
//
|
|
24
|
+
// 1. The launch token CANNOT be read from anywhere but stdout. It is a
|
|
25
|
+
// `randomBytes` value held in a process-local WeakMap and printed on one
|
|
26
|
+
// line (`dsh web: http://127.0.0.1:<port>/?token=…`). No file, no env var.
|
|
27
|
+
// Therefore the token is captured by redirecting the remote process's
|
|
28
|
+
// output to a log file and parsing that line out of it.
|
|
29
|
+
// 2. `--port 0` lets the remote OS pick a free port, and the same printed line
|
|
30
|
+
// reports which port it chose — so we never have to guess or probe for a
|
|
31
|
+
// free port on the remote.
|
|
32
|
+
// 3. The pairing that must survive is (token, port) from ONE launch. Reading
|
|
33
|
+
// them from one line is what keeps them consistent.
|
|
34
|
+
//
|
|
35
|
+
// Everything here is read-only with respect to the remote filesystem except
|
|
36
|
+
// the log file it creates under the remote `$HOME`.
|
|
37
|
+
import net from 'node:net'
|
|
38
|
+
|
|
39
|
+
/** Remote log file per attach session; `$HOME` is expanded by the remote shell. */
|
|
40
|
+
const LOG_NAME = '.dsh-remote-web'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse the `dsh web:` startup line out of a remote log.
|
|
44
|
+
*
|
|
45
|
+
* The line looks like:
|
|
46
|
+
* dsh web: http://127.0.0.1:43129/?token=Iy7FKuPSI5qJKJUFDyah4B6i8Ul6_hvspiJhiWufYt8
|
|
47
|
+
* and may be followed by a LAN suffix when the remote bound 0.0.0.0 (it never
|
|
48
|
+
* will here — we never pass `--host` — but tolerate it rather than break).
|
|
49
|
+
*
|
|
50
|
+
* @param {string} text - the remote log contents.
|
|
51
|
+
* @returns {{url: string, port: number, token: string}|null} parsed facts, or
|
|
52
|
+
* null when the line has not appeared yet (still booting) or is malformed.
|
|
53
|
+
*/
|
|
54
|
+
export function parseWebStart(text) {
|
|
55
|
+
if (typeof text !== 'string' || text === '') return null
|
|
56
|
+
// Take the LAST match: a reused log file may hold an earlier session's line,
|
|
57
|
+
// and the newest one is the one this launch produced.
|
|
58
|
+
const matches = [...text.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
59
|
+
if (matches.length === 0) return null
|
|
60
|
+
const url = matches[matches.length - 1][1]
|
|
61
|
+
let parsed
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url)
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const port = Number(parsed.port)
|
|
68
|
+
const token = parsed.searchParams.get('token') || ''
|
|
69
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
70
|
+
if (!token) return null
|
|
71
|
+
return { url, port, token }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when the log proves the remote process died instead of booting. */
|
|
75
|
+
export function launchFailed(text) {
|
|
76
|
+
return typeof text === 'string' && text.includes(DEAD_MARKER)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Marker the remote launcher echoes when the process exits before printing. */
|
|
80
|
+
const DEAD_MARKER = '__DSH_REMOTE_DIED__'
|
|
81
|
+
/** Marker separating the parsed line from the raw log tail for diagnostics. */
|
|
82
|
+
export const TAIL_MARKER = '__DSH_TAIL__'
|
|
83
|
+
/** Marker carrying the remote PID so the session can stop its own process. */
|
|
84
|
+
export const PID_MARKER = '__DSH_PID__='
|
|
85
|
+
/** Random ASCII tag so two attach sessions never share a remote log file. */
|
|
86
|
+
function randomTag() {
|
|
87
|
+
return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the one-shot remote command that launches `dsh web` detached and waits
|
|
92
|
+
* for its startup line.
|
|
93
|
+
*
|
|
94
|
+
* `setsid` + `nohup` + redirected stdio are all required: the SSH channel closes
|
|
95
|
+
* as soon as this shell exits, and without detaching the SIGHUP would kill the
|
|
96
|
+
* server we just started. The loop is bounded so a wedged remote cannot hang the
|
|
97
|
+
* call past the caller's own timeout, and it reports either the parsed line or
|
|
98
|
+
* the log tail — never silence.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} spec - launch parameters.
|
|
101
|
+
* @param {boolean} [spec.omitNoOpen] - drop `--no-open`. Older DSH (measured on
|
|
102
|
+
* 0.1.0-rc.6) has no such flag and exits with "unknown option '--no-open'";
|
|
103
|
+
* the caller retries with this set rather than assuming a flag exists.
|
|
104
|
+
* @returns {string} a POSIX shell command line (no newlines, ASCII only).
|
|
105
|
+
*/
|
|
106
|
+
export function buildLaunchCommand({ command, profile, logPath, remotePort = 0, dshHome = '', waitSeconds = 45, omitNoOpen = false }) {
|
|
107
|
+
const env = dshHome ? `DSH_HOME="${dshHome}" ` : ''
|
|
108
|
+
const noOpen = omitNoOpen ? '' : ' --no-open'
|
|
109
|
+
const args = `--profile ${profile} --port ${Number(remotePort) || 0}${noOpen}`
|
|
110
|
+
const limit = Number(waitSeconds) || 45
|
|
111
|
+
// The loop is ONE shell element: `do` is a keyword and `do;` is a syntax error
|
|
112
|
+
// in POSIX sh, so it must be glued to the first command of the body rather
|
|
113
|
+
// than separated like the other statements.
|
|
114
|
+
const loop = `while [ $i -lt ${limit} ]; do grep -q 'token=' "$LOG" 2>/dev/null && break`
|
|
115
|
+
+ `; kill -0 $P 2>/dev/null || { echo ${DEAD_MARKER}; break; }`
|
|
116
|
+
+ `; sleep 1; i=$((i+1)); done`
|
|
117
|
+
return [
|
|
118
|
+
`LOG="$HOME/${logPath}"`,
|
|
119
|
+
`: > "$LOG"`,
|
|
120
|
+
// `command -v setsid` guards hosts without util-linux (macOS); plain nohup
|
|
121
|
+
// still detaches from the channel's HUP there.
|
|
122
|
+
`command -v setsid >/dev/null 2>&1 && SET=setsid || SET=`,
|
|
123
|
+
`${env}$SET nohup ${command} ${args} >"$LOG" 2>&1 </dev/null & P=$!`,
|
|
124
|
+
`i=0`,
|
|
125
|
+
loop,
|
|
126
|
+
`echo "${PID_MARKER}$P"`,
|
|
127
|
+
`grep -m1 'dsh web:' "$LOG" 2>/dev/null`,
|
|
128
|
+
`echo ${TAIL_MARKER}`,
|
|
129
|
+
`tail -20 "$LOG" 2>/dev/null`,
|
|
130
|
+
].join('; ')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Extract the remote PID from launcher output (0 when absent). */
|
|
134
|
+
export function parseRemotePid(output) {
|
|
135
|
+
const m = /__DSH_PID__=(\d+)/.exec(String(output || ''))
|
|
136
|
+
return m ? Number(m[1]) : 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Recognize a known remote-side reason the web surface could not start, and
|
|
141
|
+
* return an actionable sentence for it.
|
|
142
|
+
*
|
|
143
|
+
* The point is to keep an operator from reading "the remote DSH did not report a
|
|
144
|
+
* startup token" and concluding the *plugin* is broken. Measured cause: dsh
|
|
145
|
+
* ≤ 0.1.0-rc.6 depends on `node-pty@1.1.0`, whose published tarball ships
|
|
146
|
+
* darwin/win32 prebuilds but **no `linux-x64`**, so `dsh web` cannot boot on
|
|
147
|
+
* Linux at all — it dies loading the native module. 0.1.5-rc.2 moved to
|
|
148
|
+
* `node-pty@1.2.0-beta.15`, which does ship Linux builds.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} output - the remote log tail.
|
|
151
|
+
* @returns {string} a hint, or '' when nothing is recognized.
|
|
152
|
+
*/
|
|
153
|
+
export function explainLaunchFailure(output) {
|
|
154
|
+
const text = String(output || '')
|
|
155
|
+
if (/pty\.node|Failed to load native module/i.test(text)) {
|
|
156
|
+
return ' The remote could not load node-pty\'s native module. Known cause: dsh <= 0.1.0-rc.6'
|
|
157
|
+
+ ' depends on node-pty@1.1.0, which publishes no linux-x64 prebuild, so `dsh web` cannot start'
|
|
158
|
+
+ ' on Linux at all. Upgrade the remote dsh to 0.1.5-rc.2 or newer (node-pty@1.2.0-beta.15 ships'
|
|
159
|
+
+ ' the Linux build), or point `webAttachCommand` at a newer dsh installed elsewhere.'
|
|
160
|
+
}
|
|
161
|
+
if (/is intentionally not supported|--host 0\.0\.0\.0/i.test(text)) {
|
|
162
|
+
return ' The remote dsh refused an all-interfaces bind. Attach deliberately never passes `--host`;'
|
|
163
|
+
+ ' check `webAttachCommand` does not add one.'
|
|
164
|
+
}
|
|
165
|
+
if (/command not found|No such file or directory|not found/i.test(text)) {
|
|
166
|
+
return ' The remote shell could not run the dsh command. If the remote dsh is not on the SSH login'
|
|
167
|
+
+ ' PATH (a version manager or private prefix), set `webAttachCommand` to its absolute path.'
|
|
168
|
+
}
|
|
169
|
+
return ''
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the command that stops the remote process THIS session started.
|
|
174
|
+
*
|
|
175
|
+
* Two things make this less obvious than it looks, both found by running it
|
|
176
|
+
* against a real host:
|
|
177
|
+
* 1. The log path cannot identify the process — it appears only as a shell
|
|
178
|
+
* redirect, so it is absent from argv and `pkill -f <logfile>` matches
|
|
179
|
+
* nothing (the process survived, verified live).
|
|
180
|
+
* 2. The PORT cannot identify it either, because we deliberately launch with
|
|
181
|
+
* `--port 0` (the remote picks a free port, and the chosen number is only
|
|
182
|
+
* known afterwards). A `--port 0` substring match therefore never fires.
|
|
183
|
+
*
|
|
184
|
+
* So the recorded PID is the handle, and the kill targets its process GROUP
|
|
185
|
+
* (`kill -- -PID`): the launch goes through `setsid`, making the child a
|
|
186
|
+
* session/group leader whose PGID equals its PID, so the group contains exactly
|
|
187
|
+
* the process tree we started — never a sibling attach session and never the
|
|
188
|
+
* user's own DSH. Before signalling, the command re-verifies that the PID still
|
|
189
|
+
* exists and still looks like a `dsh` process, so a recycled PID belonging to an
|
|
190
|
+
* unrelated program is not killed.
|
|
191
|
+
*
|
|
192
|
+
* @param {number} pid - the PID reported by the launch.
|
|
193
|
+
* @param {string} [logPath] - this session's log file, removed after the kill so
|
|
194
|
+
* repeated attaches do not accumulate files in the remote home.
|
|
195
|
+
* @returns {string} a POSIX shell command (always exits 0, never throws).
|
|
196
|
+
*/
|
|
197
|
+
export function buildStopCommand(pid, logPath = '') {
|
|
198
|
+
const p = Number(pid) || 0
|
|
199
|
+
const cleanup = logPath ? `; rm -f "$HOME/${logPath}" 2>/dev/null` : ''
|
|
200
|
+
if (p < 1) return `true${cleanup}`
|
|
201
|
+
return `P=${p}`
|
|
202
|
+
+ `; if [ -d "/proc/$P" ]; then`
|
|
203
|
+
+ ` C=$(tr '\\0' ' ' < "/proc/$P/cmdline" 2>/dev/null);`
|
|
204
|
+
+ ` case "$C" in *dsh*) kill -- -"$P" 2>/dev/null || kill "$P" 2>/dev/null;; esac;`
|
|
205
|
+
+ ` fi${cleanup}; true`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One attached remote DSH Web UI: a local listener whose sockets are carried
|
|
210
|
+
* over SSH to the remote's loopback web port.
|
|
211
|
+
*
|
|
212
|
+
* The listener is a plain `net.Server` + `client.forwardOut` pair — the same
|
|
213
|
+
* mechanism `ForwardManager` uses for user-defined forwards. It is implemented
|
|
214
|
+
* here rather than through that manager because this tunnel is bound to a
|
|
215
|
+
* CHOSEN pool (the machine the user picked), whereas `ForwardManager` is wired
|
|
216
|
+
* to the single "active machine" pool; routing an attach through it would send
|
|
217
|
+
* the tunnel to whichever machine happened to be active.
|
|
218
|
+
*/
|
|
219
|
+
export class WebAttach {
|
|
220
|
+
/**
|
|
221
|
+
* @param {object} opts - injected collaborators (all optional in production).
|
|
222
|
+
* @param {object} [opts.net] - a `node:net`-compatible module (tests).
|
|
223
|
+
* @param {number} [opts.localPortStart] - first local port to try.
|
|
224
|
+
* @param {number} [opts.localPortMax] - how many ports to try before failing.
|
|
225
|
+
* @param {boolean} [opts.trackPool] - follow the pool across reconnects so a
|
|
226
|
+
* dropped SSH connection degrades to "disconnected" and then SELF-HEALS
|
|
227
|
+
* instead of being a permanent dead end (issue #54). `true` in production.
|
|
228
|
+
*/
|
|
229
|
+
constructor(opts = {}) {
|
|
230
|
+
this.net = opts.net || net
|
|
231
|
+
// `??`, not `||`: port 0 is a MEANINGFUL value here (let the OS pick), and
|
|
232
|
+
// `0 || 3088` silently turned "let the OS choose" into a bind on a fixed
|
|
233
|
+
// port. An explicit 0 now survives, which is also what lets a test ask for a
|
|
234
|
+
// port without risking a collision with something really listening.
|
|
235
|
+
this.localPortStart = opts.localPortStart ?? 3088
|
|
236
|
+
this.localPortMax = opts.localPortMax ?? 50
|
|
237
|
+
/** Whether the pool's lifecycle hooks are ours to install. */
|
|
238
|
+
this.trackPool = opts.trackPool !== false
|
|
239
|
+
/** @type {import('node:net').Server|null} */ this.server = null
|
|
240
|
+
/** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
|
|
241
|
+
/** @type {object|null} */ this.client = null
|
|
242
|
+
/** The pool this session borrowed its connection from (may be null). */
|
|
243
|
+
this.pool = null
|
|
244
|
+
/** Pool hooks we installed, so close() can hand them back untouched. */
|
|
245
|
+
this.prevOnReady = null
|
|
246
|
+
this.prevOnCloseHook = null
|
|
247
|
+
this.hookedPool = null
|
|
248
|
+
this._installedOnReady = null
|
|
249
|
+
this._installedOnClose = null
|
|
250
|
+
this.attachedClient = null
|
|
251
|
+
/** Bound listener, kept so it can be removed from the client. */
|
|
252
|
+
this.onClientClose = null
|
|
253
|
+
this.localPort = 0
|
|
254
|
+
this.remotePort = 0
|
|
255
|
+
this.token = ''
|
|
256
|
+
this.tag = ''
|
|
257
|
+
this.logPath = ''
|
|
258
|
+
this.remotePid = 0
|
|
259
|
+
this.machineId = ''
|
|
260
|
+
this.command = ''
|
|
261
|
+
this.startedAt = 0
|
|
262
|
+
/** Launch knobs remembered so a restart after a drop uses the same ones. */
|
|
263
|
+
this.waitSeconds = 0
|
|
264
|
+
this.omittedNoOpen = false
|
|
265
|
+
/** True while a reconnect-triggered re-resolve is in flight. */
|
|
266
|
+
this._resolving = false
|
|
267
|
+
/** True once the borrowed connection has been lost (drives recovery). */
|
|
268
|
+
this.connectionLost = false
|
|
269
|
+
/** undefined = "not asked yet"; false = the remote process is provably gone. */
|
|
270
|
+
this._remoteAlive = undefined
|
|
271
|
+
/** Set by close(): nothing may re-arm the tunnel afterwards. */
|
|
272
|
+
this.closed = false
|
|
273
|
+
/** Set once the borrowed connection has gone away (see _clientLost). */
|
|
274
|
+
this.lastError = ''
|
|
275
|
+
/**
|
|
276
|
+
* A listener that throws EARNS an 'error' event on the emitting object, and
|
|
277
|
+
* an 'error' with no listener is itself fatal — the very crash this module
|
|
278
|
+
* exists to prevent. Nothing here is expected to throw, but a lifecycle
|
|
279
|
+
* callback running on someone else's event loop must never be the reason
|
|
280
|
+
* the host process dies, so both hooks are wrapped.
|
|
281
|
+
*/
|
|
282
|
+
this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
|
|
283
|
+
this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Whether the tunnel is currently listening. */
|
|
287
|
+
get active() {
|
|
288
|
+
return this.server !== null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Attach to a remote DSH Web UI.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} spec - the attach request.
|
|
295
|
+
* @param {object} spec.pool - the SshPool for the chosen machine.
|
|
296
|
+
* @param {string} spec.machineId - machine id, for bookkeeping.
|
|
297
|
+
* @param {string} [spec.command] - remote dsh executable (default `dsh`).
|
|
298
|
+
* @param {string} [spec.profile] - DSH profile to boot (default `web`).
|
|
299
|
+
* @param {string} [spec.existingUrl] - attach to an ALREADY running remote
|
|
300
|
+
* web UI instead of starting one: its `http://127.0.0.1:<port>/?token=…`.
|
|
301
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string, code: number}>} [spec.exec]
|
|
302
|
+
* - remote exec, defaulting to `pool.exec`.
|
|
303
|
+
* @returns {Promise<{url: string, localPort: number, remotePort: number}>}
|
|
304
|
+
*/
|
|
305
|
+
async open(spec) {
|
|
306
|
+
const {
|
|
307
|
+
pool, machineId = '', command = 'dsh', profile = 'web',
|
|
308
|
+
existingUrl = '', dshHome = '', waitSeconds = 45,
|
|
309
|
+
} = spec || {}
|
|
310
|
+
if (!pool) throw new Error('web-attach: a machine pool is required')
|
|
311
|
+
this.machineId = String(machineId || '')
|
|
312
|
+
this.command = command
|
|
313
|
+
this.waitSeconds = Number(waitSeconds) || 45
|
|
314
|
+
|
|
315
|
+
const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
|
|
316
|
+
// Connecting first also surfaces credential/host-key problems BEFORE we
|
|
317
|
+
// start a remote process we would then have to clean up. `_attach` (rather
|
|
318
|
+
// than a bare assignment) is what subscribes to the connection's 'close',
|
|
319
|
+
// so the loss is noticed the moment it happens instead of on the next
|
|
320
|
+
// request — which is when the old code let ssh2 throw 'Not connected' from
|
|
321
|
+
// inside a socket callback and take the host process with it (issue #54).
|
|
322
|
+
this._attach(await pool.connect())
|
|
323
|
+
this._followPool(pool)
|
|
324
|
+
|
|
325
|
+
if (existingUrl) {
|
|
326
|
+
const parsed = parseWebStart(`dsh web: ${existingUrl}`)
|
|
327
|
+
if (!parsed) {
|
|
328
|
+
throw new Error('web-attach: existing URL must look like http://127.0.0.1:<port>/?token=<token>')
|
|
329
|
+
}
|
|
330
|
+
this.remotePort = parsed.port
|
|
331
|
+
this.token = parsed.token
|
|
332
|
+
} else {
|
|
333
|
+
this.tag = randomTag()
|
|
334
|
+
const logPath = `${LOG_NAME}-${this.tag}.log`
|
|
335
|
+
this.logPath = logPath
|
|
336
|
+
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
337
|
+
let out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, false))
|
|
338
|
+
// Older DSH (0.1.0-rc.6) has no `--no-open` and exits on the unknown flag
|
|
339
|
+
// before doing anything. Retry once without it: `--no-open` is a nicety
|
|
340
|
+
// (it stops the REMOTE from opening its own browser), not a requirement,
|
|
341
|
+
// and a bounded single retry is safe because the first attempt provably
|
|
342
|
+
// never started a server.
|
|
343
|
+
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
344
|
+
out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, true))
|
|
345
|
+
this.omittedNoOpen = true
|
|
346
|
+
}
|
|
347
|
+
this.remotePid = parseRemotePid(out)
|
|
348
|
+
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
349
|
+
// Both failure shapes carry the same diagnostics: the process either died
|
|
350
|
+
// (its output is the whole story) or is still booting after the deadline.
|
|
351
|
+
if (launchFailed(out)) {
|
|
352
|
+
// The remote process is already gone — nothing to clean up.
|
|
353
|
+
throw new Error('web-attach: the remote DSH exited during startup.'
|
|
354
|
+
+ explainLaunchFailure(tail || out)
|
|
355
|
+
+ ` ${tail || 'no output'}`)
|
|
356
|
+
}
|
|
357
|
+
const parsed = parseWebStart(out)
|
|
358
|
+
if (!parsed) {
|
|
359
|
+
// A timed-out launch may STILL be booting: the deadline expiring is not
|
|
360
|
+
// evidence the process died. Kill it before throwing, or every retry
|
|
361
|
+
// leaks another remote server (measured: three leftover `dsh --port 0`
|
|
362
|
+
// processes after three failed attaches). A PID of 0 means the launcher
|
|
363
|
+
// could not report one — nothing to target.
|
|
364
|
+
if (this.remotePid > 0) {
|
|
365
|
+
try {
|
|
366
|
+
await exec(buildStopCommand(this.remotePid, logPath), { timeoutMs: 20000 })
|
|
367
|
+
this.remotePid = 0
|
|
368
|
+
} catch { /* the process may already be gone; a leftover is worse than a failed kill attempt only if it repeats, and the next attach replaces this session */ }
|
|
369
|
+
}
|
|
370
|
+
throw new Error(
|
|
371
|
+
`web-attach: the remote DSH did not report a startup token within ${waitSeconds}s.`
|
|
372
|
+
+ ` Check that \`${command}\` exists on the remote and can boot a web profile.`
|
|
373
|
+
+ explainLaunchFailure(tail || out)
|
|
374
|
+
+ (tail ? ` Remote output: ${tail}` : ''),
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
this.remotePort = parsed.port
|
|
378
|
+
this.token = parsed.token
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await this._listen()
|
|
382
|
+
this.startedAt = Date.now()
|
|
383
|
+
return this.describe()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Run one launch attempt and return its raw output.
|
|
388
|
+
*
|
|
389
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
390
|
+
* @param {object} d - launch parameters plus the exec timeout.
|
|
391
|
+
* @param {boolean} omitNoOpen - drop `--no-open` for old DSH.
|
|
392
|
+
* @returns {Promise<string>} the launcher's stdout.
|
|
393
|
+
*/
|
|
394
|
+
async _launch(exec, d, omitNoOpen) {
|
|
395
|
+
const cmd = buildLaunchCommand({ ...d, omitNoOpen })
|
|
396
|
+
const res = await exec(cmd, { timeoutMs: d.timeoutMs })
|
|
397
|
+
return String(res && res.stdout ? res.stdout : '')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Bind the local listener, advancing past ports already in use. */
|
|
401
|
+
async _listen() {
|
|
402
|
+
let lastError = null
|
|
403
|
+
for (let i = 0; i < this.localPortMax; i++) {
|
|
404
|
+
const port = this.localPortStart + i
|
|
405
|
+
try {
|
|
406
|
+
await this._listenOn(port)
|
|
407
|
+
this.localPort = port
|
|
408
|
+
return
|
|
409
|
+
} catch (err) {
|
|
410
|
+
lastError = err
|
|
411
|
+
// Only a taken port is worth retrying; anything else (permissions,
|
|
412
|
+
// an unusable interface) will not be fixed by the next number.
|
|
413
|
+
if (err && err.code !== 'EADDRINUSE') break
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
throw new Error(
|
|
417
|
+
`web-attach: no free local port in ${this.localPortStart}..${this.localPortStart + this.localPortMax - 1}`
|
|
418
|
+
+ (lastError ? ` (${lastError.code || lastError.message})` : ''),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
_listenOn(port) {
|
|
423
|
+
return new Promise((resolve, reject) => {
|
|
424
|
+
const server = this.net.createServer((socket) => this._pipe(socket))
|
|
425
|
+
const onError = (err) => {
|
|
426
|
+
server.removeListener('listening', onListening)
|
|
427
|
+
try { server.close() } catch { /* never listened */ }
|
|
428
|
+
reject(err)
|
|
429
|
+
}
|
|
430
|
+
const onListening = () => {
|
|
431
|
+
server.removeListener('error', onError)
|
|
432
|
+
// A post-bind error must not crash the host process: report and drop
|
|
433
|
+
// the tunnel so the UI can show "disconnected" instead of dying.
|
|
434
|
+
server.on('error', (err) => {
|
|
435
|
+
this.server = null
|
|
436
|
+
this.lastError = String((err && err.message) || err)
|
|
437
|
+
})
|
|
438
|
+
this.server = server
|
|
439
|
+
resolve()
|
|
440
|
+
}
|
|
441
|
+
server.once('error', onError)
|
|
442
|
+
server.once('listening', onListening)
|
|
443
|
+
// Loopback only: this endpoint reaches a full remote harness, so it must
|
|
444
|
+
// never be reachable from the network even on a LAN-facing machine.
|
|
445
|
+
server.listen(port, '127.0.0.1')
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Borrow the given connection AND subscribe to its lifecycle.
|
|
451
|
+
*
|
|
452
|
+
* Idempotent for the same client (open() calls it once, the pool's `onReady`
|
|
453
|
+
* calls it again on every reconnect), and it always drops the previous
|
|
454
|
+
* client's listener first, so one session never accumulates listeners.
|
|
455
|
+
*
|
|
456
|
+
* @param {object|null} client - the live ssh2 client / OpenSshConnection.
|
|
457
|
+
*/
|
|
458
|
+
_attach(client) {
|
|
459
|
+
if (!client) return
|
|
460
|
+
if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
|
|
461
|
+
// A different client replaced the one we borrowed (pool reconnect). Stop
|
|
462
|
+
// listening to the old one; its own 'close' has either fired already or
|
|
463
|
+
// belongs to a connection nobody uses any more.
|
|
464
|
+
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
465
|
+
}
|
|
466
|
+
this.client = client
|
|
467
|
+
if (this.attachedClient === client) return
|
|
468
|
+
this.attachedClient = client
|
|
469
|
+
this.onClientClose = () => { this._clientLost('ssh connection closed') }
|
|
470
|
+
// Without this, the object keeps handing out a connection that is already
|
|
471
|
+
// closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
|
|
472
|
+
try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The borrowed connection is gone: keep the session, drop the dead handle. */
|
|
476
|
+
_clientLost(reason) {
|
|
477
|
+
this.client = null
|
|
478
|
+
this.connectionLost = true
|
|
479
|
+
this.lastError = String(reason || 'ssh connection closed')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Register lifecycle hooks on the chosen pool so this tunnel follows it.
|
|
484
|
+
*
|
|
485
|
+
* The pool is the one place that both NOTICES a dead connection and later
|
|
486
|
+
* obtains a fresh one, and it exposes exactly two hooks for that:
|
|
487
|
+
* • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
|
|
488
|
+
* the "channel open failed, the pooled connection died server-side"
|
|
489
|
+
* self-heal path, which is the likeliest trigger);
|
|
490
|
+
* • `onReady` — fires after a successful (re)connect.
|
|
491
|
+
*
|
|
492
|
+
* Both are SINGLE-SLOT properties that the plugin already uses for
|
|
493
|
+
* `ForwardManager` (index.js), so this session CHAINS them rather than
|
|
494
|
+
* clobbering the forwards manager, and `_detachPool()` restores exactly what
|
|
495
|
+
* was there — the plugin may legitimately have none, one, or both.
|
|
496
|
+
*
|
|
497
|
+
* A real reconnect is the only way the remote port can move, so once the pool
|
|
498
|
+
* reports a new connection we must re-resolve (token, port) from the remote
|
|
499
|
+
* log. `onReady` itself cannot await: the pool fires it with the client in
|
|
500
|
+
* hand, so the re-resolve is kicked off and its failure is recorded rather
|
|
501
|
+
* than thrown into the pool's callback.
|
|
502
|
+
*
|
|
503
|
+
* @param {object} pool - the pool this session attached through.
|
|
504
|
+
*/
|
|
505
|
+
_followPool(pool) {
|
|
506
|
+
if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
|
|
507
|
+
this._detachPool()
|
|
508
|
+
this.pool = pool
|
|
509
|
+
this.hookedPool = pool
|
|
510
|
+
const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
|
|
511
|
+
const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
|
|
512
|
+
this.prevOnReady = prevReady
|
|
513
|
+
this.prevOnCloseHook = prevClose
|
|
514
|
+
const installedReady = (client) => {
|
|
515
|
+
this._attach(client)
|
|
516
|
+
if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
|
|
517
|
+
// A reconnect lands on a NEW remote process only if the old one died with
|
|
518
|
+
// the machine; re-read the log to find out which. The re-resolve runs off
|
|
519
|
+
// the event loop because the pool cannot await it — a hook that throws
|
|
520
|
+
// would earn an 'error' event on the pool, and an unhandled rejection
|
|
521
|
+
// would be fatal.
|
|
522
|
+
if (!this.closed && this.remotePort && !this._resolving) {
|
|
523
|
+
this._resolving = true
|
|
524
|
+
Promise.resolve()
|
|
525
|
+
.then(() => this._refreshRemotePort())
|
|
526
|
+
.catch((err) => { this.lastError = String((err && err.message) || err) })
|
|
527
|
+
.then(() => { this._resolving = false })
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const installedClose = () => {
|
|
531
|
+
this._clientLost('ssh connection closed')
|
|
532
|
+
if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
|
|
533
|
+
}
|
|
534
|
+
this._installedOnReady = installedReady
|
|
535
|
+
this._installedOnClose = installedClose
|
|
536
|
+
pool.onReady = installedReady
|
|
537
|
+
pool.onCloseHook = installedClose
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Hand the pool's hooks back.
|
|
542
|
+
*
|
|
543
|
+
* Each slot is restored ONLY when it still holds the wrapper this session
|
|
544
|
+
* installed. The pool's hooks are single-slot, so if something else has taken
|
|
545
|
+
* the slot over in the meantime, putting a stale callback back would be worse
|
|
546
|
+
* than leaving it alone — it would silently disconnect a newer owner.
|
|
547
|
+
*/
|
|
548
|
+
_detachPool() {
|
|
549
|
+
const pool = this.hookedPool
|
|
550
|
+
const mineReady = this._installedOnReady
|
|
551
|
+
const mineClose = this._installedOnClose
|
|
552
|
+
this.hookedPool = null
|
|
553
|
+
this._installedOnReady = null
|
|
554
|
+
this._installedOnClose = null
|
|
555
|
+
if (!pool) return
|
|
556
|
+
if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
|
|
557
|
+
if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
|
|
558
|
+
this.prevOnReady = null
|
|
559
|
+
this.prevOnCloseHook = null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* True when the borrowed connection was LOST and this is a session we can
|
|
564
|
+
* recover: we launched the remote process ourselves (so we hold its PID and
|
|
565
|
+
* log) and the tunnel is listening (so clients can actually be served).
|
|
566
|
+
*
|
|
567
|
+
* The `connectionLost` flag is deliberately separate from `this.client`: the
|
|
568
|
+
* pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
|
|
569
|
+
* check on `!this.client` would be false by then and recovery would never
|
|
570
|
+
* happen.
|
|
571
|
+
*/
|
|
572
|
+
get _needsReResolve() {
|
|
573
|
+
return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
|
|
578
|
+
* when it did not survive.
|
|
579
|
+
*
|
|
580
|
+
* Reaching a remote process needs a live SSH connection, so this reconnects
|
|
581
|
+
* through the pool first — the pool reports `onReady` for that same client in
|
|
582
|
+
* production, and in a test double the callback simply never fires.
|
|
583
|
+
*/
|
|
584
|
+
async _refreshRemotePort() {
|
|
585
|
+
if (!this._needsReResolve) return
|
|
586
|
+
const pool = this.pool
|
|
587
|
+
if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
|
|
588
|
+
const exec = (cmd, opts) => (typeof pool.exec === 'function'
|
|
589
|
+
? pool.exec(cmd, opts)
|
|
590
|
+
: Promise.reject(new Error('web-attach: the pool cannot run commands')))
|
|
591
|
+
if (!this.client) await pool.connect()
|
|
592
|
+
const match = await this._resolveRemotePort(exec)
|
|
593
|
+
this.remotePort = match.port
|
|
594
|
+
if (match.token) this.token = match.token
|
|
595
|
+
this.connectionLost = false
|
|
596
|
+
this.lastError = ''
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Read this session's remote log and decide which port is live.
|
|
601
|
+
*
|
|
602
|
+
* The startup line is the ONLY source of the port (the launch deliberately
|
|
603
|
+
* uses `--port 0`), so after the remote process died and was restarted the log
|
|
604
|
+
* holds BOTH lines — the dead one and the new one — and neither position
|
|
605
|
+
* identifies the live one. Evidence decides:
|
|
606
|
+
* • a process that is still alive on the remote is probed first, and when
|
|
607
|
+
* the port it is LISTENING on is one of the logged ports, that is the
|
|
608
|
+
* answer (the `dsh web: …` line names its own port, so this is a
|
|
609
|
+
* confirmation rather than a second guess);
|
|
610
|
+
* • otherwise the newest logged port wins, which is right in every
|
|
611
|
+
* single-process case and is the best available answer while the remote is
|
|
612
|
+
* still booting (the next reconnect retries).
|
|
613
|
+
*
|
|
614
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
615
|
+
* @returns {Promise<{port: number, token: string, restarted: boolean}>}
|
|
616
|
+
*/
|
|
617
|
+
async _resolveRemotePort(exec) {
|
|
618
|
+
this._remoteAlive = await this._remoteProcessAlive(exec)
|
|
619
|
+
if (!this._remoteAlive) return this._restartRemote(exec)
|
|
620
|
+
|
|
621
|
+
let out = ''
|
|
622
|
+
try {
|
|
623
|
+
const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
|
|
624
|
+
out = String((res && res.stdout) || '')
|
|
625
|
+
} catch { /* an unreadable log: the listening probe below is the fallback */ }
|
|
626
|
+
const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
627
|
+
.map((m) => parseWebStart(`dsh web: ${m[1]}`))
|
|
628
|
+
.filter(Boolean)
|
|
629
|
+
|
|
630
|
+
// What the recorded process actually listens on beats any guess from the
|
|
631
|
+
// log, because a killed-and-restarted remote leaves BOTH startup lines in
|
|
632
|
+
// the log and neither position says which one is live.
|
|
633
|
+
const listening = await this._listeningRemotePort(exec)
|
|
634
|
+
if (listening) {
|
|
635
|
+
const match = lines.find((l) => l.port === listening)
|
|
636
|
+
return { port: listening, token: (match && match.token) || this.token, restarted: false }
|
|
637
|
+
}
|
|
638
|
+
if (lines.length) {
|
|
639
|
+
const newest = lines[lines.length - 1]
|
|
640
|
+
return { port: newest.port, token: newest.token, restarted: false }
|
|
641
|
+
}
|
|
642
|
+
// Alive but silent and not listening yet: the remote is booting. Keep the
|
|
643
|
+
// last known port and let the next reconnect re-resolve.
|
|
644
|
+
return { port: this.remotePort, token: this.token, restarted: false }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Whether the remote process THIS session started still exists.
|
|
649
|
+
*
|
|
650
|
+
* Verified through `/proc`, which is Linux-only; a host that cannot answer (or
|
|
651
|
+
* a failed probe) is treated as ALIVE on purpose. Restarting a server that was
|
|
652
|
+
* never dead would spawn a second one and leak a port, whereas assuming life
|
|
653
|
+
* merely retries a port that turns out to be stale.
|
|
654
|
+
*
|
|
655
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
656
|
+
* @returns {Promise<boolean>}
|
|
657
|
+
*/
|
|
658
|
+
async _remoteProcessAlive(exec) {
|
|
659
|
+
const pid = Number(this.remotePid) || 0
|
|
660
|
+
if (pid < 1) return true
|
|
661
|
+
try {
|
|
662
|
+
const res = await exec(
|
|
663
|
+
`if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
|
|
664
|
+
{ timeoutMs: 15000 },
|
|
665
|
+
)
|
|
666
|
+
return !/GONE/.test(String((res && res.stdout) || ''))
|
|
667
|
+
} catch { return true }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Ask the remote which loopback port the recorded PID listens on (0 = none).
|
|
672
|
+
*
|
|
673
|
+
* Two `/proc` reads are needed because a TCP listener appears in a socket
|
|
674
|
+
* table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
|
|
675
|
+
* symlinks give the socket INODE, and the table maps that inode to a port.
|
|
676
|
+
* Matching on the inode rather than the port is what makes this an
|
|
677
|
+
* observation instead of a second guess.
|
|
678
|
+
*/
|
|
679
|
+
async _listeningRemotePort(exec) {
|
|
680
|
+
const pid = Number(this.remotePid) || 0
|
|
681
|
+
if (pid < 1) return 0
|
|
682
|
+
let inodes = ''
|
|
683
|
+
try {
|
|
684
|
+
// `ls -l` on a process's own fds needs no privileges for the same uid.
|
|
685
|
+
const res = await exec(
|
|
686
|
+
`ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
|
|
687
|
+
{ timeoutMs: 15000 },
|
|
688
|
+
)
|
|
689
|
+
inodes = String((res && res.stdout) || '').trim()
|
|
690
|
+
} catch { return 0 }
|
|
691
|
+
if (!inodes) return 0
|
|
692
|
+
const want = new Set(inodes.split(/\s+/).filter(Boolean))
|
|
693
|
+
// `/proc/net/tcp` addresses are little-endian hex, so loopback is
|
|
694
|
+
// `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
|
|
695
|
+
const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
|
|
696
|
+
for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
697
|
+
let text = ''
|
|
698
|
+
try {
|
|
699
|
+
const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
|
|
700
|
+
text = String((res && res.stdout) || '')
|
|
701
|
+
} catch { continue }
|
|
702
|
+
for (const line of text.split('\n').slice(1)) {
|
|
703
|
+
const cols = line.trim().split(/\s+/)
|
|
704
|
+
// sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
|
|
705
|
+
if (cols.length < 10) continue
|
|
706
|
+
if (cols[3] !== '0A') continue // TCP_LISTEN
|
|
707
|
+
if (!want.has(cols[9])) continue
|
|
708
|
+
const [addr, portHex] = String(cols[1]).split(':')
|
|
709
|
+
if (!LOOPBACK.has(String(addr).toUpperCase())) continue
|
|
710
|
+
const port = parseInt(portHex, 16)
|
|
711
|
+
if (Number.isInteger(port) && port > 0 && port < 65536) return port
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return 0
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Relaunch the remote `dsh web` because the previous process is gone.
|
|
719
|
+
*
|
|
720
|
+
* A restart CANNOT change the listening port locally, and a caller holding a
|
|
721
|
+
* stale port silently talks to the wrong place at worst and hangs at best, so
|
|
722
|
+
* the two facts the caller needs are checked: this is a session that launched
|
|
723
|
+
* its own process (an attach onto someone else's DSH stays hands-off and can
|
|
724
|
+
* never restart it), and recovery is possible at all (`_refreshRemotePort`
|
|
725
|
+
* has already returned early when it is not).
|
|
726
|
+
*/
|
|
727
|
+
async _restartRemote(exec) {
|
|
728
|
+
const command = this.command || 'dsh'
|
|
729
|
+
const waitSeconds = this.waitSeconds || 45
|
|
730
|
+
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
731
|
+
let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
|
|
732
|
+
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
733
|
+
out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
|
|
734
|
+
this.omittedNoOpen = true
|
|
735
|
+
}
|
|
736
|
+
this.remotePid = parseRemotePid(out) || this.remotePid
|
|
737
|
+
const parsed = parseWebStart(out)
|
|
738
|
+
if (!parsed) {
|
|
739
|
+
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
740
|
+
throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
|
|
741
|
+
+ ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
|
|
742
|
+
}
|
|
743
|
+
return { port: parsed.port, token: parsed.token, restarted: true }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Carry one local socket to the remote web port over SSH. */
|
|
747
|
+
_pipe(socket) {
|
|
748
|
+
if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
749
|
+
// ssh2's forwardOut does NOT report a dead connection through its callback:
|
|
750
|
+
// when the socket is gone it THROWS 'Not connected' synchronously
|
|
751
|
+
// (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
|
|
752
|
+
// and the callback below is never reached — so `if (err)` cannot guard this
|
|
753
|
+
// path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
|
|
754
|
+
// the whole host process (issue #54: every page refresh after the pooled
|
|
755
|
+
// connection dropped took the session, the browser and the remote orphan
|
|
756
|
+
// down with it). A dead tunnel is a UI state, not a reason to exit.
|
|
757
|
+
try {
|
|
758
|
+
this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
|
|
759
|
+
if (err) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
760
|
+
this.sockets.add(socket)
|
|
761
|
+
socket.pipe(channel)
|
|
762
|
+
channel.pipe(socket)
|
|
763
|
+
const kill = () => {
|
|
764
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
765
|
+
try { channel.close() } catch { /* already closed */ }
|
|
766
|
+
this.sockets.delete(socket)
|
|
767
|
+
}
|
|
768
|
+
socket.on('error', kill)
|
|
769
|
+
channel.on('error', kill)
|
|
770
|
+
socket.on('close', kill)
|
|
771
|
+
channel.on('close', kill)
|
|
772
|
+
})
|
|
773
|
+
} catch (err) {
|
|
774
|
+
this._clientLost(String((err && err.message) || err))
|
|
775
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The URL to open, carrying the one-time launch token.
|
|
781
|
+
*
|
|
782
|
+
* The token is a credential, so it stays on loopback and is never sent
|
|
783
|
+
* anywhere; DSH exchanges it for a 30-day signed cookie on the first request
|
|
784
|
+
* and redirects to a clean `/`.
|
|
785
|
+
*/
|
|
786
|
+
get url() {
|
|
787
|
+
return `http://127.0.0.1:${this.localPort}/?token=${this.token}`
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The clean URL (no token) — what the address bar shows after the exchange. */
|
|
791
|
+
get cleanUrl() {
|
|
792
|
+
return `http://127.0.0.1:${this.localPort}/`
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Serializable status for the settings UI. */
|
|
796
|
+
describe() {
|
|
797
|
+
return {
|
|
798
|
+
machineId: this.machineId,
|
|
799
|
+
url: this.url,
|
|
800
|
+
cleanUrl: this.cleanUrl,
|
|
801
|
+
token: this.token,
|
|
802
|
+
localPort: this.localPort,
|
|
803
|
+
remotePort: this.remotePort,
|
|
804
|
+
active: this.active,
|
|
805
|
+
startedAt: this.startedAt,
|
|
806
|
+
// Only a process we launched can be stopped by us; an attach onto the
|
|
807
|
+
// user's own running instance is deliberately hands-off.
|
|
808
|
+
managed: this.remotePid > 0,
|
|
809
|
+
error: this.lastError || '',
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Tear the tunnel down. The remote process is left running by default: the
|
|
815
|
+
* user may be using that DSH elsewhere, and silently killing a process we did
|
|
816
|
+
* not start is worse than a leftover one. `stopRemote` opts in.
|
|
817
|
+
*
|
|
818
|
+
* @param {object} [opts] - close options.
|
|
819
|
+
* @param {boolean} [opts.stopRemote] - also terminate the remote process.
|
|
820
|
+
* @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
|
|
821
|
+
*/
|
|
822
|
+
async close({ stopRemote = false, exec } = {}) {
|
|
823
|
+
// Stop following the pool BEFORE any await: a reconnect landing mid-teardown
|
|
824
|
+
// must not re-arm the tunnel this call is in the middle of closing.
|
|
825
|
+
this._detachPool()
|
|
826
|
+
if (this.attachedClient && this.onClientClose) {
|
|
827
|
+
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
828
|
+
}
|
|
829
|
+
this.attachedClient = null
|
|
830
|
+
this.onClientClose = null
|
|
831
|
+
this.pool = null
|
|
832
|
+
for (const socket of [...this.sockets]) {
|
|
833
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
834
|
+
}
|
|
835
|
+
this.sockets.clear()
|
|
836
|
+
const server = this.server
|
|
837
|
+
this.server = null
|
|
838
|
+
if (server) await new Promise((resolve) => server.close(() => resolve()))
|
|
839
|
+
// Only a process we started has a recorded PID; an attach to the user's own
|
|
840
|
+
// running instance must never signal anything.
|
|
841
|
+
if (stopRemote && exec && this.remotePid) {
|
|
842
|
+
await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
|
|
843
|
+
}
|
|
844
|
+
this.client = null
|
|
845
|
+
// The recorded PID and log path are FACTS about this session (the UI shows
|
|
846
|
+
// them, and `stopRemote` addresses them), so they survive; `closed` is what
|
|
847
|
+
// stops recovery work from being re-armed after teardown.
|
|
848
|
+
this.closed = true
|
|
849
|
+
}
|
|
850
|
+
}
|