Skip to content

Commit f293249

Browse files
authored
Merge pull request #31 from trycua/feature/vnc-local-net
Add local network support for VNC
2 parents 661cdf6 + 065f418 commit f293249

File tree

9 files changed

+137
-25
lines changed

9 files changed

+137
-25
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ Command Options:
6868
--no-display Do not start the VNC client app
6969
--shared-dir <dir> Share directory with VM (format: path[:ro|rw])
7070
--mount <path> For Linux VMs only, attach a read-only disk image
71+
--registry <url> Container registry URL (default: ghcr.io)
72+
--organization <org> Organization to pull from (default: trycua)
73+
--vnc-port <port> Port to use for the VNC server (default: 0 for auto-assign)
7174

7275
set:
7376
--cpu <cores> New number of CPU cores (e.g., 4)

src/Commands/Run.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ struct Run: AsyncParsableCommand {
2525
@Option(help: "Organization to pull the images from. Defaults to trycua")
2626
var organization: String = "trycua"
2727

28+
@Option(name: [.customLong("vnc-port")], help: "Port to use for the VNC server. Defaults to 0 (auto-assign)")
29+
var vncPort: Int = 0
30+
2831
private var parsedSharedDirectories: [SharedDirectory] {
2932
get throws {
3033
try sharedDirectories.map { dirString -> SharedDirectory in
@@ -75,7 +78,8 @@ struct Run: AsyncParsableCommand {
7578
sharedDirectories: dirs,
7679
mount: mount,
7780
registry: registry,
78-
organization: organization
81+
organization: organization,
82+
vncPort: vncPort
7983
)
8084
}
8185
}

src/Errors/Errors.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ enum VMError: Error, LocalizedError {
125125
case stopTimeout(String)
126126
case resizeTooSmall(current: UInt64, requested: UInt64)
127127
case vncNotConfigured
128+
case vncPortBindingFailed(requested: Int, actual: Int)
128129
case internalError(String)
129130
case unsupportedOS(String)
130131
case invalidDisplayResolution(String)
@@ -148,6 +149,11 @@ enum VMError: Error, LocalizedError {
148149
return "Cannot resize disk to \(requested) bytes, current size is \(current) bytes"
149150
case .vncNotConfigured:
150151
return "VNC is not configured for this virtual machine"
152+
case .vncPortBindingFailed(let requested, let actual):
153+
if actual == -1 {
154+
return "Could not bind to VNC port \(requested) (port already in use). Try a different port or use port 0 for auto-assign."
155+
}
156+
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."
151157
case .internalError(let message):
152158
return "Internal error: \(message)"
153159
case .unsupportedOS(let os):

src/LumeController.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ final class LumeController {
255255
sharedDirectories: [SharedDirectory] = [],
256256
mount: Path? = nil,
257257
registry: String = "ghcr.io",
258-
organization: String = "trycua"
258+
organization: String = "trycua",
259+
vncPort: Int = 0
259260
) async throws {
260261
let normalizedName = normalizeVMName(name: name)
261262
Logger.info(
@@ -265,6 +266,7 @@ final class LumeController {
265266
"no_display": "\(noDisplay)",
266267
"shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
267268
"mount": mount?.path ?? "none",
269+
"vnc_port": "\(vncPort)",
268270
])
269271

270272
do {
@@ -284,7 +286,7 @@ final class LumeController {
284286

285287
let vm = try get(name: normalizedName)
286288
SharedVM.shared.setVM(name: normalizedName, vm: vm)
287-
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
289+
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort)
288290
Logger.info("VM started successfully", metadata: ["name": normalizedName])
289291
} catch {
290292
SharedVM.shared.removeVM(name: normalizedName)

src/VM/VM.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class VM {
8888

8989
// MARK: - VM Lifecycle Management
9090

91-
func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws {
91+
func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0) async throws {
9292
guard vmDirContext.initialized else {
9393
throw VMError.notInitialized(vmDirContext.name)
9494
}
@@ -111,7 +111,8 @@ class VM {
111111
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
112112
"sharedDirectories": sharedDirectories.map(
113113
{ $0.string }
114-
).joined(separator: ", ")
114+
).joined(separator: ", "),
115+
"vncPort": "\(vncPort)"
115116
])
116117

117118
// Create and configure the VM
@@ -125,7 +126,7 @@ class VM {
125126
)
126127
virtualizationService = try virtualizationServiceFactory(config)
127128

128-
let vncInfo = try await setupVNC(noDisplay: noDisplay)
129+
let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort)
129130
Logger.info("VNC info", metadata: ["vncInfo": vncInfo])
130131

131132
// Start the VM
@@ -337,12 +338,12 @@ class VM {
337338
return vncService.url
338339
}
339340

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

345-
try await vncService.start(port: 0, virtualMachine: service.getVirtualMachine())
346+
try await vncService.start(port: port, virtualMachine: service.getVirtualMachine())
346347

347348
guard let url = vncService.url else {
348349
throw VMError.vncNotConfigured

src/VNC/PassphraseGenerator.swift

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import CryptoKit
23

34
final class PassphraseGenerator {
45
private let words: [String]
@@ -9,11 +10,39 @@ final class PassphraseGenerator {
910

1011
func prefix(_ count: Int) -> [String] {
1112
guard count > 0 else { return [] }
12-
return (0..<count).map { _ in words.randomElement() ?? words[0] }
13+
14+
// Use secure random number generation
15+
var result: [String] = []
16+
for _ in 0..<count {
17+
let randomBytes = (0..<4).map { _ in UInt8.random(in: 0...255) }
18+
let randomNumber = Data(randomBytes).withUnsafeBytes { bytes in
19+
bytes.load(as: UInt32.self)
20+
}
21+
let index = Int(randomNumber % UInt32(words.count))
22+
result.append(words[index])
23+
}
24+
return result
1325
}
1426

27+
// A much larger set of common, easy-to-type words
1528
private static let defaultWords = [
16-
"apple", "banana", "cherry", "date",
17-
"elder", "fig", "grape", "honey"
29+
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
30+
"india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa",
31+
"quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey", "xray",
32+
"yankee", "zulu", "zero", "one", "two", "three", "four", "five",
33+
"six", "seven", "eight", "nine", "apple", "banana", "cherry", "date",
34+
"elder", "fig", "grape", "honey", "iris", "jade", "kiwi", "lemon",
35+
"mango", "nectarine", "orange", "peach", "quince", "raspberry", "strawberry", "tangerine",
36+
"red", "blue", "green", "yellow", "purple", "orange", "pink", "brown",
37+
"black", "white", "gray", "silver", "gold", "copper", "bronze", "steel",
38+
"north", "south", "east", "west", "spring", "summer", "autumn", "winter",
39+
"river", "ocean", "mountain", "valley", "forest", "desert", "island", "beach",
40+
"sun", "moon", "star", "cloud", "rain", "snow", "wind", "storm",
41+
"happy", "brave", "calm", "swift", "wise", "kind", "bold", "free",
42+
"safe", "strong", "bright", "clear", "light", "soft", "warm", "cool",
43+
"eagle", "falcon", "hawk", "owl", "robin", "sparrow", "swan", "dove",
44+
"tiger", "lion", "bear", "wolf", "deer", "horse", "dolphin", "whale",
45+
"maple", "oak", "pine", "birch", "cedar", "fir", "palm", "willow",
46+
"rose", "lily", "daisy", "tulip", "lotus", "orchid", "violet", "jasmine"
1847
]
1948
}

src/VNC/VNCService.swift

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,98 @@ final class DefaultVNCService: VNCService {
3030
func start(port: Int, virtualMachine: Any?) async throws {
3131
let password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
3232
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
33+
34+
// Create VNC server with specified port
3335
let server = Dynamic._VZVNCServer(port: port, queue: DispatchQueue.main,
3436
securityConfiguration: securityConfiguration)
37+
3538
if let vm = virtualMachine as? VZVirtualMachine {
3639
server.virtualMachine = vm
3740
}
3841
server.start()
3942

4043
vncServer = server
4144

42-
// Wait for port to be assigned
45+
// Wait for port to be assigned (both for auto-assign and specific port)
46+
var attempts = 0
47+
let maxAttempts = 20 // 1 second total wait time
4348
while true {
44-
if let port: UInt16 = server.port.asUInt16, port != 0 {
45-
let url = "vnc://:\(password)@127.0.0.1:\(port)"
46-
47-
// Save session information
48-
let session = VNCSession(
49-
url: url
50-
)
51-
try vmDirectory.saveSession(session)
52-
break
49+
if let assignedPort: UInt16 = server.port.asUInt16 {
50+
// If we got a non-zero port, check if it matches our request
51+
if assignedPort != 0 {
52+
// For specific port requests, verify we got the requested port
53+
if port != 0 && Int(assignedPort) != port {
54+
throw VMError.vncPortBindingFailed(requested: port, actual: Int(assignedPort))
55+
}
56+
57+
// Get the local IP address for the URL - prefer IPv4
58+
let hostIP = try getLocalIPAddress() ?? "127.0.0.1"
59+
let url = "vnc://:\(password)@127.0.0.1:\(assignedPort)" // Use localhost for local connections
60+
let externalUrl = "vnc://:\(password)@\(hostIP):\(assignedPort)" // External URL for remote connections
61+
62+
Logger.info("VNC server started", metadata: [
63+
"local": url,
64+
"external": externalUrl
65+
])
66+
67+
// Save session information with local URL for the client
68+
let session = VNCSession(url: url)
69+
try vmDirectory.saveSession(session)
70+
break
71+
}
72+
}
73+
74+
attempts += 1
75+
if attempts >= maxAttempts {
76+
// If we've timed out and we requested a specific port, it likely means binding failed
77+
vncServer = nil
78+
if port != 0 {
79+
throw VMError.vncPortBindingFailed(requested: port, actual: -1)
80+
}
81+
throw VMError.internalError("Timeout waiting for VNC server to start")
5382
}
54-
try await Task.sleep(nanoseconds: 50_000_000)
83+
try await Task.sleep(nanoseconds: 50_000_000) // 50ms delay between checks
5584
}
5685
}
5786

87+
// Modified to prefer IPv4 addresses
88+
private func getLocalIPAddress() throws -> String? {
89+
var address: String?
90+
91+
var ifaddr: UnsafeMutablePointer<ifaddrs>?
92+
guard getifaddrs(&ifaddr) == 0 else {
93+
return nil
94+
}
95+
defer { freeifaddrs(ifaddr) }
96+
97+
var ptr = ifaddr
98+
while ptr != nil {
99+
defer { ptr = ptr?.pointee.ifa_next }
100+
101+
let interface = ptr?.pointee
102+
let family = interface?.ifa_addr.pointee.sa_family
103+
104+
// Only look for IPv4 addresses
105+
if family == UInt8(AF_INET) {
106+
let name = String(cString: (interface?.ifa_name)!)
107+
if name == "en0" { // Primary interface
108+
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
109+
getnameinfo(interface?.ifa_addr,
110+
socklen_t((interface?.ifa_addr.pointee.sa_len)!),
111+
&hostname,
112+
socklen_t(hostname.count),
113+
nil,
114+
0,
115+
NI_NUMERICHOST)
116+
address = String(cString: hostname, encoding: .utf8)
117+
break
118+
}
119+
}
120+
}
121+
122+
return address
123+
}
124+
58125
func stop() {
59126
if let server = vncServer as? Dynamic {
60127
server.stop()

tests/Mocks/MockVM.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ class MockVM: VM {
1818
try vmDirContext.saveConfig()
1919
}
2020

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

2626
override func stop() async throws {

tests/VMTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func testVMRunAndStop() async throws {
9595

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

101101
// Give the VM time to start

0 commit comments

Comments
 (0)