@@ -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 ! ( "\n Port 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
533571fn 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