@@ -9,6 +9,7 @@ use clap::{Parser, ValueEnum};
99use color_eyre:: eyre;
1010use color_eyre:: { eyre:: Context , Result } ;
1111use std:: fs;
12+ use std:: str:: FromStr ;
1213use tracing:: { debug, info} ;
1314
1415use 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 ) ]
57103pub 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 ! ( "\n Port 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"
492549fn 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