remote: support SPICE TLS
This commit is contained in:
parent
dbdf7498bf
commit
ea958e66d4
|
@ -71,6 +71,16 @@ import Virtualization // for getting network interfaces
|
|||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qga")
|
||||
}
|
||||
|
||||
/// Used only if in remote sever mode.
|
||||
var spiceTlsKeyUrl: URL {
|
||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("pem")
|
||||
}
|
||||
|
||||
/// Used only if in remote sever mode.
|
||||
var spiceTlsCertUrl: URL {
|
||||
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("crt")
|
||||
}
|
||||
|
||||
/// Combined generated and user specified arguments.
|
||||
@QEMUArgumentBuilder var allArguments: [QEMUArgument] {
|
||||
generatedArguments
|
||||
|
@ -120,15 +130,30 @@ import Virtualization // for getting network interfaces
|
|||
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
|
||||
f("-spice")
|
||||
if let port = qemu.spiceServerPort {
|
||||
"port=\(port)"
|
||||
if qemu.isSpiceServerTlsEnabled {
|
||||
"tls-port=\(port)"
|
||||
"tls-channel=default"
|
||||
"x509-key-file="
|
||||
spiceTlsKeyUrl
|
||||
"x509-cert-file="
|
||||
spiceTlsCertUrl
|
||||
"x509-cacert-file="
|
||||
spiceTlsCertUrl
|
||||
} else {
|
||||
"port=\(port)"
|
||||
}
|
||||
} else {
|
||||
"unix=on"
|
||||
"addr=\(spiceSocketURL.lastPathComponent)"
|
||||
}
|
||||
"disable-ticketing=on"
|
||||
"image-compression=off"
|
||||
"playback-compression=off"
|
||||
"streaming-video=off"
|
||||
if !isRemoteSpice {
|
||||
"image-compression=off"
|
||||
"playback-compression=off"
|
||||
"streaming-video=off"
|
||||
} else {
|
||||
"streaming-video=filter"
|
||||
}
|
||||
"gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
|
||||
f()
|
||||
f("-chardev")
|
||||
|
|
|
@ -72,6 +72,12 @@ struct UTMQemuConfigurationQEMU: Codable {
|
|||
/// Set to open a port for remote SPICE session. Not saved.
|
||||
var spiceServerPort: UInt16?
|
||||
|
||||
/// If true, all SPICE channels will be over TLS. Not saved.
|
||||
var isSpiceServerTlsEnabled: Bool = false
|
||||
|
||||
/// Set to TLS public key for SPICE server in SubjectPublicKey. Not saved.
|
||||
var spiceServerPublicKey: Data?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasDebugLog = "DebugLog"
|
||||
case hasUefiBoot = "UEFIBoot"
|
||||
|
|
|
@ -82,7 +82,7 @@ extension UTMData {
|
|||
/// - options: Start options
|
||||
/// - server: Remote server
|
||||
/// - Returns: Port number to SPICE server
|
||||
func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> UInt16 {
|
||||
func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> (port: UInt16, publicKey: Data) {
|
||||
guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else {
|
||||
throw UTMDataError.unsupportedBackend
|
||||
}
|
||||
|
@ -94,7 +94,7 @@ extension UTMData {
|
|||
}
|
||||
try await wrapped.start(options: options.union(.remoteSession))
|
||||
vmWindows[vm] = session
|
||||
return wrapped.config.qemu.spiceServerPort!
|
||||
return (wrapped.config.qemu.spiceServerPort!, wrapped.config.qemu.spiceServerPublicKey!)
|
||||
}
|
||||
|
||||
func stop(vm: VMData) {
|
||||
|
|
|
@ -132,7 +132,95 @@ err:
|
|||
return 0;
|
||||
}
|
||||
|
||||
_Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
|
||||
static _Nullable CFDataRef CreateP12FromKey(EVP_PKEY *pkey, X509 *cert) {
|
||||
PKCS12 *p12;
|
||||
BIO *mem;
|
||||
char *ptr;
|
||||
long length;
|
||||
CFDataRef data;
|
||||
|
||||
p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
|
||||
if (!p12) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
return NULL;
|
||||
}
|
||||
mem = BIO_new(BIO_s_mem());
|
||||
if (!mem || !i2d_PKCS12_bio(mem, p12)) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
PKCS12_free(p12);
|
||||
BIO_free(mem);
|
||||
return NULL;
|
||||
}
|
||||
PKCS12_free(p12);
|
||||
length = BIO_get_mem_data(mem, &ptr);
|
||||
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
|
||||
BIO_free(mem);
|
||||
return data;
|
||||
}
|
||||
|
||||
static _Nullable CFDataRef CreatePrivatePEMFromKey(EVP_PKEY *pkey) {
|
||||
BIO *mem;
|
||||
char *ptr;
|
||||
long length;
|
||||
CFDataRef data;
|
||||
|
||||
mem = BIO_new(BIO_s_mem());
|
||||
if (!mem || !PEM_write_bio_PrivateKey(mem, pkey, NULL, NULL, 0, NULL, NULL)) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
BIO_free(mem);
|
||||
return NULL;
|
||||
}
|
||||
length = BIO_get_mem_data(mem, &ptr);
|
||||
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
|
||||
BIO_free(mem);
|
||||
return data;
|
||||
}
|
||||
|
||||
static _Nullable CFDataRef CreatePublicPEMFromCert(X509 *cert) {
|
||||
BIO *mem;
|
||||
char *ptr;
|
||||
long length;
|
||||
CFDataRef data;
|
||||
|
||||
mem = BIO_new(BIO_s_mem());
|
||||
if (!mem || !PEM_write_bio_X509(mem, cert)) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
BIO_free(mem);
|
||||
return NULL;
|
||||
}
|
||||
length = BIO_get_mem_data(mem, &ptr);
|
||||
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
|
||||
BIO_free(mem);
|
||||
return data;
|
||||
}
|
||||
|
||||
static _Nullable CFDataRef CreatePublicKeyFromCert(X509 *cert) {
|
||||
EVP_PKEY* pubkey;
|
||||
BIO *mem;
|
||||
char *ptr;
|
||||
long length;
|
||||
CFDataRef data;
|
||||
|
||||
pubkey = X509_get_pubkey(cert);
|
||||
if (!pubkey) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
return NULL;
|
||||
}
|
||||
mem = BIO_new(BIO_s_mem());
|
||||
if (!mem || !i2d_PUBKEY_bio(mem, pubkey)) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
EVP_PKEY_free(pubkey);
|
||||
BIO_free(mem);
|
||||
return NULL;
|
||||
}
|
||||
length = BIO_get_mem_data(mem, &ptr);
|
||||
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
|
||||
BIO_free(mem);
|
||||
EVP_PKEY_free(pubkey);
|
||||
return data;
|
||||
}
|
||||
|
||||
_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
|
||||
char _commonName[X509_ENTRY_MAX_LENGTH];
|
||||
char _organizationName[X509_ENTRY_MAX_LENGTH];
|
||||
long _serial = 0;
|
||||
|
@ -140,11 +228,8 @@ _Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFSt
|
|||
int _isClient = 0;
|
||||
X509 *cert;
|
||||
EVP_PKEY *pkey;
|
||||
PKCS12 *p12;
|
||||
BIO *mem;
|
||||
char *ptr;
|
||||
long length;
|
||||
CFDataRef data;
|
||||
CFDataRef arr[4] = {NULL};
|
||||
CFArrayRef cfarr = NULL;
|
||||
|
||||
if (!CFStringGetCString(commonName, _commonName, X509_ENTRY_MAX_LENGTH, kCFStringEncodingUTF8)) {
|
||||
return NULL;
|
||||
|
@ -166,22 +251,26 @@ _Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFSt
|
|||
ERR_print_errors_fp(stderr);
|
||||
return NULL;
|
||||
}
|
||||
p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
|
||||
arr[0] = CreateP12FromKey(pkey, cert);
|
||||
arr[1] = CreatePrivatePEMFromKey(pkey);
|
||||
arr[2] = CreatePublicPEMFromCert(cert);
|
||||
arr[3] = CreatePublicKeyFromCert(cert);
|
||||
if (arr[0] && arr[1] && arr[2] && arr[3]) {
|
||||
cfarr = CFArrayCreate(kCFAllocatorDefault, (const void **)arr, 4, &kCFTypeArrayCallBacks);
|
||||
}
|
||||
if (arr[0]) {
|
||||
CFRelease(arr[0]);
|
||||
}
|
||||
if (arr[1]) {
|
||||
CFRelease(arr[1]);
|
||||
}
|
||||
if (arr[2]) {
|
||||
CFRelease(arr[2]);
|
||||
}
|
||||
if (arr[3]) {
|
||||
CFRelease(arr[3]);
|
||||
}
|
||||
EVP_PKEY_free(pkey);
|
||||
X509_free(cert);
|
||||
if (!p12) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
return NULL;
|
||||
}
|
||||
mem = BIO_new(BIO_s_mem());
|
||||
if (!mem || !i2d_PKCS12_bio(mem, p12)) {
|
||||
ERR_print_errors_fp(stderr);
|
||||
PKCS12_free(p12);
|
||||
return NULL;
|
||||
}
|
||||
PKCS12_free(p12);
|
||||
length = BIO_get_mem_data(mem, &ptr);
|
||||
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
|
||||
BIO_free(mem);
|
||||
return data;
|
||||
return cfarr;
|
||||
}
|
||||
|
|
|
@ -28,6 +28,6 @@
|
|||
/// - serial: Serial number of the certificate
|
||||
/// - days: Validity in days from today
|
||||
/// - isClient: If 0 then a TLS Server certificate is generated, otherwise a TLS Client certificate is generated
|
||||
_Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
|
||||
_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
|
||||
|
||||
#endif /* GenerateKey_h */
|
||||
|
|
|
@ -268,8 +268,9 @@ extension UTMRemoteClient {
|
|||
return fileUrl
|
||||
}
|
||||
|
||||
func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> UInt16 {
|
||||
try await _startVirtualMachine(parameters: .init(id: id, options: options)).spiceServerPort
|
||||
func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> (port: UInt16, publicKey: Data) {
|
||||
let reply = try await _startVirtualMachine(parameters: .init(id: id, options: options))
|
||||
return (reply.spiceServerPort, reply.spiceServerPublicKey)
|
||||
}
|
||||
|
||||
func stopVirtualMachine(id: UUID, method: UTMVirtualMachineStopMethod) async throws {
|
||||
|
|
|
@ -49,12 +49,12 @@ class UTMRemoteKeyManager {
|
|||
let organizationName = "UTM" as CFString
|
||||
let serialNumber = Int.random(in: 1..<CLong.max) as CFNumber
|
||||
let days = 3650 as CFNumber
|
||||
guard let p12Data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() else {
|
||||
guard let data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() as? [CFData] else {
|
||||
throw UTMRemoteKeyManagerError.generateKeyFailure
|
||||
}
|
||||
let importOptions = [ kSecImportExportPassphrase as String: "password" ] as CFDictionary
|
||||
var rawItems: CFArray?
|
||||
try withSecurityThrow(SecPKCS12Import(p12Data, importOptions, &rawItems))
|
||||
try withSecurityThrow(SecPKCS12Import(data[0], importOptions, &rawItems))
|
||||
guard let items = (rawItems! as! [[String: Any]]).first else {
|
||||
throw UTMRemoteKeyManagerError.parseKeyFailure
|
||||
}
|
||||
|
|
|
@ -129,6 +129,7 @@ extension UTMRemoteMessageServer {
|
|||
|
||||
struct Reply: Serializable, Codable {
|
||||
let spiceServerPort: UInt16
|
||||
let spiceServerPublicKey: Data
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -631,8 +631,8 @@ extension UTMRemoteServer {
|
|||
|
||||
private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
|
||||
let vm = try await findVM(withId: parameters.id)
|
||||
let port = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
|
||||
return .init(spiceServerPort: port)
|
||||
let (port, publicKey) = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
|
||||
return .init(spiceServerPort: port, spiceServerPublicKey: publicKey)
|
||||
}
|
||||
|
||||
private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {
|
||||
|
|
|
@ -154,7 +154,7 @@ extension UTMRemoteSpiceVirtualMachine {
|
|||
extension UTMRemoteSpiceVirtualMachine {
|
||||
func start(options: UTMVirtualMachineStartOptions) async throws {
|
||||
try await _state.operation(before: .stopped, during: .starting, after: .started) {
|
||||
let port = try await server.startVirtualMachine(id: id, options: options)
|
||||
let spiceServer = try await server.startVirtualMachine(id: id, options: options)
|
||||
var options = UTMSpiceIOOptions()
|
||||
if await !config.sound.isEmpty {
|
||||
options.insert(.hasAudio)
|
||||
|
@ -170,7 +170,7 @@ extension UTMRemoteSpiceVirtualMachine {
|
|||
options.insert(.hasDebugLog)
|
||||
}
|
||||
#endif
|
||||
let ioService = UTMSpiceIO(host: server.host, port: Int(port), options: options)
|
||||
let ioService = UTMSpiceIO(host: server.host, tlsPort: Int(spiceServer.port), serverPublicKey: spiceServer.publicKey, options: options)
|
||||
ioService.logHandler = { (line: String) -> Void in
|
||||
guard !line.contains("spice_make_scancode") else {
|
||||
return // do not log key presses for privacy reasons
|
||||
|
|
|
@ -279,6 +279,7 @@ extension UTMQemuVirtualMachine {
|
|||
await MainActor.run {
|
||||
config.qemu.isDisposable = isRunningAsDisposible
|
||||
config.qemu.spiceServerPort = spicePort
|
||||
config.qemu.isSpiceServerTlsEnabled = true
|
||||
}
|
||||
|
||||
// start TPM
|
||||
|
@ -338,6 +339,19 @@ extension UTMQemuVirtualMachine {
|
|||
}
|
||||
try pipeInterface.start()
|
||||
interface = pipeInterface
|
||||
// generate a TLS key for this session
|
||||
guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
|
||||
"UTM" as CFString,
|
||||
Int.random(in: 1..<CLong.max) as CFNumber,
|
||||
1 as CFNumber,
|
||||
false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
|
||||
throw UTMQemuVirtualMachineError.keyGenerationFailed
|
||||
}
|
||||
try await key[1].write(to: config.spiceTlsKeyUrl)
|
||||
try await key[2].write(to: config.spiceTlsCertUrl)
|
||||
await MainActor.run {
|
||||
config.qemu.spiceServerPublicKey = key[3]
|
||||
}
|
||||
} else {
|
||||
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
|
||||
ioService.logHandler = { [weak system] (line: String) -> Void in
|
||||
|
@ -835,6 +849,7 @@ enum UTMQemuVirtualMachineError: Error {
|
|||
case accessShareFailed
|
||||
case invalidVmState
|
||||
case saveSnapshotFailed(Error)
|
||||
case keyGenerationFailed
|
||||
}
|
||||
|
||||
extension UTMQemuVirtualMachineError: LocalizedError {
|
||||
|
@ -851,6 +866,8 @@ extension UTMQemuVirtualMachineError: LocalizedError {
|
|||
case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
|
||||
case .saveSnapshotFailed(let error):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
|
||||
case .keyGenerationFailed:
|
||||
return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithHost:(NSString *)host port:(NSInteger)port options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
||||
- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
|
||||
- (void)changeSharedDirectory:(NSURL *)url;
|
||||
|
||||
- (BOOL)startWithError:(NSError * _Nullable *)error;
|
||||
|
|
|
@ -24,7 +24,8 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
|||
|
||||
@property (nonatomic, nullable) NSURL *socketUrl;
|
||||
@property (nonatomic, nullable) NSString *host;
|
||||
@property (nonatomic) NSInteger port;
|
||||
@property (nonatomic) NSInteger tlsPort;
|
||||
@property (nonatomic, nullable) NSData *serverPublicKey;
|
||||
@property (nonatomic) UTMSpiceIOOptions options;
|
||||
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
|
||||
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
|
||||
|
@ -73,10 +74,11 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
|||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithHost:(NSString *)host port:(NSInteger)port options:(UTMSpiceIOOptions)options {
|
||||
- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey options:(UTMSpiceIOOptions)options {
|
||||
if (self = [super init]) {
|
||||
self.host = host;
|
||||
self.port = port;
|
||||
self.tlsPort = tlsPort;
|
||||
self.serverPublicKey = serverPublicKey;
|
||||
self.options = options;
|
||||
self.mutableDisplays = [NSMutableArray array];
|
||||
self.mutableSerials = [NSMutableArray array];
|
||||
|
@ -91,7 +93,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
|
|||
NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
|
||||
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
|
||||
} else {
|
||||
self.spiceConnection = [[CSConnection alloc] initWithHost:self.host port:[@(self.port) stringValue]];
|
||||
self.spiceConnection = [[CSConnection alloc] initWithHost:self.host tlsPort:[@(self.tlsPort) stringValue] serverPublicKey:self.serverPublicKey];
|
||||
}
|
||||
self.spiceConnection.delegate = self;
|
||||
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
"location" : "https://github.com/utmapp/CocoaSpice.git",
|
||||
"state" : {
|
||||
"branch" : "visionos",
|
||||
"revision" : "4529c9686259e8d1e94d6253ad2e3a563fd1498d"
|
||||
"revision" : "9591cdf41282a7e6edbe7b705adbb957592ba347"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue