diff --git a/Blink.xcodeproj/project.pbxproj b/Blink.xcodeproj/project.pbxproj index 07032780c..fe86c0d1e 100644 --- a/Blink.xcodeproj/project.pbxproj +++ b/Blink.xcodeproj/project.pbxproj @@ -94,6 +94,8 @@ BD1758AC26EA8C5400AEC545 /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1758AB26EA8C5400AEC545 /* MenuController.swift */; }; BD19DB412B056E9C003A4367 /* SSHCommandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD19DB402B056E9C003A4367 /* SSHCommandTest.swift */; }; BD2E27B529BAA8DA003AF1DA /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2E27B429BAA8DA003AF1DA /* ReplaySubject.swift */; }; + BD33F7822AAA426D00CD16EE /* MoshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD33F7802AAA426D00CD16EE /* MoshBootstrap.swift */; }; + BD33F7872AAA7C4300CD16EE /* MoshBootstrapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD33F7862AAA7C4300CD16EE /* MoshBootstrapTests.swift */; }; BD3E1E53278D190500333C44 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3E1E4F278D190500333C44 /* Archive.swift */; }; BD44DCE626D6BEAC00054338 /* BlinkItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD44DCE526D6BEAC00054338 /* BlinkItemIdentifier.swift */; }; BD67FC79272B30F300C1EE75 /* Messages.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD67FC78272B30F300C1EE75 /* Messages.swift */; }; @@ -113,6 +115,10 @@ BD81522D2739A91D002BB169 /* BlinkLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20A271F62ED00874007 /* BlinkLogging.swift */; }; BD81522E2739A91D002BB169 /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9EA20C271F664D00874007 /* Publisher.swift */; }; BD8152542743FF84002BB169 /* skstore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8152532743FF84002BB169 /* skstore.swift */; }; + BD818A052AAFC18400956488 /* mosh.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A042AAFC18400956488 /* mosh.swift */; }; + BD818A0C2AB120B800956488 /* MoshServerParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A0B2AB120B800956488 /* MoshServerParams.swift */; }; + BD818A132AB3865F00956488 /* MoshCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A122AB3865F00956488 /* MoshCommand.swift */; }; + BD818A152AB3A40100956488 /* MoshClientParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD818A142AB3A40100956488 /* MoshClientParams.swift */; }; BD835DD427A0BD19002C37D7 /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD835DD027A0BD19002C37D7 /* ReplaySubject.swift */; }; BD896F7B26CEAD37004313E6 /* FileTranslatorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD896F7A26CEAD37004313E6 /* FileTranslatorCache.swift */; }; BD8BBF5525F829B00084705F /* SEKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8BBF0825F819970084705F /* SEKeyTests.swift */; }; @@ -825,6 +831,8 @@ BD1758AB26EA8C5400AEC545 /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = ""; }; BD19DB402B056E9C003A4367 /* SSHCommandTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHCommandTest.swift; sourceTree = ""; }; BD2E27B429BAA8DA003AF1DA /* ReplaySubject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; + BD33F7802AAA426D00CD16EE /* MoshBootstrap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoshBootstrap.swift; sourceTree = ""; }; + BD33F7862AAA7C4300CD16EE /* MoshBootstrapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoshBootstrapTests.swift; sourceTree = ""; }; BD3E1E4F278D190500333C44 /* Archive.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = ""; }; BD44DCE526D6BEAC00054338 /* BlinkItemIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlinkItemIdentifier.swift; sourceTree = ""; }; BD67FC78272B30F300C1EE75 /* Messages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Messages.swift; sourceTree = ""; }; @@ -835,6 +843,10 @@ BD792A442A3B6A78009EE35F /* GitHubSnippets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubSnippets.swift; sourceTree = ""; }; BD81521C27387D1F002BB169 /* Certificates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Certificates.swift; sourceTree = ""; }; BD8152532743FF84002BB169 /* skstore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = skstore.swift; sourceTree = ""; }; + BD818A042AAFC18400956488 /* mosh.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = mosh.swift; sourceTree = ""; }; + BD818A0B2AB120B800956488 /* MoshServerParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoshServerParams.swift; sourceTree = ""; }; + BD818A122AB3865F00956488 /* MoshCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoshCommand.swift; sourceTree = ""; }; + BD818A142AB3A40100956488 /* MoshClientParams.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoshClientParams.swift; sourceTree = ""; }; BD835DD027A0BD19002C37D7 /* ReplaySubject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; BD896F7A26CEAD37004313E6 /* FileTranslatorCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTranslatorCache.swift; sourceTree = ""; }; BD8BBF0825F819970084705F /* SEKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEKeyTests.swift; sourceTree = ""; }; @@ -1685,6 +1697,18 @@ path = BlinkCode/Publisher; sourceTree = SOURCE_ROOT; }; + BD33F77F2AAA426D00CD16EE /* mosh */ = { + isa = PBXGroup; + children = ( + BD818A042AAFC18400956488 /* mosh.swift */, + BD33F7802AAA426D00CD16EE /* MoshBootstrap.swift */, + BD818A142AB3A40100956488 /* MoshClientParams.swift */, + BD818A122AB3865F00956488 /* MoshCommand.swift */, + BD818A0B2AB120B800956488 /* MoshServerParams.swift */, + ); + path = mosh; + sourceTree = ""; + }; BD835DCF27A0BD19002C37D7 /* Publisher */ = { isa = PBXGroup; children = ( @@ -2085,14 +2109,15 @@ D265FBBB2317DD3C0017EAC4 /* BlinkTests */ = { isa = PBXGroup; children = ( - D265FBBE2317DD3C0017EAC4 /* Info.plist */, - D20CBA56236031D700D93301 /* CompleteUtilsTests.swift */, - D265FBC42317E5090017EAC4 /* SessionParamsTests.swift */, - BD8BBF0825F819970084705F /* SEKeyTests.swift */, BD9EA215271F83B400874007 /* BlinkLoggingTests.swift */, - BD74A7C12905BD5800ED01CF /* WhatsNewModelTests.swift */, + D20CBA56236031D700D93301 /* CompleteUtilsTests.swift */, BDE7C45B29DCAEFA005E033E /* FileLocationPathTests.swift */, BD19DB402B056E9C003A4367 /* SSHCommandTest.swift */, + D265FBBE2317DD3C0017EAC4 /* Info.plist */, + BD8BBF0825F819970084705F /* SEKeyTests.swift */, + D265FBC42317E5090017EAC4 /* SessionParamsTests.swift */, + BD74A7C12905BD5800ED01CF /* WhatsNewModelTests.swift */, + BD33F7862AAA7C4300CD16EE /* MoshBootstrapTests.swift */, ); path = BlinkTests; sourceTree = ""; @@ -2253,6 +2278,7 @@ D2F330C520A6C8E20074ADD7 /* Commands */ = { isa = PBXGroup; children = ( + BD33F77F2AAA426D00CD16EE /* mosh */, 07FAB8E925C8E6C500E1CC2C /* ssh */, D2334D1221495DAE00D26AC3 /* udptunnel */, D240806020BC8DF800F30099 /* tool_main.c */, @@ -3231,6 +3257,7 @@ D265FBC52317E5090017EAC4 /* SessionParamsTests.swift in Sources */, BD9EA218271F846400874007 /* Publisher.swift in Sources */, BD8BBF5525F829B00084705F /* SEKeyTests.swift in Sources */, + BD33F7872AAA7C4300CD16EE /* MoshBootstrapTests.swift in Sources */, BD9EA216271F83B400874007 /* BlinkLoggingTests.swift in Sources */, BD19DB412B056E9C003A4367 /* SSHCommandTest.swift in Sources */, D20CBA57236031D700D93301 /* CompleteUtilsTests.swift in Sources */, @@ -3254,6 +3281,7 @@ D241CBD923040734003D64A5 /* KBKeyView.swift in Sources */, 803B99D72582869200DC99C8 /* BKNotificationsView.swift in Sources */, BD8152542743FF84002BB169 /* skstore.swift in Sources */, + BD818A052AAFC18400956488 /* mosh.swift in Sources */, C94E9B631D6BA21C00DA4DD6 /* DismissSegue.m in Sources */, D29B4A92274D206C00C66ED9 /* BrowserController.swift in Sources */, 803B99E3258381B200DC99C8 /* SettingsHostingController.swift in Sources */, @@ -3310,6 +3338,7 @@ D2A52227231304FF0010AC04 /* UIGestureRecognizer.swift in Sources */, D2AD8E7C27A2BAFA00DED28D /* PurchasePageView.swift in Sources */, D22278012A26204900D4C708 /* EditorViewController.swift in Sources */, + BD818A152AB3A40100956488 /* MoshClientParams.swift in Sources */, D266A9DC272A77A100C85EED /* code.swift in Sources */, D2C24425238E44AB0082C69C /* KeyModifierPicker.swift in Sources */, D259479C269C671F008B5305 /* MoshCustomOptionsPickerView.swift in Sources */, @@ -3325,6 +3354,7 @@ BD9EA1FE271A148700874007 /* Migrator.swift in Sources */, D2C24417238E44AB0082C69C /* KeyConfig.swift in Sources */, D28F301A21AD8A6B00E5259F /* DeviceInfo.m in Sources */, + BD33F7822AAA426D00CD16EE /* MoshBootstrap.swift in Sources */, D2179F2F2136DBC600B0850A /* geo.m in Sources */, D2C24414238E44AB0082C69C /* KeyAction.swift in Sources */, D28B0337243EF5F2008F38F6 /* Set+UIScene.swift in Sources */, @@ -3355,6 +3385,7 @@ D264D2B228F84592002B1B14 /* GridView.swift in Sources */, D2EFE1F520B7FAFC0087888B /* link_files.m in Sources */, D2A54CB129801062009D79FE /* BuildAccountModel.swift in Sources */, + BD818A0C2AB120B800956488 /* MoshServerParams.swift in Sources */, D2C24418238E44AB0082C69C /* KBConfigView.swift in Sources */, D2BF5F7F265BA0A80070F839 /* UserDefaults.swift in Sources */, D2F330CA20A6CB840074ADD7 /* help.m in Sources */, @@ -3391,6 +3422,7 @@ D23EA9592604CB4C00BCF1FF /* FixedTextField.swift in Sources */, D2C24437239104250082C69C /* ShortcutsConfigView.swift in Sources */, B7D450361DD3A87200CE0DBE /* BKiCloudSyncHandler.m in Sources */, + BD818A132AB3865F00956488 /* MoshCommand.swift in Sources */, D248E67622DDDF130057FE67 /* UIStateRestorable.swift in Sources */, C9B2E0341D6B612400B89F69 /* BKTheme.m in Sources */, D241CBD123040734003D64A5 /* KBKeyViewArrows.swift in Sources */, diff --git a/Blink/Blink-bridge.h b/Blink/Blink-bridge.h index 81a972452..a7dca6741 100644 --- a/Blink/Blink-bridge.h +++ b/Blink/Blink-bridge.h @@ -48,6 +48,8 @@ extern void __thread_ssh_execute_command(const char *command, socket_t in, socke extern int ios_dup2(int fd1, int fd2); extern void ios_exit(int errorCode) __dead2; // set error code and exits from the thread. +typedef void (*mosh_state_callback) (const void *context, const void *buffer, size_t size); + #import "BLKDefaults.h" #import "UIDevice+DeviceName.h" #import "BKHosts.h" @@ -70,5 +72,7 @@ extern void ios_exit(int errorCode) __dead2; // set error code and exits from th #import "TokioSignals.h" #import "BlinkMenu.h" #import "GeoManager.h" +#import "mosh/moshiosbridge.h" + #endif /* Blink_bridge_h */ diff --git a/Blink/Commands/mosh/MoshBootstrap.swift b/Blink/Commands/mosh/MoshBootstrap.swift new file mode 100644 index 000000000..fa61f4788 --- /dev/null +++ b/Blink/Commands/mosh/MoshBootstrap.swift @@ -0,0 +1,416 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2021 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import Combine +import CryptoKit + +import BlinkFiles +import SSH + +// We have decided to hard-code the version of Blink so client-server match. +// Static and override with env variable. +let MoshServerRemotePath = ".local/blink" +let MoshServerBinaryName = "mosh-server" +let MoshServerVersion = "1.4.0" +let MoshServerDownloadPathURL = URL(string: "https://github.com/blinksh/mosh-static-multiarch/releases/latest/download/")! +// TODO +fileprivate enum Checksum { + static let DarwinArm64 = "24df62e8f1490f5dc58a8ae50ae39957d5bf80d57f5f07fa81d46199b890dfd3" + static let DarwinX86_64 = "f4b7ed42a54d0ea743a157179eb0aaa9e30d2e67b869217c158f331ae396f317" + static let LinuxAmd64 = "e7aa244fbd0466273ae2ad34f3c26ba7b660438ee8635d7180950e255384b906" + static let LinuxArm64 = "9a2b5cc731664eb18f46a9aa14886c341b21d2f08ca798b6ea8c4e61313489e0" + static let LinuxArmv7 = "930aa3b4a40bf67fa56a17cfc3ba119cc7fa0f0bbd1760562ef0f9cefbc0466e" + static func validate(data: Data, platform: Platform, architecture: Architecture) -> Bool { + let hash = SHA256.hash(data: data) + let hexHash = hash.map { byte in String(format: "%02x", byte)}.joined() + let checksum: String + switch (platform, architecture) { + case (.Darwin, .X86_64): + checksum = Self.DarwinX86_64 + case (.Darwin, .Arm64): + checksum = Self.DarwinArm64 + case (.Linux, .Amd64): + checksum = Self.LinuxAmd64 + case (.Linux, .Arm64): + checksum = Self.LinuxArm64 + case (.Linux, .Armv7): + checksum = Self.LinuxArmv7 + default: + return false + } + return checksum == hexHash + } +} + +enum Platform { + case Darwin + case Linux +} + +extension Platform { + init?(from str: String) { + switch str.lowercased() { + case "darwin": + self = .Darwin + case "linux": + self = .Linux + default: + return nil + } + } +} + +extension Platform: CustomStringConvertible { + public var description: String { + switch self { + case .Darwin: + return "darwin" + case .Linux: + return "linux" + } + } +} + +enum Architecture { + case X86_64 + case Amd64 + case Arm64 + case Armv7 +} + +extension Architecture { + init?(from str: String) { + switch str.lowercased() { + case "x86_64": + self = .X86_64 + case "arm64": + self = .Arm64 + case "amd64": + self = .Amd64 + case "armv7": + self = .Armv7 + case "armv7l": + self = .Armv7 + default: + return nil + } + } +} + +extension Architecture: CustomStringConvertible { + public var description: String { + switch self { + case .X86_64: + return "x86_64" + case .Arm64: + return "arm64" + case .Amd64: + return "amd64" + case .Armv7: + return "armv7" + } + } +} + +protocol MoshBootstrap { + func start(on client: SSHClient) -> AnyPublisher +} + +// NOTE We could enforce "which" on interactive shell as a different bootstrap method. +class UseMoshOnPath: MoshBootstrap { + let path: String + + init(path: String? = nil) { + self.path = path ?? MoshServerBinaryName + } + + static func staticMosh() -> UseMoshOnPath { + UseMoshOnPath(path: "~/\(MoshServerRemotePath)/\(MoshServerBinaryName)") + } + + func start(on client: SSHClient) -> AnyPublisher { + Just(self.path).setFailureType(to: Error.self).eraseToAnyPublisher() + } +} + +class InstallStaticMosh: MoshBootstrap { + let promptUser: Bool + let onCancel: () -> () + let logger: MoshLogger + + init(promptUser: Bool = true, onCancel: @escaping () -> () = {}, logger: MoshLogger) { + self.promptUser = promptUser + self.onCancel = onCancel + self.logger = logger + } + + func start(on client: SSHClient) -> AnyPublisher { + let log = logger.log("InstallStaticMosh") + let prompt = InstallStaticMoshPrompt() + + return Just(()) + .flatMap { [unowned self] in self.platformAndArchitecture(on: client) } + .tryMap { pa in + guard let platform = pa?.0, + let architecture = pa?.1 else { + throw MoshError.NoBinaryAvailable + } + + if !self.promptUser || prompt.installMoshRequest() { + return (platform, architecture) + } else { + throw MoshError.UserCancelled + } + } + .flatMap { [unowned self] in self.getMoshServerBinary(platform: $0, architecture: $1) } + .flatMap { [unowned self] in self.installMoshServerBinary(on: client, localMoshServerBinary: $0) } + .print() + .eraseToAnyPublisher() + } + + private func platformAndArchitecture(on client: SSHClient) -> AnyPublisher<(Platform, Architecture)?, Error> { + let log = logger.log("platformAndArchitecture") + + return client.requestExec(command: "uname && uname -m") + .flatMap { s -> AnyPublisher in + s.read(max: 1024) + } + .map { String(decoding: $0 as AnyObject as! Data, as: UTF8.self).components(separatedBy: .newlines) } + .map { lines -> (Platform, Architecture)? in + if lines.count != 3 { + log.error("uname output: \(lines)") + return nil + } + + guard let platform = Platform(from: lines[0]), + let architecture = Architecture(from: lines[1]) else { + return nil + } + + return (platform, architecture) + }.eraseToAnyPublisher() + } + + func getMoshServerBinary(platform: Platform, architecture: Architecture) -> AnyPublisher { + let moshServerReleaseName = "\(MoshServerBinaryName)-\(MoshServerVersion)-\(platform)-\(architecture)" + let localMoshServerURL = BlinkPaths.blinkURL().appending(path: moshServerReleaseName) + let moshServerDownloadURL = MoshServerDownloadPathURL.appending(path: moshServerReleaseName) + let log = logger.log("getMoshServerBinary") + let prompt = InstallStaticMoshPrompt() + + log.info("\(platform) \(architecture)") + return Local().cloneWalkTo(localMoshServerURL.path) + .catch { _ in + log.info("Downloading \(moshServerDownloadURL)") + prompt.showDownloadProgress(cancellationHandler: { [weak self] in self?.onCancel() }) + return URLSession.shared.dataTaskPublisher(for: moshServerDownloadURL) + .map(\.data) + .tryMap { data in + guard Checksum.validate(data: data, platform: platform, architecture: architecture) else { + log.error("Download mismatch. Downloaded size: \(data.count)") + throw MoshError.NoChecksumMatch + } + try data.write(to: localMoshServerURL) + prompt.progressUpdate(1.0) + return localMoshServerURL + } + .flatMap { + Local().cloneWalkTo($0.path) + } + }.eraseToAnyPublisher() + } + + private func installMoshServerBinary(on client: SSHClient, localMoshServerBinary: Translator) -> AnyPublisher { + let moshServerRemotePath = NSString(string: MoshServerRemotePath) + let moshServerBinaryPath = moshServerRemotePath.appendingPathComponent(MoshServerBinaryName) + let log = logger.log("installMoshServerBinary") + let prompt = InstallStaticMoshPrompt() + + log.info("on \(moshServerBinaryPath)") + var uploaded: UInt64 = 0 + return client.requestSFTP() + .tryMap { try SFTPTranslator(on: $0) } + .flatMap { sftp in + sftp.cloneWalkTo(moshServerBinaryPath) + .catch { _ in + prompt.showUploadProgress(cancellationHandler: { [weak self] in self?.onCancel() }) + return sftp.cloneWalkTo(moshServerRemotePath.standardizingPath) + .catch { _ in + log.info("Path not found: \(moshServerRemotePath.standardizingPath). Creating it...") + return sftp.mkPath(path: moshServerRemotePath.standardizingPath) + } + // Upload file + .flatMap { dest in + dest.copy(from: [localMoshServerBinary]) + .tryMap { info in + uploaded += info.written + let percentage = Float(uploaded) / Float(info.size) + // Tested. If something happens, it closes properly. + // if percentage > 0.5 { throw MoshError.UserCancelled } + prompt.progressUpdate(percentage) + } + } + .last() + .flatMap { _ -> AnyPublisher in + let uploadedBinaryPath = moshServerRemotePath + .appendingPathComponent((localMoshServerBinary.current as NSString).lastPathComponent) + log.info("File uploaded at \(uploadedBinaryPath). Moving to \(moshServerBinaryPath)") + + return sftp.cloneWalkTo(uploadedBinaryPath) + .flatMap { $0.wstat([.name: MoshServerBinaryName]) } + .flatMap { _ in sftp.cloneWalkTo(moshServerBinaryPath) } + .eraseToAnyPublisher() + } + } + .map { $0.current } + // Set execution flag. + .flatMap { moshPath in + let command = "chmod +x \(moshPath)" + log.info("chmod +x \(moshPath)") + return client.requestExec(command: command) + .flatMap { $0.read_err(max: 1024) } + .tryMap { err_out in + prompt.progressUpdate(1.0) + if err_out.count > 0 { + log.error("chmod err: \(err_out)") + throw MoshError.NoBinaryExecFlag + } else { return moshPath } + } + } + } + .eraseToAnyPublisher() + } + + deinit { + print("Install Mosh OUT") + } +} + +class InstallStaticMoshPrompt { + public var name: String { "User Prompt" } + var window: UIWindow? = nil + var progressView: UIProgressView? = nil + + public func installMoshRequest() -> Bool { + var shouldInstall = false + let semaphore = DispatchSemaphore(value: 0) + + let alert = UIAlertController(title: "Mosh server not found", message: "Blink will try to install mosh on the remote.", preferredStyle: .alert) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("Continue", comment: "Install"), + style: .default, + handler: { _ in + shouldInstall = true + semaphore.signal() + self.window = nil + })) + alert.addAction( + UIAlertAction(title: NSLocalizedString("Cancel", comment: "Do not install"), + style: .cancel, + handler: { _ in + shouldInstall = false + semaphore.signal() + self.window = nil + })) + + self.displayAlert(alert, completion: nil) + + semaphore.wait() + + return shouldInstall + } + + public func showDownloadProgress(cancellationHandler: @escaping () -> ()) { + // Show download progress. Communicate progress. Once done, dismiss. + let alert = UIAlertController(title: "Downloading mosh-server to device", message: "", preferredStyle: .alert) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel the download"), + style: .cancel, + handler: { [weak self] _ in + cancellationHandler() + self?.window = nil + })) + + self.displayAlert(alert, completion: nil) + } + + public func showUploadProgress(cancellationHandler: @escaping () -> ()) { + let alert = UIAlertController(title: "Uploading mosh-server to remote", message: "", preferredStyle: .alert) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel the upload"), + style: .cancel, + handler: { [weak self] _ in + cancellationHandler() + self?.window = nil + self?.progressView = nil + })) + + self.displayAlert(alert, completion: { + let margin:CGFloat = 8.0 + let rect = CGRect(x: margin, y: 72.0, width: alert.view.frame.width - margin * 2.0 , height: 2.0) + self.progressView = UIProgressView(frame: rect) + self.progressView!.tintColor = self.window?.tintColor + alert.view.addSubview(self.progressView!) + }) + } + + // Kinda like the DownloadDelegate for the URLSession. But we don't need to reuse this. + public func progressUpdate(_ progress: Float) { + if progress == 1.0 { + self.progressView = nil + self.window = nil + } else { + DispatchQueue.main.async { + self.progressView?.progress = progress + } + } + } + + private func displayAlert(_ alert: UIAlertController, completion: (() -> ())?) { + DispatchQueue.main.async { + let foregroundActiveScene = UIApplication.shared.connectedScenes.filter { $0.activationState == .foregroundActive }.first + guard let foregroundWindowScene = foregroundActiveScene as? UIWindowScene else { + // semaphore.signal() + return + } + + let window = UIWindow(windowScene: foregroundWindowScene) + self.window = window + window.rootViewController = UIViewController() + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + window.rootViewController!.present(alert, animated: true, completion: completion) + } + } +} diff --git a/Blink/Commands/mosh/MoshClientParams.swift b/Blink/Commands/mosh/MoshClientParams.swift new file mode 100644 index 000000000..248d2851d --- /dev/null +++ b/Blink/Commands/mosh/MoshClientParams.swift @@ -0,0 +1,56 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2023 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import Foundation + +struct MoshClientParams { + let predictionMode: BKMoshPrediction + let predictOverwrite: String? + let experimentalRemoteIP: BKMoshExperimentalIP + let customUDPPort: String? + let server: String + let remoteExecCommand: String? + + init(extending cmd: MoshCommand) { + let bkHost = BKHosts.withHost(cmd.hostAlias) + + let customUDPPort: String? = if let moshPort = bkHost?.moshPort { String(describing: moshPort) } else { nil } + self.customUDPPort = cmd.customUDPPort ?? customUDPPort + let moshServer: String? = if let moshServer = bkHost?.moshServer, !moshServer.isEmpty { moshServer } else { nil } + self.server = cmd.server ?? moshServer ?? "mosh-server" + self.predictionMode = cmd.predict ?? BKMoshPrediction(UInt32(truncating: bkHost?.prediction ?? 0)) + self.predictOverwrite = cmd.predictOverwrite ? "yes" : bkHost?.moshPredictOverwrite + self.experimentalRemoteIP = cmd.experimentalRemoteIP ?? BKMoshExperimentalIP(UInt32(truncating: bkHost?.moshExperimentalIP ?? 0)) + let remoteExecCommand: String? = if let command = bkHost?.moshStartup, !command.isEmpty { command } else { nil } + self.remoteExecCommand = !cmd.remoteExecCommand.isEmpty ? cmd.remoteExecCommand.joined(separator: " ") : remoteExecCommand + } +} diff --git a/Blink/Commands/mosh/MoshCommand.swift b/Blink/Commands/mosh/MoshCommand.swift new file mode 100644 index 000000000..79740d6d7 --- /dev/null +++ b/Blink/Commands/mosh/MoshCommand.swift @@ -0,0 +1,210 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +// mosh [options] [user@]host|IP [--] [command] +// "anop:I:P:k:T2" +// {"server", required_argument, 0, 's'}, +// {"predict", required_argument, 0, 'r'}, +// {"port", required_argument, 0, 'p'}, +// {"ip", optional_argument, 0, 'i'}, +// {"key", optional_argument, 0, 'k'}, +// {"no-ssh-pty", optional_argument, 0, 'T'}, +// {"predict-overwrite", no_argument, 0, 'o'}, +// //{"ssh", required_argument, 0, 'S'}, +// {"verbose", no_argument, &_debug, 1}, +// {"help", no_argument, &help, 1}, +// {"experimental-remote-ip", required_argument, 0, 'R'}, +import Foundation +import ArgumentParser + +fileprivate let Version = "1.4.0" + +struct MoshCommand: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "", + discussion: """ + """, + version: Version) + + @Option(name: .shortAndLong) + var server: String? + + @Option(help: "Prediction mode", + transform: { try BKMoshPrediction(parsing: $0) }) + var predict: BKMoshPrediction? + + @Flag var predictOverwrite: Bool = false + + @Flag var verbose: Bool = false + + @Flag var noSshPty: Bool = false + + @Option(help: "How to discover the IP address that the mosh-client connects to: default, remote or local", + transform: { try BKMoshExperimentalIP(parsing: $0) }) + var experimentalRemoteIP: BKMoshExperimentalIP? + + // Mosh Key + @Option( + name: [.customShort("k")], + help: "Use the provided server-side key for mosh connection." + ) + var customKey: String? + + // UDP Port + @Option( + name: [.customShort("p")], + help: "Use a particular server-side UDP port or port range, for example, if this is the only port that is forwarded through a firewall to the server. Otherwise, mosh will choose a port between 60000 and 61000." + ) + var customUDPPort: String? + + // SSH Port + @Option( + name: [.customShort("P")], + help: "Specifies the SSH port to initialize mosh-server on remote host." + ) + var customSSHPort: UInt16? + + // Identity + @Option( + name: [.customShort("i")], + help: .init( + """ + Selects a file from which the identity (private key) for public key authentication is read. The default is ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519 and ~/.ssh/id_rsa. Identity files may also be specified on a per-host basis in the configuration pane in the Settings of Blink. + """, + valueName: "identity" + ) + ) + var identityFile: String? + + // TODO Reuse fields + // Connect to User at Host + @Argument(help: "[user@]host[#port]", + transform: { UserAtHostAndPort($0) }) + var userAtHostAndPort: UserAtHostAndPort + var hostAlias: String { userAtHostAndPort.hostAlias } + var user: String? { userAtHostAndPort.user } + var sshPort: UInt16? { + get { if let port = customSSHPort { port } else { userAtHostAndPort.port } } + } + + @Argument( + parsing: .unconditionalRemaining, + help: .init( + "If a is specified, it is executed on the remote host instead of a login shell", + valueName: "remoteCommand" + ) + ) + + fileprivate var cmd: [String] = [] + var remoteExecCommand: [String] { + get { + if cmd.first == "--" { + return Array(cmd.dropFirst()) + } else { + return cmd + } + } + } +} + +extension MoshCommand { + func bkSSHHost() throws -> BKSSHHost { + var params: [String:Any] = [:] + + if let user = self.user { + params["user"] = user + } + + if let port = self.sshPort { + params["port"] = String(port) + } + + if let identityFile = self.identityFile { + params["identityfile"] = identityFile + } + + // TODO - Careful here as a high log level like DEBUG will introduce a lot of noise. + if self.verbose { + params["loglevel"] = "INFO" + } + // params["loglevel"] = "DEBUG" + + params["compression"] = "no" + return try BKSSHHost(content: params) + } +} + +extension BKMoshPrediction: CustomStringConvertible { + init(parsing: String) throws { + switch parsing.lowercased() { + case "adaptive": + self = BKMoshPredictionAdaptive + case "always": + self = BKMoshPredictionAlways + case "never": + self = BKMoshPredictionNever + case "experimental": + self = BKMoshPredictionExperimental + default: + throw ValidationError("Unknown prediction mode, must be: adaptive, always, never, experimental.") + } + } + + public var description: String { + switch self { + case BKMoshPredictionAdaptive: + "adaptive" + case BKMoshPredictionAlways: + "always" + case BKMoshPredictionNever: + "never" + case BKMoshPredictionExperimental: + "experimental" + default: + "unknown" + } + } +} + +extension BKMoshExperimentalIP { + init(parsing: String) throws { + switch parsing.lowercased() { + case "default": + self = BKMoshExperimentalIPNone + case "local": + self = BKMoshExperimentalIPLocal + case "remote": + self = BKMoshExperimentalIPRemote + default: + throw ValidationError("Unknown experimental-ip mode, must be: default, local or remote.") + } + } +} diff --git a/Blink/Commands/mosh/MoshServerParams.swift b/Blink/Commands/mosh/MoshServerParams.swift new file mode 100644 index 000000000..6b9ccdfd5 --- /dev/null +++ b/Blink/Commands/mosh/MoshServerParams.swift @@ -0,0 +1,77 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2023 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + + +import Foundation + + +struct MoshServerParams { + let key: String + let udpPort: String + let remoteIP: String +} + +extension MoshServerParams { + init(parsing output: String, remoteIP: String?) throws { + if let remoteIP = remoteIP { + self.remoteIP = remoteIP + } else { + let remoteIPPattern = try! NSRegularExpression( + pattern: "(?m)^MOSH SSH_CONNECTION (\\S*) (\\d*) (\\S*) (\\d*)$", + options: [] + ) + if let remoteIPMatch = remoteIPPattern.firstMatch( + in: output, + options: [], + range: NSRange(location: 0, length: output.utf8.count) + ) { + self.remoteIP = String(output[Range(remoteIPMatch.range(at: 3), in: output)!]) + } else { + throw MoshError.NoRemoteServerIP + } + } + + let connectPattern = try! NSRegularExpression( + pattern: "(?m)^MOSH CONNECT (\\d+) (\\S*)$", + options: [] + ) + if let connectMatch = connectPattern.firstMatch( + in: output, + options: [], + range: NSRange(output.startIndex..., in: output) + ) { + self.udpPort = String(output[Range(connectMatch.range(at: 1), in: output)!]) + self.key = String(output[Range(connectMatch.range(at: 2), in: output)!]) + } else { + throw MoshError.NoMoshServerArgs + } + } +} diff --git a/Blink/Commands/mosh/mosh.swift b/Blink/Commands/mosh/mosh.swift new file mode 100644 index 000000000..cc872b2e0 --- /dev/null +++ b/Blink/Commands/mosh/mosh.swift @@ -0,0 +1,503 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import Combine +import Dispatch + +import SSH +import ios_system + +// TODO Enable mosh2 as the old mosh session. Will have to be hard-coded on MCP. + +// @_cdecl("blink_mosh_main") +// public func blink_mosh_main(argc: Int32, argv: Argv) -> Int32 { +// setvbuf(thread_stdin, nil, _IONBF, 0) +// setvbuf(thread_stdout, nil, _IONBF, 0) +// setvbuf(thread_stderr, nil, _IONBF, 0) + +// let session = Unmanaged.fromOpaque(thread_context).takeUnretainedValue() +// // TODO How about register and deregister here? +// let cmd = BlinkMosh() +// return cmd.start(argc, argv: argv.args(count: argc)) +// } + +enum MoshError: Error { + case NoBinaryAvailable + case NoBinaryExecFlag + case NoChecksumMatch + case UserCancelled + case NoMoshServerArgs + case NoRemoteServerIP + case AddressInfo(String) + case StartMoshServerError(String) +} + +@objc public class BlinkMosh: Session { + var exitCode: Int32 = 0 + var sshCancellable: AnyCancellable? = nil + var currentRunLoop: RunLoop! + var stdin: InputStream! + var stdout: OutputStream! + var stderr: OutputStream! + private var initialMoshParams: MoshParams? = nil + private let mcpSession: MCPSession + private var suspendSemaphore: DispatchSemaphore? = nil + private let escapeKey: String + private var logger: MoshLogger! = nil + var isRunloopRunning = false + + let stateCallback: mosh_state_callback = { (context, buffer, size) in + guard let buffer = buffer, let context = context else { + return + } + let data = Data(bytes: buffer, count: size) + let session = Unmanaged.fromOpaque(context).takeUnretainedValue() + session.onStateEncoded(data) + } + + @objc init!(mcpSession: MCPSession, device: TermDevice!, andParams params: SessionParams!) { + if let escapeKey = ProcessInfo.processInfo.environment["MOSH_ESCAPE_KEY"], + escapeKey.count == 1 { + self.escapeKey = escapeKey + } else { + self.escapeKey = "\u{1e}" + } + self.mcpSession = mcpSession + + super.init(device: device, andParams: params) + + self.stdin = InputStream(file: stream.in) + self.stdout = OutputStream(file: stream.out) + self.stderr = OutputStream(file: stream.err) + } + + @objc public override func main(_ argc: Int32, argv: Argv) -> Int32 { + mcpSession.setActiveSession() + self.currentRunLoop = RunLoop.current + // In ObjC, sessionParams is a covariable for MoshParams. + // In Swift we need to cast. + if let initialMoshParams = self.sessionParams as? MoshParams, + let _ = initialMoshParams.encodedState { + return moshMain(initialMoshParams) + } else { + let command: MoshCommand + do { + command = try MoshCommand.parse(Array(argv.args(count: argc)[1...])) + } catch { + let message = MoshCommand.message(for: error) + return die(message: message) + } + + self.logger = MoshLogger(output: self.stderr, logLevel: command.verbose ? .info : .error) + + let moshParams: MoshParams + do { + moshParams = try startMoshServer(using: command) + self.copyToSession(moshParams: moshParams) + } catch { + return die(message: "\(error)") + } + + return moshMain(moshParams) + } + } + + func startMoshServer(using command: MoshCommand) throws -> MoshParams { + let host: BKSSHHost + let config: SSHClientConfig + let hostName: String + let log = logger.log("startMoshServer") + + host = try BKConfig().bkSSHHost(command.hostAlias, extending: command.bkSSHHost()) + hostName = host.hostName ?? command.hostAlias + config = try SSHClientConfigProvider.config(host: host, using: device) + + let moshClientParams = MoshClientParams(extending: command) + let moshServerParams: MoshServerParams + if let customKey = command.customKey { + guard let customUDPPort = moshClientParams.customUDPPort else { + throw MoshError.StartMoshServerError("If MOSH_KEY is set, port is required. (-p)") + } + + // Resolved as part of the host info or explicit on params. + let remoteIP = hostName + moshServerParams = MoshServerParams(key: customKey, udpPort: customUDPPort, remoteIP: remoteIP) + log.info("Manual Mosh server bootstrapped with params \(moshServerParams)") + } else { + let moshServerStartupArgs = getMoshServerStartupArgs(udpPort: moshClientParams.customUDPPort, + colors: nil, + exec: moshClientParams.remoteExecCommand) + + let sequence: [MoshBootstrap] + // NOTE Users may want a way to disable or make installation explicit. Right now, we offer it by default. + if moshClientParams.server != "mosh-server" { + // If the server is specific, go after that one only. + sequence = [UseMoshOnPath(path: moshClientParams.server)] + } else { + sequence = [UseMoshOnPath(path: moshClientParams.server), + UseMoshOnPath.staticMosh(), + InstallStaticMosh(onCancel: { [weak self] in self?.kill() }, logger: self.logger)] + } + + let pty: SSH.SSHClient.PTY? + if command.noSshPty { + pty = nil + } else { + pty = SSH.SSHClient.PTY(rows: Int32(self.device.rows), columns: Int32(self.device.cols)) + } + + var sshError: Error? = nil + var _moshServerParams: MoshServerParams? = nil + self.sshCancellable = SSHClient.dial(hostName, with: config) + .flatMap { self.bootstrapMoshServer(on: $0, + sequence: sequence, + experimentalRemoteIP: moshClientParams.experimentalRemoteIP, + args: moshServerStartupArgs, + withPTY: pty) } + //.print() + .handleEvents(receiveCancel: { [weak self] in + if let self = self { + self.currentRunLoop.run(until: Date(timeIntervalSinceNow: 0.5)) + awake(runLoop: currentRunLoop) + } + }) + .sink( + receiveCompletion: { [weak self] completion in + switch completion { + case .failure(let error): + sshError = error + default: + break + } + self?.kill() + }, + receiveValue: { params in + _moshServerParams = params + }) + + self.isRunloopRunning = true + awaitRunLoop(currentRunLoop) + self.isRunloopRunning = false + + if let error = sshError { + throw error + } + + guard let _moshServerParams = _moshServerParams else { + throw MoshError.NoMoshServerArgs + } + moshServerParams = _moshServerParams + log.info("Remote Mosh server bootstrapped with params \(moshServerParams)") + } + + return MoshParams(server: moshServerParams, client: moshClientParams) + } + + private func moshMain(_ moshParams: MoshParams) -> Int32 { + let originalRawMode = device.rawMode + self.device.rawMode = true + + defer { + device.rawMode = originalRawMode + } + + let _selfRef = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + let encodedState = [UInt8](moshParams.encodedState ?? Data()) + + if let localesPath = Bundle.main.path(forResource: "locales", ofType: "bundle"), + let ccharLocalesPath = localesPath.cString(using: .utf8) { + setenv("PATH_LOCALE", ccharLocalesPath, 1) + } + + mosh_main( + self.stdin.file, + self.stdout.file, + self.device.window(), + self.stateCallback, + _selfRef, + moshParams.ip, + moshParams.port, + moshParams.key, + moshParams.predictionMode, + encodedState, + encodedState.count, + moshParams.predictOverwrite + ) + + return 0 + } + + private func getMoshServerStartupArgs(udpPort: String?, + colors: String?, + exec: String?) -> String { + let localeFallback = "LANG=\(String(cString: getenv("LANG")))" + + var args = ["new", "-s", "-c", colors ?? "256", "-l", localeFallback] + + if let udpPort = udpPort { + args.append(contentsOf: ["-p", udpPort]) + } + if let exec = exec { + args.append(contentsOf: ["--", exec]) + } + + return args.joined(separator: " ") + } + + private func bootstrapMoshServer(on client: SSHClient, + sequence: [MoshBootstrap], + experimentalRemoteIP: BKMoshExperimentalIP, + args: String, + withPTY pty: SSH.SSHClient.PTY? = nil) -> AnyPublisher { + let log = logger.log("bootstrapMoshServer") + log.info("Trying bootstrap with sequence: \(sequence), experimental: \(experimentalRemoteIP), args: \(args)") + + if sequence.isEmpty { + return Fail(error: MoshError.NoBinaryAvailable).eraseToAnyPublisher() + } + + func tryBootstrap(_ sequence: [MoshBootstrap]) -> AnyPublisher { + if sequence.count == 0 { + return .fail(error: MoshError.NoMoshServerArgs) + } + + let bootstrap = sequence.first! + log.info("Trying \(bootstrap)") + return Just(bootstrap) + .flatMap { $0.start(on: client) } + .map { moshServerPath -> String in + if experimentalRemoteIP == BKMoshExperimentalIPRemote { + return "echo \"MOSH SSH_CONNECTION $SSH_CONNECTION\" && \(moshServerPath) \(args)" + } else { + return "\(moshServerPath) \(args)" + } + } + .flatMap { + log.info("Connecting to \($0)") + return client.requestExec(command: $0, withPTY: pty) + } + .flatMap { s -> AnyPublisher in + // The PTY will multiplex, so we only try to parse stdout in all cases. + s.read(max: 1024).eraseToAnyPublisher() //.zip(s.read_err(max: 1024)).eraseToAnyPublisher() + } + .flatMap { data -> AnyPublisher in + return Just(data) + .map { + String(decoding: $0 as AnyObject as! Data, as: UTF8.self) + } + .tryMap { output -> MoshServerParams in + log.info("Command output: \(output)") + // IP Resolution + switch experimentalRemoteIP { + case BKMoshExperimentalIPRemote: + // remote - echo SSH_CONNECTION on remote for parsing. + return try MoshServerParams(parsing: output, remoteIP: nil) + case BKMoshExperimentalIPLocal: + // local - resolve address on its own. + // TODO Or to INET6 from CLI flag + let remoteIP = try self.resolveAddress(host: client.host, port: client.options.port, family: nil) + return try MoshServerParams(parsing: output, remoteIP: remoteIP) + default: + // default - get it from the established SSH Connection. + return try MoshServerParams(parsing: output, remoteIP: client.clientAddressIP()) + } + } + .catch{ err in + //let err = String(decoding: err as AnyObject as! Data, as: UTF8.self) + log.warn("Bootstrap failed with \(err)") + var sequence = sequence + sequence.removeFirst() + return tryBootstrap(sequence) + } + .eraseToAnyPublisher() + } + .print() + .eraseToAnyPublisher() + } + + return tryBootstrap(sequence) + } + + private func copyToSession(moshParams: MoshParams) { + if let sessionParams = self.sessionParams as? MoshParams { + sessionParams.copy(from: moshParams) + } + } + + // Migrated from Objc, based on... + // getaddrinfo + // https://stackoverflow.com/questions/39857435/swift-getaddrinfo + // getnameinfo + // https://stackoverflow.com/questions/44478074/swift-getnameinfo-unreliable-results-for-ipv6 + private func resolveAddress(host: String, port: String?, family: Int32?) throws -> String { + guard let port = (port ?? "22").cString(using: .utf8) else { + throw MoshError.AddressInfo("Invalid port") + } + var hints = addrinfo( + ai_flags: 0, + ai_family: family ?? AF_UNSPEC, + ai_socktype: SOCK_STREAM, + ai_protocol: IPPROTO_TCP, + ai_addrlen: 0, + ai_canonname: nil, + ai_addr: nil, + ai_next: nil) + var result: UnsafeMutablePointer? = nil + let err = getaddrinfo(host, port, &hints, &result) + if err != 0 { + throw MoshError.AddressInfo("getaddrinfo failed with \(err)") + } + defer { freeaddrinfo(result) } + + guard let firstAddr = result?.pointee else { + throw MoshError.AddressInfo("No address info found") + } + for ai in sequence(first: firstAddr, next: { $0.ai_next?.pointee }) { + if (ai.ai_family != AF_INET && ai.ai_family != AF_INET6) { + continue; + } + + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var port = port + if getnameinfo(ai.ai_addr, ai.ai_addrlen, + &buffer, socklen_t(buffer.count), + &port, socklen_t(port.count), + NI_NUMERICHOST | NI_NUMERICSERV) != 0 { + print("getnameinfo failed") + continue + } + + return String(cString: buffer) + } + + throw MoshError.AddressInfo("Could not resolve address through getnameinfo.") + } + + @objc public override func kill() { + if isRunloopRunning { + // Cancelling here makes sure the flows are cancelled. + // Trying to do it at the runloop has the issue that flows may continue running. + print("Kill received") + self.currentRunLoop.run(until: Date(timeIntervalSinceNow: 0.5)) + awake(runLoop: currentRunLoop) + sshCancellable = nil + } else { + // MOSH-ESC . + self.device.write(String("\(self.escapeKey)\u{2e}")) + pthread_kill(self.tid, SIGINT) + } + } + + @objc public override func suspend() { + if sshCancellable == nil { + suspendSemaphore = DispatchSemaphore(value: 0) + // MOSH-ESC C-z + self.device.write(String("\(self.escapeKey)\u{1a}")) + print("Session suspend called") + let _ = suspendSemaphore!.wait(timeout: (DispatchTime.now() + 2.0)) + print("Session suspended") + } + } + + @objc public override func sigwinch() { + pthread_kill(self.tid, SIGWINCH); + } + + @objc public override func handleControl(_ control: String!) -> Bool { + if isRunloopRunning { + self.kill() + return true + } else { + return false + } + } + + func onStateEncoded(_ encodedState: Data) { + self.sessionParams.encodedState = encodedState + print("Encoding session") + if let sema = suspendSemaphore { + sema.signal() + } + } + + func die(message: String) -> Int32 { + print(message, to: &stderr) + return -1 + } + + deinit { + print("Mosh is out") + } +} + +extension MoshParams { + convenience init(server: MoshServerParams, client: MoshClientParams) { + self.init() + + self.key = server.key + self.port = server.udpPort + self.ip = server.remoteIP + self.predictionMode = String(describing: client.predictionMode) + self.predictOverwrite = client.predictOverwrite + self.serverPath = client.server + } +} + +struct MoshLogger { + var handler = [BlinkLogging.LogHandlerFactory]() + init(output: OutputStream, logLevel: BlinkLogLevel = .error) { + handler.append( + { + $0 + .filter(logLevel: logLevel) + .format { [ ($0[.component] as? String)?.appending(":") ?? "global:", + $0[.message] as? String ?? "" + ].joined(separator: " ") } + .sink(receiveValue: { print($0[.message]) }) + //.sinkToStream(output) + } + ) + } + + func log(_ component: String) -> BlinkLogger { + BlinkLogger(component, handlers: handler) + } +} + +extension Publisher { + fileprivate func sinkToStream(_ stream: OutputStream) -> AnyCancellable where Self.Output == [BlinkLogKeys:Any] { + let out = NonStdIO(err: stream) + return sink(receiveCompletion: { _ in }, + receiveValue: { + out.printError($0[.message] ?? "") + }) + } +} diff --git a/Blink/Commands/ssh/SSHConfig.swift b/Blink/Commands/ssh/SSHConfig.swift index 165441c63..5a8b496a3 100644 --- a/Blink/Commands/ssh/SSHConfig.swift +++ b/Blink/Commands/ssh/SSHConfig.swift @@ -91,7 +91,7 @@ struct SSHCommand: ParsableCommand { var localForward: [String] = [] // Remote Port forwarding - @Option(name: [.customShort("R")], + @Option(name: [.customShort("R")], help: "port:host:hostport Specifies that the given port on the remote (server) host is to be forwarded to the given host and port on the local side." ) var remoteForward: [String] = [] @@ -346,3 +346,26 @@ enum SSHControlCommands: String, CaseIterable, ExpressibleByArgument { case cancel = "cancel" case stop = "stop" } + +class UserAtHostAndPort { + let user: String? + let hostAlias: String + let port: UInt16? + + init(_ input: String) { + var userAtHost = input.components(separatedBy: "@") + let hostAndPort: String + if userAtHost.count > 1 { + hostAndPort = userAtHost.removeLast() + // A user may have multiple @ symbols + self.user = userAtHost.joined(separator: "@") + } else { + self.user = nil + hostAndPort = userAtHost[0] + } + + var hostAndPortComponents = hostAndPort.components(separatedBy: "#") + self.hostAlias = hostAndPortComponents.removeFirst() + self.port = if hostAndPortComponents.count == 0 { nil } else { UInt16(hostAndPortComponents[0]) } + } +} diff --git a/Blink/TermDevice.h b/Blink/TermDevice.h index 5a03c6a62..b712c2e77 100644 --- a/Blink/TermDevice.h +++ b/Blink/TermDevice.h @@ -76,6 +76,9 @@ @property (nonatomic) NSInteger rows; @property (nonatomic) NSInteger cols; +// Offer the pointer as it is a struct on itself. This is helpful because on Swift, +// we cannot used a synthesized expression to get the UnsafeMutablePointer. +- (struct winsize *)window; - (void)attachInput:(UIView *)termInput; - (void)attachView:(TermView *)termView; diff --git a/Blink/TermDevice.m b/Blink/TermDevice.m index fcbfab132..4eb42417b 100644 --- a/Blink/TermDevice.m +++ b/Blink/TermDevice.m @@ -200,6 +200,11 @@ - (id)init return self; } +- (struct winsize *)window +{ + return &win; +} + - (void)write:(NSString *)input { if (!_rawMode) { diff --git a/BlinkConfig/BKHosts.h b/BlinkConfig/BKHosts.h index 348b7988f..6fca4a0da 100644 --- a/BlinkConfig/BKHosts.h +++ b/BlinkConfig/BKHosts.h @@ -37,8 +37,7 @@ enum BKMoshPrediction { BKMoshPredictionAdaptive, BKMoshPredictionAlways, BKMoshPredictionNever, - BKMoshPredictionExperimental, - BKMoshPredictionUnknown + BKMoshPredictionExperimental }; enum BKMoshExperimentalIP { diff --git a/BlinkFiles/BlinkFiles+Extensions.swift b/BlinkFiles/BlinkFiles+Extensions.swift index 3ff962f3e..82a5e4d49 100644 --- a/BlinkFiles/BlinkFiles+Extensions.swift +++ b/BlinkFiles/BlinkFiles+Extensions.swift @@ -96,4 +96,17 @@ extension Translator { .eraseToAnyPublisher() }.eraseToAnyPublisher() } + + public func mkdir(name: String) -> AnyPublisher { + mkdir(name: name, mode: S_IRWXU | S_IRWXG | S_IRWXO) + } + + public func mkPath(path: String) -> AnyPublisher { + cloneWalkTo(path) + .catch { _ in + let name = (path as NSString).lastPathComponent + let parentPath = (path as NSString).deletingLastPathComponent + return mkPath(path: parentPath).flatMap { $0.mkdir(name: name ) }.eraseToAnyPublisher() + }.eraseToAnyPublisher() + } } diff --git a/BlinkTests/MoshBootstrapTests.swift b/BlinkTests/MoshBootstrapTests.swift new file mode 100644 index 000000000..745ee88b6 --- /dev/null +++ b/BlinkTests/MoshBootstrapTests.swift @@ -0,0 +1,112 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2023 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import Combine +import XCTest + +import SSH + +@testable import Blink + +final class MoshBootstrapTests: XCTestCase { + var cancellableBag: Set = [] + + func testMoshBootstrap() throws { + print("connecting...") + + let expectConn = self.expectation(description: "Connection established") + + var connection: SSHClient! + SSHClient.dial(SSHClientConfig.testHost, with: .testConfig) + .sink( + receiveCompletion: { _ in }, + receiveValue: { conn in + connection = conn + expectConn.fulfill() + }).store(in: &cancellableBag) + + wait(for: [expectConn], timeout: 5) + + print("connected") + + let expectBootstrap = self.expectation(description: "Mosh bootstrapped") + + InstallStaticMosh(promptUser: false) + .start(on: connection) + .sink( + receiveCompletion: { _ in }, + receiveValue: { moshServerPath in + print("Mosh server path at: \(moshServerPath)") + expectBootstrap.fulfill() + } + ).store(in: &cancellableBag) + + wait(for: [expectBootstrap], timeout: 30) + } + + func testMoshDownloadBinaries() throws { + let moshBootstrap = InstallStaticMosh(promptUser: false) + + moshBootstrap.getMoshServerBinary(platform: .Darwin, architecture: .X86_64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Darwin, architecture: .Arm64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Linux, architecture: .Amd64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Linux, architecture: .Arm64) + .assertNoFailure() + .sink(test: self) + + moshBootstrap.getMoshServerBinary(platform: .Linux, architecture: .Armv7) + .assertNoFailure() + .sink(test: self) + } +} + +// TODO Test getting parameters from expected Mosh output. Important in case we find weird cases, but not critical. +// TODO - We could test the Bootstrap request flow, separating it to a different object. Complicated and not sure what extra insight we would get from it. +// TODO Test configurations from .ssh/config + parameters. How? This will have to go to the QA instructions. + +extension SSHClientConfig { + static let testHost = "localhost" + static let testConfig = SSHClientConfig( + user: "asdf", + port: "22", + authMethods: [AuthPassword(with: "")], + loggingVerbosity: .debug + ) +} diff --git a/Resources/blinkCommandsDictionary.plist b/Resources/blinkCommandsDictionary.plist index 1a608cac0..995fca774 100644 --- a/Resources/blinkCommandsDictionary.plist +++ b/Resources/blinkCommandsDictionary.plist @@ -2,6 +2,13 @@ + mosh2 + + MAIN + blink_mosh_main + + no + skstore MAIN diff --git a/SSH/SFTP.swift b/SSH/SFTP.swift index e6c7d968a..d36422093 100644 --- a/SSH/SFTP.swift +++ b/SSH/SFTP.swift @@ -297,7 +297,7 @@ public class SFTPTranslator: BlinkFiles.Translator { // Mode uses same default as mkdir // This is working well for filesystems, but everything else... - public func mkdir(name: String, mode: mode_t = S_IRWXU | S_IRWXG | S_IRWXO) -> AnyPublisher { + public func mkdir(name: String, mode: mode_t) -> AnyPublisher { return connection().tryMap { sftp -> Translator in ssh_channel_set_blocking(self.channel, 1) defer { ssh_channel_set_blocking(self.channel, 0) } diff --git a/SSH/SSHClient.swift b/SSH/SSHClient.swift index 8ca897bdd..58163b369 100644 --- a/SSH/SSHClient.swift +++ b/SSH/SSHClient.swift @@ -55,7 +55,7 @@ func ssh_init_channel_callbacks(_ cb: inout ssh_channel_callbacks_struct) { public class SSHClient { let session: ssh_session public let host: String - let options: SSHClientConfig + public let options: SSHClientConfig let log: SSHLogger public typealias ExecProxyCommandCallback = (String, Int32, Int32) -> Void diff --git a/SSH/SSHClientConfig.swift b/SSH/SSHClientConfig.swift index 9bf53e1a0..572d90e34 100644 --- a/SSH/SSHClientConfig.swift +++ b/SSH/SSHClientConfig.swift @@ -75,8 +75,8 @@ public enum VerifyHost { } public struct SSHClientConfig: CustomStringConvertible, Equatable { - let user: String - let port: String + public let user: String + public let port: String public typealias RequestVerifyHostCallback = (VerifyHost) -> AnyPublisher diff --git a/SSH/Streams.swift b/SSH/Streams.swift index a145b5d01..bdbb40e37 100644 --- a/SSH/Streams.swift +++ b/SSH/Streams.swift @@ -161,6 +161,11 @@ public class Stream : Reader, Writer, WriterTo { return outstream.read(max: length) } + public func read_err(max length: Int) -> AnyPublisher { + let errstream = OutStream(self, isStderr: true) + return errstream.read(max: length) + } + public func write(_ buf: DispatchData, max length: Int) -> AnyPublisher { let instream = InStream(self) return instream.write(buf, max: length) diff --git a/Sessions/MCPSession.m b/Sessions/MCPSession.m index 15a132c5e..e87337d42 100644 --- a/Sessions/MCPSession.m +++ b/Sessions/MCPSession.m @@ -99,9 +99,22 @@ - (void)executeWithArgs:(NSString *)args { thread_stderr = nil; ios_setStreams(_stream.in, _stream.out, _stream.err); - + // We are restoring mosh session if possible first. + // TODO Restore BlinkMosh if ([@"mosh" isEqualToString:self.sessionParams.childSessionType] && self.sessionParams.hasEncodedState) { + BlinkMosh *mosh = [[BlinkMosh alloc] initWithMcpSession: self device:_device andParams:self.sessionParams.childSessionParams]; + // MoshSession *mosh = [[MoshSession alloc] initWithDevice:_device andParams:self.sessionParams.childSessionParams]; + // mosh.mcpSession = self; + _childSession = mosh; + [_childSession executeAttachedWithArgs:@""]; + _childSession = nil; + if (self.sessionParams.hasEncodedState) { + return; + } + } + if ([@"mosh2" isEqualToString:self.sessionParams.childSessionType] && self.sessionParams.hasEncodedState) { + //BlinkMosh *mosh = [[BlinkMosh alloc] initWithMcpSession: self device:_device andParams:self.sessionParams.childSessionParams]; MoshSession *mosh = [[MoshSession alloc] initWithDevice:_device andParams:self.sessionParams.childSessionParams]; mosh.mcpSession = self; _childSession = mosh; @@ -201,6 +214,11 @@ - (BOOL)_runCommand:(NSString *)cmdline skipHistoryRecord: (BOOL) skipHistoryRec if (self.sessionParams.hasEncodedState) { return NO; } + } else if ([cmd isEqualToString:@"mosh2"]) { + [self _runMosh2WithArgs:cmdline]; + if (self.sessionParams.hasEncodedState) { + return NO; + } } else if ([cmd isEqualToString:@"ssh2"]) { [self _runSSHWithArgs:cmdline]; } else if ([cmd isEqualToString:@"ssh-copy-id"]) { @@ -275,6 +293,25 @@ - (void)_runMoshWithArgs:(NSString *)args { self.sessionParams.childSessionParams = [[MoshParams alloc] init]; self.sessionParams.childSessionType = @"mosh"; + BlinkMosh *mosh = [[BlinkMosh alloc] initWithMcpSession: self device:_device andParams:self.sessionParams.childSessionParams]; + // TODO Connect previous mosh + //MoshSession *mosh = [[MoshSession alloc] initWithDevice:_device andParams:self.sessionParams.childSessionParams]; + //mosh.mcpSession = self; + _childSession = mosh; + + // duplicate args + NSString *str = [NSString stringWithFormat:@"%@", args]; + [_childSession executeAttachedWithArgs:str]; + + _childSession = nil; +} + +- (void)_runMosh2WithArgs:(NSString *)args +{ + self.sessionParams.childSessionParams = [[MoshParams alloc] init]; + self.sessionParams.childSessionType = @"mosh2"; + //BlinkMosh *mosh = [[BlinkMosh alloc] initWithMcpSession: self device:_device andParams:self.sessionParams.childSessionParams]; + // TODO Connect previous mosh MoshSession *mosh = [[MoshSession alloc] initWithDevice:_device andParams:self.sessionParams.childSessionParams]; mosh.mcpSession = self; _childSession = mosh; diff --git a/Sessions/MoshSession.m b/Sessions/MoshSession.m index 8ea83c40a..638bfd566 100644 --- a/Sessions/MoshSession.m +++ b/Sessions/MoshSession.m @@ -102,8 +102,7 @@ + (void)initialize @(BKMoshPredictionAdaptive): @"adaptive", @(BKMoshPredictionAlways): @"always", @(BKMoshPredictionNever): @"never", - @(BKMoshPredictionExperimental): @"experimental", - @(BKMoshPredictionUnknown): @"adaptive" + @(BKMoshPredictionExperimental): @"experimental" }; experimentalIPStrings = @{ diff --git a/Sessions/Session.h b/Sessions/Session.h index 9a2a0033f..e97f7e19f 100644 --- a/Sessions/Session.h +++ b/Sessions/Session.h @@ -52,6 +52,7 @@ @property (strong, atomic) SessionParams *sessionParams; @property (strong) TermStream *stream; @property (strong) TermDevice *device; +@property (readonly) pthread_t tid; @property (weak) id delegate; diff --git a/Sessions/SessionParams.swift b/Sessions/SessionParams.swift index 015035ee1..055d5e40c 100644 --- a/Sessions/SessionParams.swift +++ b/Sessions/SessionParams.swift @@ -117,6 +117,17 @@ import UIKit static var secureCoding2 = true override class var supportsSecureCoding: Bool { secureCoding2 } + + public func copy(from params: MoshParams) { + self.ip = params.ip + self.port = params.port + self.key = params.key + self.predictionMode = params.predictionMode + self.predictOverwrite = params.predictOverwrite + self.startupCmd = params.startupCmd + self.serverPath = params.serverPath + self.experimentalRemoteIp = params.experimentalRemoteIp + } } @objc class MCPParams: SessionParams {