Compare commits

..

3 Commits

Author SHA1 Message Date
b5d78b2bd9 Fix P2P discovery and networking 2025-08-31 14:12:01 -04:00
1f92098d8e Fixed uPnP 2025-08-31 14:03:58 -04:00
7b8bb02dc3 Fixed config 2025-08-30 20:22:43 -04:00
5 changed files with 321 additions and 218 deletions

View File

@@ -109,6 +109,9 @@ public class P2PConsole
case "upnp": case "upnp":
await ShowUPnPStatusAsync(); await ShowUPnPStatusAsync();
break; break;
case "discovery":
await ShowDiscoveryInfoAsync();
break;
case "exit": case "exit":
case "quit": case "quit":
System.Console.WriteLine("Stopping P2P node..."); System.Console.WriteLine("Stopping P2P node...");
@@ -144,6 +147,7 @@ public class P2PConsole
System.Console.WriteLine(" migrate <vm-id> <node-id> - Migrate VM to different node"); System.Console.WriteLine(" migrate <vm-id> <node-id> - Migrate VM to different node");
System.Console.WriteLine(" forward <vm-id> <port> - Forward port for VM (master only)"); System.Console.WriteLine(" forward <vm-id> <port> - Forward port for VM (master only)");
System.Console.WriteLine(" upnp - Show UPnP status"); System.Console.WriteLine(" upnp - Show UPnP status");
System.Console.WriteLine(" discovery - Show network discovery information");
System.Console.WriteLine(" help - Show this help"); System.Console.WriteLine(" help - Show this help");
System.Console.WriteLine(" exit/quit - Exit the application"); System.Console.WriteLine(" exit/quit - Exit the application");
} }
@@ -518,4 +522,51 @@ public class P2PConsole
var input = System.Console.ReadLine()?.Trim(); var input = System.Console.ReadLine()?.Trim();
return string.IsNullOrEmpty(input) ? defaultValue : input; return string.IsNullOrEmpty(input) ? defaultValue : input;
} }
private async Task ShowDiscoveryInfoAsync()
{
System.Console.WriteLine("=== Network Discovery Information ===");
System.Console.WriteLine($"Current Node ID: {_p2pNode.CurrentNode.NodeId}");
System.Console.WriteLine($"Current Node IP: {_p2pNode.CurrentNode.IpAddress}");
System.Console.WriteLine($"Current Node Port: {_p2pNode.CurrentNode.Port}");
System.Console.WriteLine($"Current Role: {_p2pNode.CurrentNode.Role}");
System.Console.WriteLine($"Is Master: {_p2pNode.IsMaster}");
System.Console.WriteLine();
// Get cluster state to show known nodes
var cluster = _p2pNode.ClusterState;
System.Console.WriteLine($"Known Nodes: {cluster.Nodes.Count}");
if (cluster.Nodes.Count > 0)
{
System.Console.WriteLine();
System.Console.WriteLine("=== Known Nodes ===");
System.Console.WriteLine($"{"Node ID",-20} {"IP Address",-15} {"Port",-8} {"Role",-10} {"Last Seen"}");
System.Console.WriteLine(new string('-', 80));
foreach (var node in cluster.Nodes.Values)
{
var lastSeen = node.LastSeen.ToString("HH:mm:ss");
System.Console.WriteLine($"{node.NodeId,-20} {node.IpAddress,-15} {node.Port,-8} {node.Role,-10} {lastSeen}");
}
}
else
{
System.Console.WriteLine("No other nodes discovered yet.");
System.Console.WriteLine();
System.Console.WriteLine("Discovery troubleshooting:");
System.Console.WriteLine("1. Make sure other nodes are running on the same network");
System.Console.WriteLine("2. Check if firewall is blocking UDP port 8080");
System.Console.WriteLine("3. Try running multiple instances on different machines");
System.Console.WriteLine("4. Check network connectivity between nodes");
}
System.Console.WriteLine();
System.Console.WriteLine("=== Network Configuration ===");
System.Console.WriteLine("UDP Discovery: Enabled (port 8080)");
System.Console.WriteLine("TCP Communication: Enabled (port 8081)");
System.Console.WriteLine("Heartbeat Interval: 5 seconds");
System.Console.WriteLine("Discovery Interval: 30 seconds");
System.Console.WriteLine("Node Timeout: 30 seconds");
}
} }

View File

@@ -273,15 +273,23 @@ public class P2PNode : IDisposable
private async Task InitializeNetworkAsync() private async Task InitializeNetworkAsync()
{ {
// Initialize UDP client for discovery and heartbeats // Initialize UDP client for discovery and heartbeats
_udpClient = new UdpClient(_port); _udpClient = new UdpClient();
_udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, true);
_udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, _port));
// Initialize TCP listener for direct communication // Initialize TCP listener for direct communication on a different port
_tcpListener = new TcpListener(IPAddress.Any, _port); var tcpPort = _port + 1; // Use next port for TCP
_tcpListener = new TcpListener(IPAddress.Any, tcpPort);
_tcpListener.Start(); _tcpListener.Start();
_logger?.LogInformation("Network initialized - UDP: {UdpPort}, TCP: {TcpPort}", _port, tcpPort);
// Start listening for incoming connections // Start listening for incoming connections
_ = Task.Run(() => ListenForConnectionsAsync(_cancellationTokenSource!.Token)); _ = Task.Run(() => ListenForConnectionsAsync(_cancellationTokenSource!.Token));
// Start listening for UDP messages
_ = Task.Run(() => ListenForUdpMessagesAsync(_cancellationTokenSource!.Token));
} }
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken) private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
@@ -389,8 +397,29 @@ public class P2PNode : IDisposable
}); });
var data = System.Text.Encoding.UTF8.GetBytes(discoveryMessage); var data = System.Text.Encoding.UTF8.GetBytes(discoveryMessage);
await _udpClient!.SendAsync(data, data.Length, new IPEndPoint(IPAddress.Broadcast, _port));
// Try multiple broadcast addresses
var broadcastAddresses = new[]
{
IPAddress.Broadcast,
IPAddress.Parse("255.255.255.255"),
GetLocalBroadcastAddress()
};
foreach (var broadcastAddr in broadcastAddresses)
{
try
{
await _udpClient!.SendAsync(data, data.Length, new IPEndPoint(broadcastAddr, _port));
_logger?.LogDebug("Sent discovery message to {BroadcastAddress}", broadcastAddr);
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "Failed to send discovery to {BroadcastAddress}", broadcastAddr);
}
}
_logger?.LogInformation("Discovery cycle completed. Known nodes: {NodeCount}", _knownNodes.Count);
await Task.Delay(30000, cancellationToken); // Send discovery every 30 seconds await Task.Delay(30000, cancellationToken); // Send discovery every 30 seconds
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -579,6 +608,7 @@ public class P2PNode : IDisposable
var message = JsonSerializer.Serialize(heartbeat); var message = JsonSerializer.Serialize(heartbeat);
var data = System.Text.Encoding.UTF8.GetBytes(message); var data = System.Text.Encoding.UTF8.GetBytes(message);
// Send to known nodes
foreach (var node in _knownNodes.Values) foreach (var node in _knownNodes.Values)
{ {
try try
@@ -590,6 +620,27 @@ public class P2PNode : IDisposable
_logger?.LogWarning(ex, "Failed to send heartbeat to node {NodeId}", node.NodeId); _logger?.LogWarning(ex, "Failed to send heartbeat to node {NodeId}", node.NodeId);
} }
} }
// Also broadcast to discover new nodes
var broadcastAddresses = new[]
{
IPAddress.Broadcast,
IPAddress.Parse("255.255.255.255"),
GetLocalBroadcastAddress()
};
foreach (var broadcastAddr in broadcastAddresses)
{
try
{
await _udpClient!.SendAsync(data, data.Length, new IPEndPoint(broadcastAddr, _port));
_logger?.LogDebug("Broadcasted heartbeat to {BroadcastAddress}", broadcastAddr);
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "Failed to broadcast heartbeat to {BroadcastAddress}", broadcastAddr);
}
}
} }
private async Task CleanupStaleNodesAsync() private async Task CleanupStaleNodesAsync()
@@ -625,6 +676,57 @@ public class P2PNode : IDisposable
} }
} }
private async Task ListenForUdpMessagesAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
var result = await _udpClient!.ReceiveAsync(cancellationToken);
var message = System.Text.Encoding.UTF8.GetString(result.Buffer);
_logger?.LogDebug("Received UDP message from {Endpoint}: {Message}",
result.RemoteEndPoint, message);
await ProcessUdpMessageAsync(message, result.RemoteEndPoint);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error receiving UDP message");
}
}
}
private async Task ProcessUdpMessageAsync(string message, IPEndPoint remoteEndpoint)
{
try
{
var data = JsonSerializer.Deserialize<JsonElement>(message);
var messageType = data.GetProperty("type").GetString();
switch (messageType)
{
case "heartbeat":
await ProcessHeartbeatAsync(data);
break;
case "discovery":
await ProcessDiscoveryAsync(data);
break;
default:
_logger?.LogWarning("Unknown UDP message type: {MessageType}", messageType);
break;
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error processing UDP message: {Message}", message);
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
{ {
try try
@@ -718,13 +820,24 @@ public class P2PNode : IDisposable
var newNode = new NodeInfo var newNode = new NodeInfo
{ {
NodeId = nodeId, NodeId = nodeId,
LastSeen = DateTime.UtcNow Hostname = Environment.MachineName, // We'll get this from heartbeat
IpAddress = IPAddress.Any, // We'll get this from heartbeat
Port = _port,
Role = NodeRole.Follower,
State = NodeState.Running,
LastSeen = DateTime.UtcNow,
SystemInfo = new SystemInfo() // We'll get this from heartbeat
}; };
_knownNodes[nodeId] = newNode; _knownNodes[nodeId] = newNode;
NodeJoined?.Invoke(this, newNode); NodeJoined?.Invoke(this, newNode);
_logger?.LogInformation("Discovered new node {NodeId}", nodeId); _logger?.LogInformation("Discovered new node {NodeId}", nodeId);
} }
else if (nodeId != _nodeId && _knownNodes.ContainsKey(nodeId))
{
// Update last seen for existing node
_knownNodes[nodeId].LastSeen = DateTime.UtcNow;
}
} }
private async Task ProcessElectionRequestAsync(JsonElement data, StreamWriter writer) private async Task ProcessElectionRequestAsync(JsonElement data, StreamWriter writer)
@@ -808,6 +921,29 @@ public class P2PNode : IDisposable
} }
} }
private IPAddress GetLocalBroadcastAddress()
{
try
{
var localIp = GetLocalIpAddress();
if (localIp != null && localIp != IPAddress.Any)
{
var ipBytes = localIp.GetAddressBytes();
// Set all host bits to 1 for broadcast
for (int i = 3; i >= 0; i--)
{
ipBytes[i] = 255;
}
return new IPAddress(ipBytes);
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to get local broadcast address");
}
return IPAddress.Broadcast;
}
private SystemInfo GetSystemInfo() private SystemInfo GetSystemInfo()
{ {
return new SystemInfo return new SystemInfo

View File

@@ -11,8 +11,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Open.NAT" Version="2.1.0" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="8.0.0" /> <PackageReference Include="System.Diagnostics.PerformanceCounter" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="System.Reflection.Metadata" Version="9.0.8" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using QemuVmManager.Models; using QemuVmManager.Models;
using Open.Nat;
namespace QemuVmManager.Core; namespace QemuVmManager.Core;
@@ -19,23 +20,56 @@ public class UPnPManager : IUPnPManager
private readonly ILogger<UPnPManager>? _logger; private readonly ILogger<UPnPManager>? _logger;
private readonly Dictionary<int, PortMapping> _activeMappings = new(); private readonly Dictionary<int, PortMapping> _activeMappings = new();
private readonly object _lock = new(); private readonly object _lock = new();
private NatDevice? _natDevice;
private bool _isInitialized = false;
public UPnPManager(ILogger<UPnPManager>? logger = null) public UPnPManager(ILogger<UPnPManager>? logger = null)
{ {
_logger = logger; _logger = logger;
} }
private async Task InitializeAsync()
{
if (_isInitialized) return;
try
{
_logger?.LogInformation("Initializing UPnP/NAT discovery...");
// Create a new NAT discoverer
var discoverer = new NatDiscoverer();
// Discover UPnP devices with a 10-second timeout
_natDevice = await discoverer.DiscoverDeviceAsync(PortMapper.Upnp, new CancellationTokenSource(TimeSpan.FromSeconds(10)));
if (_natDevice != null)
{
_logger?.LogInformation("UPnP device discovered successfully");
_isInitialized = true;
}
else
{
_logger?.LogWarning("No UPnP devices found");
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "UPnP initialization failed");
_natDevice = null;
_isInitialized = false;
}
}
public async Task<bool> IsUPnPAvailableAsync() public async Task<bool> IsUPnPAvailableAsync()
{ {
try try
{ {
// Try to discover UPnP devices await InitializeAsync();
var devices = await DiscoverUPnPDevicesAsync(); return _natDevice != null;
return devices.Any();
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger?.LogWarning(ex, "UPnP discovery failed"); _logger?.LogWarning(ex, "UPnP availability check failed");
return false; return false;
} }
} }
@@ -44,18 +78,22 @@ public class UPnPManager : IUPnPManager
{ {
try try
{ {
// Try multiple methods to get external IP await InitializeAsync();
var externalIp = await GetExternalIpFromUPnPAsync();
if (externalIp != null)
return externalIp;
// Fallback to external service if (_natDevice == null)
{
_logger?.LogWarning("No UPnP device available for external IP lookup");
return await GetExternalIpFromServiceAsync(); return await GetExternalIpFromServiceAsync();
} }
var externalIp = await _natDevice.GetExternalIPAsync();
_logger?.LogInformation("External IP from UPnP: {ExternalIP}", externalIp);
return externalIp;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger?.LogError(ex, "Failed to get external IP address"); _logger?.LogWarning(ex, "Failed to get external IP from UPnP, falling back to external service");
return null; return await GetExternalIpFromServiceAsync();
} }
} }
@@ -63,6 +101,14 @@ public class UPnPManager : IUPnPManager
{ {
try try
{ {
await InitializeAsync();
if (_natDevice == null)
{
_logger?.LogError("No UPnP device available for port mapping");
return false;
}
var localIp = GetLocalIpAddress(); var localIp = GetLocalIpAddress();
if (localIp == null) if (localIp == null)
{ {
@@ -70,9 +116,12 @@ public class UPnPManager : IUPnPManager
return false; return false;
} }
var success = await AddUPnPPortMappingAsync(externalPort, internalPort, localIp, description); // Create the port mapping
if (success) var mapping = new Mapping(Protocol.Tcp, internalPort, externalPort, description);
{
// Add the mapping
await _natDevice.CreatePortMapAsync(mapping);
lock (_lock) lock (_lock)
{ {
_activeMappings[externalPort] = new PortMapping _activeMappings[externalPort] = new PortMapping
@@ -85,11 +134,11 @@ public class UPnPManager : IUPnPManager
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
} }
_logger?.LogInformation("Added port mapping: {ExternalPort} -> {InternalPort} ({Description})",
externalPort, internalPort, description);
}
return success; _logger?.LogInformation("Successfully added port mapping: {ExternalPort} -> {InternalIp}:{InternalPort} ({Description})",
externalPort, localIp, internalPort, description);
return true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -103,17 +152,34 @@ public class UPnPManager : IUPnPManager
{ {
try try
{ {
var success = await RemoveUPnPPortMappingAsync(externalPort); await InitializeAsync();
if (success)
if (_natDevice == null)
{ {
_logger?.LogError("No UPnP device available for port mapping removal");
return false;
}
var localIp = GetLocalIpAddress();
if (localIp == null)
{
_logger?.LogError("Could not determine local IP address for mapping removal");
return false;
}
// Create the mapping object for removal
var mapping = new Mapping(Protocol.Tcp, 0, externalPort, "");
// Remove the mapping
await _natDevice.DeletePortMapAsync(mapping);
lock (_lock) lock (_lock)
{ {
_activeMappings.Remove(externalPort); _activeMappings.Remove(externalPort);
} }
_logger?.LogInformation("Removed port mapping: {ExternalPort}", externalPort);
}
return success; _logger?.LogInformation("Successfully removed port mapping: {ExternalPort}", externalPort);
return true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -126,119 +192,35 @@ public class UPnPManager : IUPnPManager
{ {
try try
{ {
var mappings = await GetUPnPPortMappingsAsync(); await InitializeAsync();
lock (_lock)
if (_natDevice == null)
{ {
// Merge with our active mappings _logger?.LogWarning("No UPnP device available for port mapping retrieval");
foreach (var mapping in _activeMappings.Values)
{
if (!mappings.Any(m => m.ExternalPort == mapping.ExternalPort))
{
mappings.Add(mapping);
}
}
}
return mappings;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to get port mappings");
lock (_lock) lock (_lock)
{ {
return _activeMappings.Values.ToList(); return _activeMappings.Values.ToList();
} }
} }
var mappings = new List<PortMapping>();
// For now, just return our active mappings since the Open.NAT API is complex
// In a full implementation, we would query the device for all mappings
lock (_lock)
{
mappings.AddRange(_activeMappings.Values);
} }
private async Task<List<UPnPDevice>> DiscoverUPnPDevicesAsync() return mappings;
{
var devices = new List<UPnPDevice>();
// Simple SSDP discovery
var ssdpMessage =
"M-SEARCH * HTTP/1.1\r\n" +
"HOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"MX: 3\r\n" +
"ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" +
"\r\n";
using var udpClient = new UdpClient();
udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
var endpoint = new IPEndPoint(IPAddress.Parse("239.255.255.250"), 1900);
var data = System.Text.Encoding.UTF8.GetBytes(ssdpMessage);
await udpClient.SendAsync(data, data.Length, endpoint);
// Wait for responses
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
while (!cts.Token.IsCancellationRequested)
{
var result = await udpClient.ReceiveAsync(cts.Token);
var response = System.Text.Encoding.UTF8.GetString(result.Buffer);
if (response.Contains("InternetGatewayDevice"))
{
var device = ParseUPnPResponse(response, result.RemoteEndPoint);
if (device != null)
devices.Add(device);
}
}
}
catch (OperationCanceledException)
{
// Timeout - this is expected
}
return devices;
}
private UPnPDevice? ParseUPnPResponse(string response, IPEndPoint endpoint)
{
try
{
var lines = response.Split('\n');
var location = lines.FirstOrDefault(l => l.StartsWith("LOCATION:", StringComparison.OrdinalIgnoreCase));
if (location != null)
{
var url = location.Substring("LOCATION:".Length).Trim();
return new UPnPDevice
{
ControlUrl = url,
IpAddress = endpoint.Address,
Port = endpoint.Port
};
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger?.LogWarning(ex, "Failed to parse UPnP response"); _logger?.LogError(ex, "Failed to get port mappings from UPnP device");
} lock (_lock)
return null;
}
private async Task<IPAddress?> GetExternalIpFromUPnPAsync()
{ {
try return _activeMappings.Values.ToList();
{
var devices = await DiscoverUPnPDevicesAsync();
if (!devices.Any())
return null;
// Use the first device to get external IP
var device = devices.First();
// This would require implementing SOAP calls to the UPnP device
// For now, we'll use the fallback method
return null;
} }
catch
{
return null;
} }
} }
@@ -264,17 +246,21 @@ public class UPnPManager : IUPnPManager
var response = await client.GetStringAsync(service); var response = await client.GetStringAsync(service);
var ipString = response.Trim(); var ipString = response.Trim();
if (IPAddress.TryParse(ipString, out var ip)) if (IPAddress.TryParse(ipString, out var ip))
{
_logger?.LogInformation("External IP from service {Service}: {IP}", service, ip);
return ip; return ip;
} }
catch }
catch (Exception ex)
{ {
_logger?.LogDebug(ex, "Failed to get external IP from {Service}", service);
continue; continue;
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger?.LogWarning(ex, "Failed to get external IP from services"); _logger?.LogWarning(ex, "Failed to get external IP from any service");
} }
return null; return null;
@@ -295,75 +281,6 @@ public class UPnPManager : IUPnPManager
return null; return null;
} }
} }
private async Task<bool> AddUPnPPortMappingAsync(int externalPort, int internalPort, IPAddress internalIp, string description)
{
try
{
var devices = await DiscoverUPnPDevicesAsync();
if (!devices.Any())
return false;
// This would require implementing SOAP calls to add port mapping
// For now, we'll simulate success
_logger?.LogInformation("Simulating UPnP port mapping: {ExternalPort} -> {InternalIp}:{InternalPort}",
externalPort, internalIp, internalPort);
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to add UPnP port mapping");
return false;
}
}
private async Task<bool> RemoveUPnPPortMappingAsync(int externalPort)
{
try
{
var devices = await DiscoverUPnPDevicesAsync();
if (!devices.Any())
return false;
// This would require implementing SOAP calls to remove port mapping
// For now, we'll simulate success
_logger?.LogInformation("Simulating UPnP port mapping removal: {ExternalPort}", externalPort);
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to remove UPnP port mapping");
return false;
}
}
private async Task<List<PortMapping>> GetUPnPPortMappingsAsync()
{
try
{
var devices = await DiscoverUPnPDevicesAsync();
if (!devices.Any())
return new List<PortMapping>();
// This would require implementing SOAP calls to get port mappings
// For now, we'll return empty list
return new List<PortMapping>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to get UPnP port mappings");
return new List<PortMapping>();
}
}
}
public class UPnPDevice
{
public string ControlUrl { get; set; } = string.Empty;
public IPAddress IpAddress { get; set; } = IPAddress.Any;
public int Port { get; set; }
} }
public class PortMapping public class PortMapping

View File

@@ -24,10 +24,7 @@
"isBoot": false "isBoot": false
} }
], ],
"cdrom": { "cdrom":"ubuntu-24.04.3-desktop-amd64.iso"
"path": "ubuntu-24.04.3-desktop-amd64.iso",
"isBoot": true
}
}, },
"network": { "network": {
"backend": "user", "backend": "user",