mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 04:59:05 +08:00
update
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -330,3 +330,4 @@ setup_logs/
|
||||
/research/artifacts
|
||||
/research/xnu
|
||||
/vm
|
||||
/.tmp
|
||||
|
||||
@@ -15,6 +15,9 @@ let package = Package(
|
||||
.package(path: "vendor/libimg4-spm"),
|
||||
.package(path: "vendor/MachOKit"),
|
||||
.package(path: "vendor/AppleMobileDeviceLibrary"),
|
||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
|
||||
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"),
|
||||
.package(path: "vendor/swift-nio-ssh"),
|
||||
.package(path: "vendor/swift-subprocess"),
|
||||
.package(path: "vendor/swift-trustcache"),
|
||||
.package(path: "vendor/SWCompression"),
|
||||
@@ -60,8 +63,12 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Capstone", package: "libcapstone-spm"),
|
||||
.product(name: "Crypto", package: "swift-crypto"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
.product(name: "libirecovery", package: "AppleMobileDeviceLibrary"),
|
||||
.product(name: "NIOCore", package: "swift-nio"),
|
||||
.product(name: "NIOPosix", package: "swift-nio"),
|
||||
.product(name: "NIOSSH", package: "swift-nio-ssh"),
|
||||
.product(name: "SWCompression", package: "SWCompression"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "TrustCache", package: "swift-trustcache"),
|
||||
|
||||
@@ -26,15 +26,16 @@ struct CFWInstallCLI: AsyncParsableCommand {
|
||||
var sshPort: Int = Int(ProcessInfo.processInfo.environment["SSH_PORT"] ?? "2222") ?? 2222
|
||||
|
||||
@Flag(help: "Skip halting the ramdisk after install")
|
||||
var skipHalt: Bool = ProcessInfo.processInfo.environment["CFW_SKIP_HALT"] == "1"
|
||||
var skipHalt = false
|
||||
|
||||
mutating func run() async throws {
|
||||
let effectiveSkipHalt = skipHalt || ProcessInfo.processInfo.environment["CFW_SKIP_HALT"] == "1"
|
||||
let installer = try VPhoneCFWInstaller(
|
||||
vmDirectory: vmDirectory.standardizedFileURL,
|
||||
projectRoot: projectRoot.standardizedFileURL,
|
||||
variant: variant,
|
||||
sshPort: sshPort,
|
||||
skipHalt: skipHalt
|
||||
skipHalt: effectiveSkipHalt
|
||||
)
|
||||
try await installer.run()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import NIOSSH
|
||||
|
||||
struct VPhoneSSHCommandResult {
|
||||
let exitStatus: Int32
|
||||
@@ -16,14 +19,20 @@ struct VPhoneSSHCommandResult {
|
||||
|
||||
enum VPhoneSSHError: Error, CustomStringConvertible {
|
||||
case notConnected
|
||||
case invalidChannelType
|
||||
case commandFailed(String)
|
||||
case timeout(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .notConnected:
|
||||
return "SSH client is not connected"
|
||||
case .invalidChannelType:
|
||||
return "Invalid SSH channel type"
|
||||
case let .commandFailed(message):
|
||||
return message
|
||||
case let .timeout(context):
|
||||
return "SSH timeout: \(context)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +43,13 @@ final class VPhoneSSHClient: @unchecked Sendable {
|
||||
let username: String
|
||||
let password: String
|
||||
|
||||
private var connected = false
|
||||
private var controlPath: URL?
|
||||
private let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
private var channel: Channel?
|
||||
private var sshHandler: NIOSSHHandler?
|
||||
private var shutDown = false
|
||||
private var debugEnabled: Bool {
|
||||
ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1"
|
||||
}
|
||||
|
||||
init(host: String, port: Int, username: String, password: String) {
|
||||
self.host = host
|
||||
@@ -44,48 +58,132 @@ final class VPhoneSSHClient: @unchecked Sendable {
|
||||
self.password = password
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? shutdown()
|
||||
}
|
||||
|
||||
func connect() throws {
|
||||
if connected { return }
|
||||
let token = String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))
|
||||
let socketPath = URL(fileURLWithPath: "/tmp/vssh.\(token).sock")
|
||||
do {
|
||||
let result = try runProcess(
|
||||
executable: "/usr/bin/ssh",
|
||||
arguments: masterArguments(controlPath: socketPath),
|
||||
stdin: nil
|
||||
)
|
||||
guard result.exitStatus == 0 else {
|
||||
throw VPhoneSSHError.commandFailed(result.standardErrorString)
|
||||
guard channel == nil else { return }
|
||||
let connectionState = VPhoneSSHConnectionState(eventLoop: group.next())
|
||||
debug("connect begin host=\(host) port=\(port) user=\(username)")
|
||||
|
||||
let bootstrap = ClientBootstrap(group: group)
|
||||
.channelInitializer { [username, password] channel in
|
||||
let configuration = SSHClientConfiguration(
|
||||
userAuthDelegate: SimplePasswordDelegate(username: username, password: password),
|
||||
serverAuthDelegate: AcceptAllHostKeysDelegate(),
|
||||
transportProtectionSchemes: [
|
||||
AES256CTRHMACSHA256TransportProtection.self,
|
||||
AES128CTRHMACSHA256TransportProtection.self,
|
||||
AES256CTRHMACSHA1TransportProtection.self,
|
||||
AES128CTRHMACSHA1TransportProtection.self,
|
||||
]
|
||||
)
|
||||
let ssh = NIOSSHHandler(
|
||||
role: .client(configuration),
|
||||
allocator: channel.allocator,
|
||||
inboundChildChannelInitializer: nil
|
||||
)
|
||||
connectionState.sshHandler = ssh
|
||||
return channel.pipeline.addHandler(ssh).flatMap {
|
||||
channel.pipeline.addHandler(VPhoneSSHConnectionHandler(readyPromise: connectionState.readyPromise))
|
||||
}.flatMap {
|
||||
channel.pipeline.addHandler(VPhoneSSHErrorHandler())
|
||||
}
|
||||
}
|
||||
controlPath = socketPath
|
||||
connected = true
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: socketPath)
|
||||
throw error
|
||||
.connectTimeout(.seconds(5))
|
||||
.channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
|
||||
.channelOption(ChannelOptions.socket(SocketOptionLevel(IPPROTO_TCP), TCP_NODELAY), value: 1)
|
||||
|
||||
let connectedChannel = try waitForFuture(
|
||||
bootstrap.connect(host: host, port: port),
|
||||
timeout: .seconds(8),
|
||||
context: "tcp connect to \(host):\(port)"
|
||||
)
|
||||
debug("tcp connected")
|
||||
channel = connectedChannel
|
||||
_ = try waitForFuture(
|
||||
connectionState.readyPromise.futureResult,
|
||||
timeout: .seconds(8),
|
||||
context: "SSH authentication"
|
||||
)
|
||||
debug("ssh authenticated")
|
||||
guard let sshHandler = connectionState.sshHandler else {
|
||||
throw VPhoneSSHError.notConnected
|
||||
}
|
||||
self.sshHandler = sshHandler
|
||||
}
|
||||
|
||||
func shutdown() throws {
|
||||
if let controlPath {
|
||||
_ = try? runProcess(
|
||||
executable: "/usr/bin/ssh",
|
||||
arguments: controlArguments(controlPath: controlPath) + ["-O", "exit", "\(username)@\(host)"],
|
||||
stdin: nil
|
||||
)
|
||||
guard !shutDown else { return }
|
||||
shutDown = true
|
||||
if let channel {
|
||||
try? channel.close().wait()
|
||||
self.channel = nil
|
||||
self.sshHandler = nil
|
||||
}
|
||||
if let controlPath {
|
||||
try? FileManager.default.removeItem(at: controlPath)
|
||||
}
|
||||
controlPath = nil
|
||||
connected = false
|
||||
try group.syncShutdownGracefully()
|
||||
}
|
||||
|
||||
func execute(_ command: String, stdin: Data? = nil, requireSuccess: Bool = true) throws -> VPhoneSSHCommandResult {
|
||||
guard connected else {
|
||||
guard let channel, let sshHandler else {
|
||||
throw VPhoneSSHError.notConnected
|
||||
}
|
||||
debug("execute command=\(command)")
|
||||
|
||||
let result = try runSSH(command: command, stdin: stdin)
|
||||
let resultBox = VPhoneSSHCommandResultBox(eventLoop: channel.eventLoop)
|
||||
let childPromise = channel.eventLoop.makePromise(of: Channel.self)
|
||||
let createChannelPromise = channel.eventLoop.makePromise(of: Void.self)
|
||||
let commandTimeout: TimeAmount
|
||||
if let stdin, stdin.count > 1_000_000 {
|
||||
commandTimeout = .seconds(600)
|
||||
} else if stdin != nil {
|
||||
commandTimeout = .seconds(120)
|
||||
} else {
|
||||
commandTimeout = .seconds(30)
|
||||
}
|
||||
channel.eventLoop.execute {
|
||||
sshHandler.createChannel(childPromise) { childChannel, channelType in
|
||||
guard channelType == .session else {
|
||||
return channel.eventLoop.makeFailedFuture(VPhoneSSHError.invalidChannelType)
|
||||
}
|
||||
|
||||
return childChannel.pipeline.addHandler(
|
||||
VPhoneSSHExecHandler(command: command, stdinData: stdin, resultBox: resultBox)
|
||||
)
|
||||
}
|
||||
createChannelPromise.succeed(())
|
||||
}
|
||||
|
||||
childPromise.futureResult.whenFailure { error in
|
||||
resultBox.fail(error)
|
||||
}
|
||||
|
||||
let result: VPhoneSSHCommandResult
|
||||
do {
|
||||
_ = try waitForFuture(
|
||||
createChannelPromise.futureResult,
|
||||
timeout: .seconds(2),
|
||||
context: "schedule exec channel open"
|
||||
)
|
||||
debug("exec channel scheduled")
|
||||
let childChannel = try waitForFuture(
|
||||
childPromise.futureResult,
|
||||
timeout: .seconds(8),
|
||||
context: "open exec channel"
|
||||
)
|
||||
debug("exec channel active")
|
||||
result = try waitForFuture(
|
||||
resultBox.futureResult,
|
||||
timeout: commandTimeout,
|
||||
context: "command result"
|
||||
)
|
||||
debug("command result exit=\(result.exitStatus)")
|
||||
childChannel.close(promise: nil)
|
||||
} catch {
|
||||
resultBox.fail(error)
|
||||
throw error
|
||||
}
|
||||
if requireSuccess, result.exitStatus != 0 {
|
||||
let stderr = result.standardErrorString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw VPhoneSSHError.commandFailed(
|
||||
@@ -143,109 +241,6 @@ final class VPhoneSSHClient: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func runSSH(command: String, stdin: Data?) throws -> VPhoneSSHCommandResult {
|
||||
let result = try runProcess(
|
||||
executable: "/usr/bin/ssh",
|
||||
arguments: sshArguments(command: command),
|
||||
stdin: stdin
|
||||
)
|
||||
return VPhoneSSHCommandResult(
|
||||
exitStatus: result.exitStatus,
|
||||
standardOutput: result.standardOutput,
|
||||
standardError: result.standardError
|
||||
)
|
||||
}
|
||||
|
||||
private func sshArguments(command: String) -> [String] {
|
||||
var arguments = sshBaseArguments()
|
||||
if let controlPath {
|
||||
arguments.append(contentsOf: controlArguments(controlPath: controlPath))
|
||||
}
|
||||
arguments.append(contentsOf: [
|
||||
"-p", "\(port)",
|
||||
"\(username)@\(host)",
|
||||
command,
|
||||
])
|
||||
return arguments
|
||||
}
|
||||
|
||||
private func masterArguments(controlPath: URL) -> [String] {
|
||||
var arguments = sshBaseArguments()
|
||||
arguments.append(contentsOf: [
|
||||
"-M",
|
||||
"-S", controlPath.path,
|
||||
"-o", "ControlMaster=yes",
|
||||
"-o", "ControlPersist=yes",
|
||||
"-p", "\(port)",
|
||||
"-f",
|
||||
"-N",
|
||||
"\(username)@\(host)",
|
||||
])
|
||||
return arguments
|
||||
}
|
||||
|
||||
private func sshBaseArguments() -> [String] {
|
||||
[
|
||||
"-F", "/dev/null",
|
||||
"-T",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "GlobalKnownHostsFile=/dev/null",
|
||||
"-o", "UpdateHostKeys=no",
|
||||
"-o", "PreferredAuthentications=password,keyboard-interactive",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-o", "NumberOfPasswordPrompts=1",
|
||||
"-o", "ConnectTimeout=5",
|
||||
]
|
||||
}
|
||||
|
||||
private func controlArguments(controlPath: URL) -> [String] {
|
||||
["-S", controlPath.path, "-o", "ControlMaster=no"]
|
||||
}
|
||||
|
||||
private func runProcess(executable: String, arguments: [String], stdin: Data?) throws -> VPhoneSSHCommandResult {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
for (key, value) in VPhoneHost.sshAskpassEnvironment(password: password) {
|
||||
if let value {
|
||||
environment[key] = value
|
||||
} else {
|
||||
environment.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
process.environment = environment
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
if let stdin, !stdin.isEmpty {
|
||||
let stdinPipe = Pipe()
|
||||
process.standardInput = stdinPipe
|
||||
try process.run()
|
||||
stdinPipe.fileHandleForWriting.write(stdin)
|
||||
try stdinPipe.fileHandleForWriting.close()
|
||||
} else {
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
try process.run()
|
||||
}
|
||||
|
||||
let stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
return VPhoneSSHCommandResult(
|
||||
exitStatus: process.terminationStatus,
|
||||
standardOutput: stdout,
|
||||
standardError: stderr
|
||||
)
|
||||
}
|
||||
|
||||
private func shellQuote(_ string: String) -> String {
|
||||
"'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
@@ -312,4 +307,264 @@ final class VPhoneSSHClient: @unchecked Sendable {
|
||||
}
|
||||
return remotePath
|
||||
}
|
||||
|
||||
private func waitForFuture<Value>(
|
||||
_ future: EventLoopFuture<Value>,
|
||||
timeout: TimeAmount,
|
||||
context: String
|
||||
) throws -> Value {
|
||||
let promise = future.eventLoop.makePromise(of: Value.self)
|
||||
let scheduled = future.eventLoop.scheduleTask(in: timeout) {
|
||||
promise.fail(VPhoneSSHError.timeout(context))
|
||||
}
|
||||
future.whenComplete { result in
|
||||
scheduled.cancel()
|
||||
switch result {
|
||||
case let .success(value):
|
||||
promise.succeed(value)
|
||||
case let .failure(error):
|
||||
promise.fail(error)
|
||||
}
|
||||
}
|
||||
return try promise.futureResult.wait()
|
||||
}
|
||||
|
||||
private func debug(_ message: String) {
|
||||
guard debugEnabled else { return }
|
||||
FileHandle.standardError.write(Data("[ssh] \(message)\n".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private final class VPhoneSSHExecHandler: ChannelDuplexHandler, @unchecked Sendable {
|
||||
typealias InboundIn = SSHChannelData
|
||||
typealias InboundOut = SSHChannelData
|
||||
typealias OutboundIn = SSHChannelData
|
||||
typealias OutboundOut = SSHChannelData
|
||||
|
||||
let command: String
|
||||
let stdinData: Data?
|
||||
let resultBox: VPhoneSSHCommandResultBox
|
||||
let stdinChunkSize = 256 * 1024
|
||||
|
||||
var standardOutput = Data()
|
||||
var standardError = Data()
|
||||
var exitStatus: Int32 = 0
|
||||
var completed = false
|
||||
var stdinOffset = 0
|
||||
|
||||
init(command: String, stdinData: Data?, resultBox: VPhoneSSHCommandResultBox) {
|
||||
self.command = command
|
||||
self.stdinData = stdinData
|
||||
self.resultBox = resultBox
|
||||
}
|
||||
|
||||
func handlerAdded(context: ChannelHandlerContext) {
|
||||
let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
||||
context.channel.setOption(ChannelOptions.allowRemoteHalfClosure, value: true).whenFailure { error in
|
||||
self.fail(error, context: loopBoundContext.value)
|
||||
}
|
||||
}
|
||||
|
||||
func channelActive(context: ChannelHandlerContext) {
|
||||
let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
||||
if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" {
|
||||
FileHandle.standardError.write(Data("[ssh] exec handler active command=\(command)\n".utf8))
|
||||
}
|
||||
let request = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true)
|
||||
context.triggerUserOutboundEvent(request).whenComplete { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.sendStandardInput(context: loopBoundContext.value)
|
||||
case .failure(let error):
|
||||
self.fail(error, context: loopBoundContext.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
let message = unwrapInboundIn(data)
|
||||
guard case .byteBuffer(var bytes) = message.data,
|
||||
let chunk = bytes.readData(length: bytes.readableBytes)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
switch message.type {
|
||||
case .channel:
|
||||
standardOutput.append(chunk)
|
||||
case .stdErr:
|
||||
standardError.append(chunk)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
||||
if let exit = event as? SSHChannelRequestEvent.ExitStatus {
|
||||
exitStatus = Int32(exit.exitStatus)
|
||||
if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" {
|
||||
FileHandle.standardError.write(Data("[ssh] exit status=\(exit.exitStatus)\n".utf8))
|
||||
}
|
||||
} else {
|
||||
context.fireUserInboundEventTriggered(event)
|
||||
}
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" {
|
||||
FileHandle.standardError.write(Data("[ssh] exec handler inactive command=\(command)\n".utf8))
|
||||
}
|
||||
succeedIfNeeded()
|
||||
context.fireChannelInactive()
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
fail(error, context: context)
|
||||
}
|
||||
|
||||
private func sendStandardInput(context: ChannelHandlerContext) {
|
||||
guard let stdinData, !stdinData.isEmpty else {
|
||||
context.close(mode: .output, promise: nil)
|
||||
return
|
||||
}
|
||||
writeNextStandardInputChunk(context: context, stdinData: stdinData)
|
||||
}
|
||||
|
||||
private func writeNextStandardInputChunk(context: ChannelHandlerContext, stdinData: Data) {
|
||||
if stdinOffset >= stdinData.count {
|
||||
context.close(mode: .output, promise: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let nextOffset = min(stdinOffset + stdinChunkSize, stdinData.count)
|
||||
let chunk = stdinData[stdinOffset..<nextOffset]
|
||||
stdinOffset = nextOffset
|
||||
|
||||
var buffer = context.channel.allocator.buffer(capacity: chunk.count)
|
||||
buffer.writeBytes(chunk)
|
||||
let payload = SSHChannelData(type: .channel, data: .byteBuffer(buffer))
|
||||
let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
||||
context.writeAndFlush(wrapOutboundOut(payload)).whenComplete { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.writeNextStandardInputChunk(context: loopBoundContext.value, stdinData: stdinData)
|
||||
case .failure(let error):
|
||||
self.fail(error, context: loopBoundContext.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func succeedIfNeeded() {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
resultBox.succeed(
|
||||
VPhoneSSHCommandResult(
|
||||
exitStatus: exitStatus,
|
||||
standardOutput: standardOutput,
|
||||
standardError: standardError
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func fail(_ error: Error, context: ChannelHandlerContext) {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
resultBox.fail(error)
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class VPhoneSSHCommandResultBox: @unchecked Sendable {
|
||||
let futureResult: EventLoopFuture<VPhoneSSHCommandResult>
|
||||
|
||||
private let promise: EventLoopPromise<VPhoneSSHCommandResult>
|
||||
private let lock = NSLock()
|
||||
private var completed = false
|
||||
|
||||
init(eventLoop: EventLoop) {
|
||||
promise = eventLoop.makePromise(of: VPhoneSSHCommandResult.self)
|
||||
futureResult = promise.futureResult
|
||||
}
|
||||
|
||||
func succeed(_ value: VPhoneSSHCommandResult) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
promise.succeed(value)
|
||||
}
|
||||
|
||||
func fail(_ error: Error) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
promise.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
private final class VPhoneSSHConnectionState: @unchecked Sendable {
|
||||
let readyPromise: EventLoopPromise<Void>
|
||||
var sshHandler: NIOSSHHandler?
|
||||
|
||||
init(eventLoop: EventLoop) {
|
||||
readyPromise = eventLoop.makePromise(of: Void.self)
|
||||
}
|
||||
}
|
||||
|
||||
private final class VPhoneSSHConnectionHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = Any
|
||||
|
||||
let readyPromise: EventLoopPromise<Void>
|
||||
var completed = false
|
||||
|
||||
init(readyPromise: EventLoopPromise<Void>) {
|
||||
self.readyPromise = readyPromise
|
||||
}
|
||||
|
||||
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
||||
if event is UserAuthSuccessEvent {
|
||||
if ProcessInfo.processInfo.environment["VPHONE_SSH_DEBUG"] == "1" {
|
||||
FileHandle.standardError.write(Data("[ssh] auth success event\n".utf8))
|
||||
}
|
||||
succeedIfNeeded()
|
||||
}
|
||||
context.fireUserInboundEventTriggered(event)
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
failIfNeeded(error)
|
||||
context.fireErrorCaught(error)
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
failIfNeeded(VPhoneSSHError.notConnected)
|
||||
context.fireChannelInactive()
|
||||
}
|
||||
|
||||
private func succeedIfNeeded() {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
readyPromise.succeed(())
|
||||
}
|
||||
|
||||
private func failIfNeeded(_ error: Error) {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
readyPromise.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
private final class AcceptAllHostKeysDelegate: NIOSSHClientServerAuthenticationDelegate {
|
||||
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
|
||||
validationCompletePromise.succeed(())
|
||||
}
|
||||
}
|
||||
|
||||
private final class VPhoneSSHErrorHandler: ChannelInboundHandler {
|
||||
typealias InboundIn = Any
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
|
||||
350
sources/vphone-cli/VPhoneSSHTransport.swift
Normal file
350
sources/vphone-cli/VPhoneSSHTransport.swift
Normal file
@@ -0,0 +1,350 @@
|
||||
import CommonCrypto
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
enum VPhoneSSHTransportError: Error, CustomStringConvertible {
|
||||
case invalidKeySize(expected: Int, actual: Int)
|
||||
case invalidIVSize(expected: Int, actual: Int)
|
||||
case cryptorCreateFailed(CCCryptorStatus)
|
||||
case cryptorUpdateFailed(CCCryptorStatus)
|
||||
case invalidPacket
|
||||
case invalidMAC
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case let .invalidKeySize(expected, actual):
|
||||
return "invalid SSH key size: expected \(expected), got \(actual)"
|
||||
case let .invalidIVSize(expected, actual):
|
||||
return "invalid SSH IV size: expected \(expected), got \(actual)"
|
||||
case let .cryptorCreateFailed(status):
|
||||
return "failed to create SSH AES-CTR cryptor: \(status)"
|
||||
case let .cryptorUpdateFailed(status):
|
||||
return "failed to update SSH AES-CTR cryptor: \(status)"
|
||||
case .invalidPacket:
|
||||
return "invalid SSH packet"
|
||||
case .invalidMAC:
|
||||
return "invalid SSH packet MAC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AESCTRTransportProtectionBase: NIOSSHTransportProtection {
|
||||
class var cipherName: String { fatalError("Override cipherName") }
|
||||
class var macName: String? { fatalError("Override macName") }
|
||||
class var keySizes: ExpectedKeySizes { fatalError("Override keySizes") }
|
||||
class var macAlgorithm: CCHmacAlgorithm { fatalError("Override macAlgorithm") }
|
||||
class var macLength: Int { fatalError("Override macLength") }
|
||||
|
||||
static var cipherBlockSize: Int { 16 }
|
||||
|
||||
var macBytes: Int { Self.macLength }
|
||||
var lengthEncrypted: Bool { true }
|
||||
|
||||
private var inboundCipher: AESCTRStreamCipher
|
||||
private var outboundCipher: AESCTRStreamCipher
|
||||
private var inboundMACKey: Data
|
||||
private var outboundMACKey: Data
|
||||
|
||||
required init(initialKeys: NIOSSHSessionKeys) throws {
|
||||
let configured = try Self.makeState(from: initialKeys)
|
||||
self.inboundCipher = configured.inboundCipher
|
||||
self.outboundCipher = configured.outboundCipher
|
||||
self.inboundMACKey = configured.inboundMACKey
|
||||
self.outboundMACKey = configured.outboundMACKey
|
||||
}
|
||||
|
||||
func updateKeys(_ newKeys: NIOSSHSessionKeys) throws {
|
||||
let configured = try Self.makeState(from: newKeys)
|
||||
self.inboundCipher = configured.inboundCipher
|
||||
self.outboundCipher = configured.outboundCipher
|
||||
self.inboundMACKey = configured.inboundMACKey
|
||||
self.outboundMACKey = configured.outboundMACKey
|
||||
}
|
||||
|
||||
func decryptFirstBlock(_ source: inout ByteBuffer) throws {
|
||||
let start = source.readerIndex
|
||||
guard let encrypted = source.getBytes(at: start, length: Self.cipherBlockSize) else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
let plaintext = try inboundCipher.update(encrypted)
|
||||
source.setBytes(plaintext, at: start)
|
||||
}
|
||||
|
||||
func decryptAndVerifyRemainingPacket(_ source: inout ByteBuffer, sequenceNumber: UInt32) throws -> ByteBuffer {
|
||||
let packetByteCount = source.readableBytes - Self.macLength
|
||||
guard packetByteCount >= Self.cipherBlockSize else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
|
||||
let remainingCiphertextCount = packetByteCount - Self.cipherBlockSize
|
||||
if remainingCiphertextCount > 0 {
|
||||
let offset = source.readerIndex + Self.cipherBlockSize
|
||||
guard let encrypted = source.getBytes(at: offset, length: remainingCiphertextCount) else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
let plaintext = try inboundCipher.update(encrypted)
|
||||
source.setBytes(plaintext, at: offset)
|
||||
}
|
||||
|
||||
let packetStart = source.readerIndex
|
||||
guard let plaintextPacket = source.getBytes(at: packetStart, length: packetByteCount),
|
||||
let receivedMAC = source.getBytes(at: packetStart + packetByteCount, length: Self.macLength)
|
||||
else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
|
||||
let expectedMAC = Self.computeMAC(
|
||||
key: inboundMACKey,
|
||||
sequenceNumber: sequenceNumber,
|
||||
packet: plaintextPacket
|
||||
)
|
||||
guard Self.constantTimeEqual(expectedMAC, receivedMAC) else {
|
||||
throw VPhoneSSHTransportError.invalidMAC
|
||||
}
|
||||
|
||||
guard var packetBuffer = source.readSlice(length: packetByteCount) else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
source.moveReaderIndex(forwardBy: Self.macLength)
|
||||
return try Self.payloadBuffer(fromPlaintextPacket: &packetBuffer)
|
||||
}
|
||||
|
||||
func encryptPacket(_ destination: inout ByteBuffer, sequenceNumber: UInt32) throws {
|
||||
let packetStart = destination.readerIndex
|
||||
let packetByteCount = destination.readableBytes
|
||||
guard let plaintextPacket = destination.getBytes(at: packetStart, length: packetByteCount) else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
|
||||
let mac = Self.computeMAC(
|
||||
key: outboundMACKey,
|
||||
sequenceNumber: sequenceNumber,
|
||||
packet: plaintextPacket
|
||||
)
|
||||
let ciphertext = try outboundCipher.update(plaintextPacket)
|
||||
destination.setBytes(ciphertext, at: packetStart)
|
||||
destination.writeBytes(mac)
|
||||
}
|
||||
|
||||
private static func makeState(
|
||||
from keys: NIOSSHSessionKeys
|
||||
) throws -> (
|
||||
inboundCipher: AESCTRStreamCipher,
|
||||
outboundCipher: AESCTRStreamCipher,
|
||||
inboundMACKey: Data,
|
||||
outboundMACKey: Data
|
||||
) {
|
||||
let inboundEncryptionKey = Data(keys.inboundEncryptionKey.withUnsafeBytes { Array($0) })
|
||||
let outboundEncryptionKey = Data(keys.outboundEncryptionKey.withUnsafeBytes { Array($0) })
|
||||
let inboundMACKey = Data(keys.inboundMACKey.withUnsafeBytes { Array($0) })
|
||||
let outboundMACKey = Data(keys.outboundMACKey.withUnsafeBytes { Array($0) })
|
||||
|
||||
guard inboundEncryptionKey.count == Self.keySizes.encryptionKeySize else {
|
||||
throw VPhoneSSHTransportError.invalidKeySize(
|
||||
expected: Self.keySizes.encryptionKeySize,
|
||||
actual: inboundEncryptionKey.count
|
||||
)
|
||||
}
|
||||
guard outboundEncryptionKey.count == Self.keySizes.encryptionKeySize else {
|
||||
throw VPhoneSSHTransportError.invalidKeySize(
|
||||
expected: Self.keySizes.encryptionKeySize,
|
||||
actual: outboundEncryptionKey.count
|
||||
)
|
||||
}
|
||||
guard inboundMACKey.count == Self.keySizes.macKeySize else {
|
||||
throw VPhoneSSHTransportError.invalidKeySize(
|
||||
expected: Self.keySizes.macKeySize,
|
||||
actual: inboundMACKey.count
|
||||
)
|
||||
}
|
||||
guard outboundMACKey.count == Self.keySizes.macKeySize else {
|
||||
throw VPhoneSSHTransportError.invalidKeySize(
|
||||
expected: Self.keySizes.macKeySize,
|
||||
actual: outboundMACKey.count
|
||||
)
|
||||
}
|
||||
guard keys.initialInboundIV.count == Self.keySizes.ivSize else {
|
||||
throw VPhoneSSHTransportError.invalidIVSize(
|
||||
expected: Self.keySizes.ivSize,
|
||||
actual: keys.initialInboundIV.count
|
||||
)
|
||||
}
|
||||
guard keys.initialOutboundIV.count == Self.keySizes.ivSize else {
|
||||
throw VPhoneSSHTransportError.invalidIVSize(
|
||||
expected: Self.keySizes.ivSize,
|
||||
actual: keys.initialOutboundIV.count
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
inboundCipher: try AESCTRStreamCipher(key: inboundEncryptionKey, iv: keys.initialInboundIV),
|
||||
outboundCipher: try AESCTRStreamCipher(key: outboundEncryptionKey, iv: keys.initialOutboundIV),
|
||||
inboundMACKey: inboundMACKey,
|
||||
outboundMACKey: outboundMACKey
|
||||
)
|
||||
}
|
||||
|
||||
private static func computeMAC(key: Data, sequenceNumber: UInt32, packet: [UInt8]) -> [UInt8] {
|
||||
var sequenceNumber = sequenceNumber.bigEndian
|
||||
var mac = [UInt8](repeating: 0, count: Self.macLength)
|
||||
|
||||
key.withUnsafeBytes { keyBuffer in
|
||||
withUnsafeBytes(of: &sequenceNumber) { sequenceBuffer in
|
||||
packet.withUnsafeBytes { packetBuffer in
|
||||
CCHmacInitBuffer(Self.macAlgorithm, keyBuffer.baseAddress, keyBuffer.count) { context in
|
||||
CCHmacUpdate(context, sequenceBuffer.baseAddress, sequenceBuffer.count)
|
||||
CCHmacUpdate(context, packetBuffer.baseAddress, packetBuffer.count)
|
||||
mac.withUnsafeMutableBytes { outputBuffer in
|
||||
CCHmacFinal(context, outputBuffer.baseAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mac
|
||||
}
|
||||
|
||||
private static func constantTimeEqual(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool {
|
||||
guard lhs.count == rhs.count else { return false }
|
||||
var diff: UInt8 = 0
|
||||
for index in lhs.indices {
|
||||
diff |= lhs[index] ^ rhs[index]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
private static func payloadBuffer(fromPlaintextPacket packetBuffer: inout ByteBuffer) throws -> ByteBuffer {
|
||||
packetBuffer.moveReaderIndex(forwardBy: MemoryLayout<UInt32>.size)
|
||||
guard let paddingLength = packetBuffer.readInteger(as: UInt8.self) else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
let payloadLength = packetBuffer.readableBytes - Int(paddingLength)
|
||||
guard payloadLength >= 0,
|
||||
let payload = packetBuffer.readSlice(length: payloadLength),
|
||||
packetBuffer.readerIndex + Int(paddingLength) == packetBuffer.writerIndex
|
||||
else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
packetBuffer.moveReaderIndex(forwardBy: Int(paddingLength))
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
final class AES128CTRHMACSHA256TransportProtection: AESCTRTransportProtectionBase {
|
||||
override class var cipherName: String { "aes128-ctr" }
|
||||
override class var macName: String? { "hmac-sha2-256" }
|
||||
override class var keySizes: ExpectedKeySizes {
|
||||
.init(ivSize: 16, encryptionKeySize: 16, macKeySize: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
}
|
||||
override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA256) }
|
||||
override class var macLength: Int { Int(CC_SHA256_DIGEST_LENGTH) }
|
||||
}
|
||||
|
||||
final class AES256CTRHMACSHA256TransportProtection: AESCTRTransportProtectionBase {
|
||||
override class var cipherName: String { "aes256-ctr" }
|
||||
override class var macName: String? { "hmac-sha2-256" }
|
||||
override class var keySizes: ExpectedKeySizes {
|
||||
.init(ivSize: 16, encryptionKeySize: 32, macKeySize: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
}
|
||||
override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA256) }
|
||||
override class var macLength: Int { Int(CC_SHA256_DIGEST_LENGTH) }
|
||||
}
|
||||
|
||||
final class AES128CTRHMACSHA1TransportProtection: AESCTRTransportProtectionBase {
|
||||
override class var cipherName: String { "aes128-ctr" }
|
||||
override class var macName: String? { "hmac-sha1" }
|
||||
override class var keySizes: ExpectedKeySizes {
|
||||
.init(ivSize: 16, encryptionKeySize: 16, macKeySize: Int(CC_SHA1_DIGEST_LENGTH))
|
||||
}
|
||||
override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA1) }
|
||||
override class var macLength: Int { Int(CC_SHA1_DIGEST_LENGTH) }
|
||||
}
|
||||
|
||||
final class AES256CTRHMACSHA1TransportProtection: AESCTRTransportProtectionBase {
|
||||
override class var cipherName: String { "aes256-ctr" }
|
||||
override class var macName: String? { "hmac-sha1" }
|
||||
override class var keySizes: ExpectedKeySizes {
|
||||
.init(ivSize: 16, encryptionKeySize: 32, macKeySize: Int(CC_SHA1_DIGEST_LENGTH))
|
||||
}
|
||||
override class var macAlgorithm: CCHmacAlgorithm { CCHmacAlgorithm(kCCHmacAlgSHA1) }
|
||||
override class var macLength: Int { Int(CC_SHA1_DIGEST_LENGTH) }
|
||||
}
|
||||
|
||||
final class AESCTRStreamCipher {
|
||||
private var cryptor: CCCryptorRef?
|
||||
|
||||
init(key: Data, iv: [UInt8]) throws {
|
||||
var cryptor: CCCryptorRef?
|
||||
let status = key.withUnsafeBytes { keyBuffer in
|
||||
iv.withUnsafeBytes { ivBuffer in
|
||||
CCCryptorCreateWithMode(
|
||||
CCOperation(kCCEncrypt),
|
||||
CCMode(kCCModeCTR),
|
||||
CCAlgorithm(kCCAlgorithmAES),
|
||||
CCPadding(ccNoPadding),
|
||||
ivBuffer.baseAddress,
|
||||
keyBuffer.baseAddress,
|
||||
key.count,
|
||||
nil,
|
||||
0,
|
||||
0,
|
||||
CCModeOptions(kCCModeOptionCTR_BE),
|
||||
&cryptor
|
||||
)
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess, let cryptor else {
|
||||
throw VPhoneSSHTransportError.cryptorCreateFailed(status)
|
||||
}
|
||||
self.cryptor = cryptor
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let cryptor {
|
||||
CCCryptorRelease(cryptor)
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ input: [UInt8]) throws -> [UInt8] {
|
||||
guard let cryptor else {
|
||||
throw VPhoneSSHTransportError.invalidPacket
|
||||
}
|
||||
if input.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
let outputCount = input.count
|
||||
var output = [UInt8](repeating: 0, count: outputCount)
|
||||
var bytesMoved = 0
|
||||
let status = input.withUnsafeBytes { inputBuffer in
|
||||
output.withUnsafeMutableBytes { outputBuffer in
|
||||
CCCryptorUpdate(
|
||||
cryptor,
|
||||
inputBuffer.baseAddress,
|
||||
input.count,
|
||||
outputBuffer.baseAddress,
|
||||
outputCount,
|
||||
&bytesMoved
|
||||
)
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess, bytesMoved == input.count else {
|
||||
throw VPhoneSSHTransportError.cryptorUpdateFailed(status)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private func CCHmacInitBuffer(
|
||||
_ algorithm: CCHmacAlgorithm,
|
||||
_ key: UnsafeRawPointer?,
|
||||
_ keyLength: Int,
|
||||
body: (UnsafeMutablePointer<CCHmacContext>) -> Void
|
||||
) {
|
||||
var context = CCHmacContext()
|
||||
CCHmacInit(&context, algorithm, key, keyLength)
|
||||
body(&context)
|
||||
}
|
||||
@@ -56,16 +56,13 @@ struct SetupMachineCLI: AsyncParsableCommand {
|
||||
}()
|
||||
|
||||
@Flag(help: "Skip setup_tools/build stage")
|
||||
var skipProjectSetup: Bool = {
|
||||
["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["SKIP_PROJECT_SETUP"]?.lowercased() ?? "")
|
||||
}()
|
||||
var skipProjectSetup = false
|
||||
|
||||
@Flag(help: "Auto-continue first boot and final boot analysis")
|
||||
var nonInteractive: Bool = {
|
||||
["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["NONE_INTERACTIVE"]?.lowercased() ?? "")
|
||||
}()
|
||||
var nonInteractive = false
|
||||
|
||||
mutating func run() async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let runner = try SetupMachineRunner(
|
||||
projectRoot: projectRoot.standardizedFileURL,
|
||||
vmDirectoryName: vmDirectory,
|
||||
@@ -79,11 +76,15 @@ struct SetupMachineCLI: AsyncParsableCommand {
|
||||
cloudOSSource: cloudOSSource,
|
||||
ipswDirectory: ipswDirectory?.standardizedFileURL,
|
||||
variant: variant,
|
||||
skipProjectSetup: skipProjectSetup,
|
||||
nonInteractive: nonInteractive
|
||||
skipProjectSetup: skipProjectSetup || Self.boolEnvironmentValue("SKIP_PROJECT_SETUP", environment: environment),
|
||||
nonInteractive: nonInteractive || Self.boolEnvironmentValue("NONE_INTERACTIVE", environment: environment)
|
||||
)
|
||||
try await runner.run()
|
||||
}
|
||||
|
||||
static func boolEnvironmentValue(_ key: String, environment: [String: String]) -> Bool {
|
||||
["1", "true", "yes"].contains(environment[key]?.lowercased() ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private struct SetupMachineRunner {
|
||||
|
||||
Reference in New Issue
Block a user