Skip to content

Commit ae4c8b0

Browse files
committed
libvirt run: Add port forwarding support
Add --port/-p option to forward ports from the host to the VM guest. The option accepts host_port:guest_port format (e.g., --port 8080:80) and can be specified multiple times for forwarding multiple ports. Implementation uses QEMU's user networking hostfwd parameter to forward TCP ports from the host to the VM. Port mappings are parsed and validated, with clear error messages for invalid formats. The forwarded ports are displayed to the user when the VM is created, similar to how volume mounts are shown. Assisted-by: Claude Code Signed-off-by: ckyrouac <ckyrouac@redhat.com>
1 parent 44eb365 commit ae4c8b0

1 file changed

Lines changed: 119 additions & 9 deletions

File tree

crates/kit/src/libvirt/run.rs

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ pub struct LibvirtRunOpts {
7777
#[clap(flatten)]
7878
pub install: InstallOptions,
7979

80-
/// Port mapping from host to VM
80+
/// Port mapping from host to VM (format: host_port:guest_port, e.g., 8080:80)
8181
#[clap(long = "port", short = 'p', action = clap::ArgAction::Append)]
8282
pub port_mappings: Vec<String>,
8383

@@ -225,6 +225,16 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtRunOpts) -
225225
}
226226
}
227227

228+
// Display port forwarding information if any
229+
if !opts.port_mappings.is_empty() {
230+
println!("\nPort forwarding:");
231+
for port_str in opts.port_mappings.iter() {
232+
if let Ok((host_port, guest_port)) = parse_port_mapping(port_str) {
233+
println!(" localhost:{} -> VM:{}", host_port, guest_port);
234+
}
235+
}
236+
}
237+
228238
if opts.ssh {
229239
// Use the libvirt SSH functionality directly
230240
let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts {
@@ -528,6 +538,34 @@ fn parse_volume_mount(volume_str: &str) -> Result<(String, String)> {
528538
Ok((host_path.to_string(), tag.to_string()))
529539
}
530540

541+
/// Parse a port mapping string in the format "host_port:guest_port"
542+
fn parse_port_mapping(port_str: &str) -> Result<(u16, u16)> {
543+
let parts: Vec<&str> = port_str.splitn(2, ':').collect();
544+
545+
if parts.len() != 2 {
546+
return Err(color_eyre::eyre::eyre!(
547+
"Invalid port format '{}'. Expected format: host_port:guest_port",
548+
port_str
549+
));
550+
}
551+
552+
let host_port = parts[0].trim().parse::<u16>().map_err(|_| {
553+
color_eyre::eyre::eyre!(
554+
"Invalid host port '{}'. Must be a number between 1 and 65535",
555+
parts[0]
556+
)
557+
})?;
558+
559+
let guest_port = parts[1].trim().parse::<u16>().map_err(|_| {
560+
color_eyre::eyre::eyre!(
561+
"Invalid guest port '{}'. Must be a number between 1 and 65535",
562+
parts[1]
563+
)
564+
})?;
565+
566+
Ok((host_port, guest_port))
567+
}
568+
531569
/// Check if the libvirt version supports readonly virtiofs filesystems
532570
/// Requires libvirt 11.0+ and modern QEMU with rust-based virtiofsd
533571
fn check_libvirt_readonly_support() -> Result<()> {
@@ -591,6 +629,57 @@ mod tests {
591629
assert!(result.is_err());
592630
assert!(result.unwrap_err().to_string().contains("does not exist"));
593631
}
632+
633+
#[test]
634+
fn test_parse_port_mapping_valid() {
635+
let result = parse_port_mapping("8080:80");
636+
assert!(result.is_ok());
637+
let (host, guest) = result.unwrap();
638+
assert_eq!(host, 8080);
639+
assert_eq!(guest, 80);
640+
}
641+
642+
#[test]
643+
fn test_parse_port_mapping_same_port() {
644+
let result = parse_port_mapping("80:80");
645+
assert!(result.is_ok());
646+
let (host, guest) = result.unwrap();
647+
assert_eq!(host, 80);
648+
assert_eq!(guest, 80);
649+
}
650+
651+
#[test]
652+
fn test_parse_port_mapping_invalid_format() {
653+
let result = parse_port_mapping("8080");
654+
assert!(result.is_err());
655+
assert!(result
656+
.unwrap_err()
657+
.to_string()
658+
.contains("Expected format: host_port:guest_port"));
659+
}
660+
661+
#[test]
662+
fn test_parse_port_mapping_invalid_host_port() {
663+
let result = parse_port_mapping("abc:80");
664+
assert!(result.is_err());
665+
assert!(result.unwrap_err().to_string().contains("Invalid host port"));
666+
}
667+
668+
#[test]
669+
fn test_parse_port_mapping_invalid_guest_port() {
670+
let result = parse_port_mapping("8080:xyz");
671+
assert!(result.is_err());
672+
assert!(result
673+
.unwrap_err()
674+
.to_string()
675+
.contains("Invalid guest port"));
676+
}
677+
678+
#[test]
679+
fn test_parse_port_mapping_port_out_of_range() {
680+
let result = parse_port_mapping("70000:80");
681+
assert!(result.is_err());
682+
}
594683
}
595684

596685
/// Create a libvirt domain directly from a disk image file
@@ -754,15 +843,36 @@ fn create_libvirt_domain_from_disk(
754843
.with_metadata("bootc:storage-path", storage_path.as_str());
755844
}
756845

846+
// Build QEMU args with port forwarding
847+
let mut qemu_args = vec![
848+
"-smbios".to_string(),
849+
format!("type=11,value={}", smbios_cred),
850+
];
851+
852+
// Build netdev user mode networking with port forwarding
853+
let mut hostfwd_args = vec![format!("tcp::{}-:22", ssh_port)];
854+
855+
// Add user-specified port mappings
856+
for port_str in opts.port_mappings.iter() {
857+
let (host_port, guest_port) = parse_port_mapping(port_str)
858+
.with_context(|| format!("Failed to parse port mapping '{}'", port_str))?;
859+
hostfwd_args.push(format!("tcp::{}-:{}", host_port, guest_port));
860+
}
861+
862+
let netdev_config = format!("user,id=ssh0,{}",
863+
hostfwd_args
864+
.iter()
865+
.map(|fwd| format!("hostfwd={}", fwd))
866+
.collect::<Vec<_>>()
867+
.join(","));
868+
869+
qemu_args.push("-netdev".to_string());
870+
qemu_args.push(netdev_config);
871+
qemu_args.push("-device".to_string());
872+
qemu_args.push("virtio-net-pci,netdev=ssh0,addr=0x3".to_string());
873+
757874
let domain_xml = domain_builder
758-
.with_qemu_args(vec![
759-
"-smbios".to_string(),
760-
format!("type=11,value={}", smbios_cred),
761-
"-netdev".to_string(),
762-
format!("user,id=ssh0,hostfwd=tcp::{}-:22", ssh_port),
763-
"-device".to_string(),
764-
"virtio-net-pci,netdev=ssh0,addr=0x3".to_string(),
765-
])
875+
.with_qemu_args(qemu_args)
766876
.build_xml()
767877
.with_context(|| "Failed to build domain XML")?;
768878

0 commit comments

Comments
 (0)