Skip to content

Commit 693be54

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 693be54

1 file changed

Lines changed: 151 additions & 18 deletions

File tree

crates/kit/src/libvirt/run.rs

Lines changed: 151 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use clap::{Parser, ValueEnum};
99
use color_eyre::eyre;
1010
use color_eyre::{eyre::Context, Result};
1111
use std::fs;
12+
use std::str::FromStr;
1213
use tracing::{debug, info};
1314

1415
use crate::common_opts::MemoryOpts;
@@ -52,6 +53,51 @@ pub enum FirmwareType {
5253
Bios,
5354
}
5455

56+
/// Port mapping from host to VM
57+
#[derive(Debug, Clone, PartialEq, Eq)]
58+
pub struct PortMapping {
59+
pub host_port: u16,
60+
pub guest_port: u16,
61+
}
62+
63+
impl FromStr for PortMapping {
64+
type Err = color_eyre::Report;
65+
66+
fn from_str(s: &str) -> Result<Self> {
67+
let (host_part, guest_part) = s.split_once(':').ok_or_else(|| {
68+
color_eyre::eyre::eyre!(
69+
"Invalid port format '{}'. Expected format: host_port:guest_port",
70+
s
71+
)
72+
})?;
73+
74+
let host_port = host_part.trim().parse::<u16>().map_err(|_| {
75+
color_eyre::eyre::eyre!(
76+
"Invalid host port '{}'. Must be a number between 1 and 65535",
77+
host_part
78+
)
79+
})?;
80+
81+
let guest_port = guest_part.trim().parse::<u16>().map_err(|_| {
82+
color_eyre::eyre::eyre!(
83+
"Invalid guest port '{}'. Must be a number between 1 and 65535",
84+
guest_part
85+
)
86+
})?;
87+
88+
Ok(PortMapping {
89+
host_port,
90+
guest_port,
91+
})
92+
}
93+
}
94+
95+
impl std::fmt::Display for PortMapping {
96+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97+
write!(f, "{}:{}", self.host_port, self.guest_port)
98+
}
99+
}
100+
55101
/// Options for creating and running a bootable container VM
56102
#[derive(Debug, Parser)]
57103
pub struct LibvirtRunOpts {
@@ -77,9 +123,9 @@ pub struct LibvirtRunOpts {
77123
#[clap(flatten)]
78124
pub install: InstallOptions,
79125

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

84130
/// Volume mount from host to VM
85131
#[clap(long = "volume", short = 'v', action = clap::ArgAction::Append)]
@@ -225,6 +271,17 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, opts: LibvirtRunOpts) -
225271
}
226272
}
227273

274+
// Display port forwarding information if any
275+
if !opts.port_mappings.is_empty() {
276+
println!("\nPort forwarding:");
277+
for mapping in opts.port_mappings.iter() {
278+
println!(
279+
" localhost:{} -> VM:{}",
280+
mapping.host_port, mapping.guest_port
281+
);
282+
}
283+
}
284+
228285
if opts.ssh {
229286
// Use the libvirt SSH functionality directly
230287
let ssh_opts = crate::libvirt::ssh::LibvirtSshOpts {
@@ -490,17 +547,15 @@ fn find_available_ssh_port() -> u16 {
490547

491548
/// Parse a volume mount string in the format "host_path:tag"
492549
fn parse_volume_mount(volume_str: &str) -> Result<(String, String)> {
493-
let parts: Vec<&str> = volume_str.splitn(2, ':').collect();
494-
495-
if parts.len() != 2 {
496-
return Err(color_eyre::eyre::eyre!(
550+
let (host_part, tag_part) = volume_str.split_once(':').ok_or_else(|| {
551+
color_eyre::eyre::eyre!(
497552
"Invalid volume format '{}'. Expected format: host_path:tag",
498553
volume_str
499-
));
500-
}
554+
)
555+
})?;
501556

502-
let host_path = parts[0].trim();
503-
let tag = parts[1].trim();
557+
let host_path = host_part.trim();
558+
let tag = tag_part.trim();
504559

505560
if host_path.is_empty() || tag.is_empty() {
506561
return Err(color_eyre::eyre::eyre!(
@@ -591,6 +646,60 @@ mod tests {
591646
assert!(result.is_err());
592647
assert!(result.unwrap_err().to_string().contains("does not exist"));
593648
}
649+
650+
#[test]
651+
fn test_parse_port_mapping_valid() {
652+
let result = "8080:80".parse::<PortMapping>();
653+
assert!(result.is_ok());
654+
let mapping = result.unwrap();
655+
assert_eq!(mapping.host_port, 8080);
656+
assert_eq!(mapping.guest_port, 80);
657+
}
658+
659+
#[test]
660+
fn test_parse_port_mapping_same_port() {
661+
let result = "80:80".parse::<PortMapping>();
662+
assert!(result.is_ok());
663+
let mapping = result.unwrap();
664+
assert_eq!(mapping.host_port, 80);
665+
assert_eq!(mapping.guest_port, 80);
666+
}
667+
668+
#[test]
669+
fn test_parse_port_mapping_invalid_format() {
670+
let result = "8080".parse::<PortMapping>();
671+
assert!(result.is_err());
672+
assert!(result
673+
.unwrap_err()
674+
.to_string()
675+
.contains("Expected format: host_port:guest_port"));
676+
}
677+
678+
#[test]
679+
fn test_parse_port_mapping_invalid_host_port() {
680+
let result = "abc:80".parse::<PortMapping>();
681+
assert!(result.is_err());
682+
assert!(result
683+
.unwrap_err()
684+
.to_string()
685+
.contains("Invalid host port"));
686+
}
687+
688+
#[test]
689+
fn test_parse_port_mapping_invalid_guest_port() {
690+
let result = "8080:xyz".parse::<PortMapping>();
691+
assert!(result.is_err());
692+
assert!(result
693+
.unwrap_err()
694+
.to_string()
695+
.contains("Invalid guest port"));
696+
}
697+
698+
#[test]
699+
fn test_parse_port_mapping_port_out_of_range() {
700+
let result = "70000:80".parse::<PortMapping>();
701+
assert!(result.is_err());
702+
}
594703
}
595704

596705
/// Create a libvirt domain directly from a disk image file
@@ -754,15 +863,39 @@ fn create_libvirt_domain_from_disk(
754863
.with_metadata("bootc:storage-path", storage_path.as_str());
755864
}
756865

866+
// Build QEMU args with port forwarding
867+
let mut qemu_args = vec![
868+
"-smbios".to_string(),
869+
format!("type=11,value={}", smbios_cred),
870+
];
871+
872+
// Build netdev user mode networking with port forwarding
873+
let mut hostfwd_args = vec![format!("tcp::{}-:22", ssh_port)];
874+
875+
// Add user-specified port mappings
876+
for mapping in opts.port_mappings.iter() {
877+
hostfwd_args.push(format!(
878+
"tcp::{}-:{}",
879+
mapping.host_port, mapping.guest_port
880+
));
881+
}
882+
883+
let netdev_config = format!(
884+
"user,id=ssh0,{}",
885+
hostfwd_args
886+
.iter()
887+
.map(|fwd| format!("hostfwd={}", fwd))
888+
.collect::<Vec<_>>()
889+
.join(",")
890+
);
891+
892+
qemu_args.push("-netdev".to_string());
893+
qemu_args.push(netdev_config);
894+
qemu_args.push("-device".to_string());
895+
qemu_args.push("virtio-net-pci,netdev=ssh0,addr=0x3".to_string());
896+
757897
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-
])
898+
.with_qemu_args(qemu_args)
766899
.build_xml()
767900
.with_context(|| "Failed to build domain XML")?;
768901

0 commit comments

Comments
 (0)