Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add local network support for VNC #31

Merged
merged 2 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ Command Options:
--no-display Do not start the VNC client app
--shared-dir <dir> Share directory with VM (format: path[:ro|rw])
--mount <path> For Linux VMs only, attach a read-only disk image
--registry <url> Container registry URL (default: ghcr.io)
--organization <org> Organization to pull from (default: trycua)
--vnc-port <port> Port to use for the VNC server (default: 0 for auto-assign)

set:
--cpu <cores> New number of CPU cores (e.g., 4)
Expand Down
6 changes: 5 additions & 1 deletion src/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ struct Run: AsyncParsableCommand {
@Option(help: "Organization to pull the images from. Defaults to trycua")
var organization: String = "trycua"

@Option(name: [.customLong("vnc-port")], help: "Port to use for the VNC server. Defaults to 0 (auto-assign)")
var vncPort: Int = 0

private var parsedSharedDirectories: [SharedDirectory] {
get throws {
try sharedDirectories.map { dirString -> SharedDirectory in
Expand Down Expand Up @@ -75,7 +78,8 @@ struct Run: AsyncParsableCommand {
sharedDirectories: dirs,
mount: mount,
registry: registry,
organization: organization
organization: organization,
vncPort: vncPort
)
}
}
6 changes: 6 additions & 0 deletions src/Errors/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ enum VMError: Error, LocalizedError {
case stopTimeout(String)
case resizeTooSmall(current: UInt64, requested: UInt64)
case vncNotConfigured
case vncPortBindingFailed(requested: Int, actual: Int)
case internalError(String)
case unsupportedOS(String)
case invalidDisplayResolution(String)
Expand All @@ -148,6 +149,11 @@ enum VMError: Error, LocalizedError {
return "Cannot resize disk to \(requested) bytes, current size is \(current) bytes"
case .vncNotConfigured:
return "VNC is not configured for this virtual machine"
case .vncPortBindingFailed(let requested, let actual):
if actual == -1 {
return "Could not bind to VNC port \(requested) (port already in use). Try a different port or use port 0 for auto-assign."
}
return "Could not bind to VNC port \(requested) (port already in use). System assigned port \(actual) instead. Try a different port or use port 0 for auto-assign."
case .internalError(let message):
return "Internal error: \(message)"
case .unsupportedOS(let os):
Expand Down
6 changes: 4 additions & 2 deletions src/LumeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ final class LumeController {
sharedDirectories: [SharedDirectory] = [],
mount: Path? = nil,
registry: String = "ghcr.io",
organization: String = "trycua"
organization: String = "trycua",
vncPort: Int = 0
) async throws {
let normalizedName = normalizeVMName(name: name)
Logger.info(
Expand All @@ -265,6 +266,7 @@ final class LumeController {
"no_display": "\(noDisplay)",
"shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
"mount": mount?.path ?? "none",
"vnc_port": "\(vncPort)",
])

do {
Expand All @@ -284,7 +286,7 @@ final class LumeController {

let vm = try get(name: normalizedName)
SharedVM.shared.setVM(name: normalizedName, vm: vm)
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort)
Logger.info("VM started successfully", metadata: ["name": normalizedName])
} catch {
SharedVM.shared.removeVM(name: normalizedName)
Expand Down
11 changes: 6 additions & 5 deletions src/VM/VM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class VM {

// MARK: - VM Lifecycle Management

func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws {
func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws {
guard vmDirContext.initialized else {
throw VMError.notInitialized(vmDirContext.name)
}
Expand All @@ -111,7 +111,8 @@ class VM {
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
"sharedDirectories": sharedDirectories.map(
{ $0.string }
).joined(separator: ", ")
).joined(separator: ", "),
"vncPort": "\(vncPort)"
])

// Create and configure the VM
Expand All @@ -125,7 +126,7 @@ class VM {
)
virtualizationService = try virtualizationServiceFactory(config)

let vncInfo = try await setupVNC(noDisplay: noDisplay)
let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort)
Logger.info("VNC info", metadata: ["vncInfo": vncInfo])

// Start the VM
Expand Down Expand Up @@ -337,12 +338,12 @@ class VM {
return vncService.url
}

private func setupVNC(noDisplay: Bool) async throws -> String {
private func setupVNC(noDisplay: Bool, port: Int = 0) async throws -> String {
guard let service = virtualizationService else {
throw VMError.internalError("Virtualization service not initialized")
}

try await vncService.start(port: 0, virtualMachine: service.getVirtualMachine())
try await vncService.start(port: port, virtualMachine: service.getVirtualMachine())

guard let url = vncService.url else {
throw VMError.vncNotConfigured
Expand Down
35 changes: 32 additions & 3 deletions src/VNC/PassphraseGenerator.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit

final class PassphraseGenerator {
private let words: [String]
Expand All @@ -9,11 +10,39 @@ final class PassphraseGenerator {

func prefix(_ count: Int) -> [String] {
guard count > 0 else { return [] }
return (0..<count).map { _ in words.randomElement() ?? words[0] }

// Use secure random number generation
var result: [String] = []
for _ in 0..<count {
let randomBytes = (0..<4).map { _ in UInt8.random(in: 0...255) }
let randomNumber = Data(randomBytes).withUnsafeBytes { bytes in
bytes.load(as: UInt32.self)
}
let index = Int(randomNumber % UInt32(words.count))
result.append(words[index])
}
return result
}

// A much larger set of common, easy-to-type words
private static let defaultWords = [
"apple", "banana", "cherry", "date",
"elder", "fig", "grape", "honey"
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
"india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa",
"quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey", "xray",
"yankee", "zulu", "zero", "one", "two", "three", "four", "five",
"six", "seven", "eight", "nine", "apple", "banana", "cherry", "date",
"elder", "fig", "grape", "honey", "iris", "jade", "kiwi", "lemon",
"mango", "nectarine", "orange", "peach", "quince", "raspberry", "strawberry", "tangerine",
"red", "blue", "green", "yellow", "purple", "orange", "pink", "brown",
"black", "white", "gray", "silver", "gold", "copper", "bronze", "steel",
"north", "south", "east", "west", "spring", "summer", "autumn", "winter",
"river", "ocean", "mountain", "valley", "forest", "desert", "island", "beach",
"sun", "moon", "star", "cloud", "rain", "snow", "wind", "storm",
"happy", "brave", "calm", "swift", "wise", "kind", "bold", "free",
"safe", "strong", "bright", "clear", "light", "soft", "warm", "cool",
"eagle", "falcon", "hawk", "owl", "robin", "sparrow", "swan", "dove",
"tiger", "lion", "bear", "wolf", "deer", "horse", "dolphin", "whale",
"maple", "oak", "pine", "birch", "cedar", "fir", "palm", "willow",
"rose", "lily", "daisy", "tulip", "lotus", "orchid", "violet", "jasmine"
]
}
89 changes: 78 additions & 11 deletions src/VNC/VNCService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,98 @@ final class DefaultVNCService: VNCService {
func start(port: Int, virtualMachine: Any?) async throws {
let password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)

// Create VNC server with specified port
let server = Dynamic._VZVNCServer(port: port, queue: DispatchQueue.main,
securityConfiguration: securityConfiguration)

if let vm = virtualMachine as? VZVirtualMachine {
server.virtualMachine = vm
}
server.start()

vncServer = server

// Wait for port to be assigned
// Wait for port to be assigned (both for auto-assign and specific port)
var attempts = 0
let maxAttempts = 20 // 1 second total wait time
while true {
if let port: UInt16 = server.port.asUInt16, port != 0 {
let url = "vnc://:\(password)@127.0.0.1:\(port)"

// Save session information
let session = VNCSession(
url: url
)
try vmDirectory.saveSession(session)
break
if let assignedPort: UInt16 = server.port.asUInt16 {
// If we got a non-zero port, check if it matches our request
if assignedPort != 0 {
// For specific port requests, verify we got the requested port
if port != 0 && Int(assignedPort) != port {
throw VMError.vncPortBindingFailed(requested: port, actual: Int(assignedPort))
}

// Get the local IP address for the URL - prefer IPv4
let hostIP = try getLocalIPAddress() ?? "127.0.0.1"
let url = "vnc://:\(password)@127.0.0.1:\(assignedPort)" // Use localhost for local connections
let externalUrl = "vnc://:\(password)@\(hostIP):\(assignedPort)" // External URL for remote connections

Logger.info("VNC server started", metadata: [
"local": url,
"external": externalUrl
])

// Save session information with local URL for the client
let session = VNCSession(url: url)
try vmDirectory.saveSession(session)
break
}
}

attempts += 1
if attempts >= maxAttempts {
// If we've timed out and we requested a specific port, it likely means binding failed
vncServer = nil
if port != 0 {
throw VMError.vncPortBindingFailed(requested: port, actual: -1)
}
throw VMError.internalError("Timeout waiting for VNC server to start")
}
try await Task.sleep(nanoseconds: 50_000_000)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms delay between checks
}
}

// Modified to prefer IPv4 addresses
private func getLocalIPAddress() throws -> String? {
var address: String?

var ifaddr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddr) == 0 else {
return nil
}
defer { freeifaddrs(ifaddr) }

var ptr = ifaddr
while ptr != nil {
defer { ptr = ptr?.pointee.ifa_next }

let interface = ptr?.pointee
let family = interface?.ifa_addr.pointee.sa_family

// Only look for IPv4 addresses
if family == UInt8(AF_INET) {
let name = String(cString: (interface?.ifa_name)!)
if name == "en0" { // Primary interface
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
getnameinfo(interface?.ifa_addr,
socklen_t((interface?.ifa_addr.pointee.sa_len)!),
&hostname,
socklen_t(hostname.count),
nil,
0,
NI_NUMERICHOST)
address = String(cString: hostname, encoding: .utf8)
break
}
}
}

return address
}

func stop() {
if let server = vncServer as? Dynamic {
server.stop()
Expand Down
4 changes: 2 additions & 2 deletions tests/Mocks/MockVM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ class MockVM: VM {
try vmDirContext.saveConfig()
}

override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws {
override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws {
mockIsRunning = true
try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort)
}

override func stop() async throws {
Expand Down
2 changes: 1 addition & 1 deletion tests/VMTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func testVMRunAndStop() async throws {

// Test running VM
let runTask = Task {
try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil)
try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil, vncPort: 0)
}

// Give the VM time to start
Expand Down