88 QApplication , QMainWindow , QWidget , QVBoxLayout , QHBoxLayout ,
99 QLabel , QLineEdit , QPushButton , QFileDialog , QComboBox ,
1010 QTableWidget , QTableWidgetItem , QAbstractItemView , QHeaderView ,
11- QMessageBox , QSpinBox , QFormLayout , QGroupBox , QInputDialog , QSizePolicy
11+ QMessageBox , QSpinBox , QFormLayout , QGroupBox , QInputDialog , QSizePolicy ,
12+ QRadioButton , QButtonGroup
1213)
1314from PyQt6 .QtCore import Qt , QProcess , QProcessEnvironment , QTimer
1415from PyQt6 .QtGui import QIcon , QCloseEvent
@@ -74,6 +75,11 @@ def __init__(self):
7475 self .server_input .textChanged .connect (self ._schedule_save_current_profile )
7576 self .port_input .valueChanged .connect (self ._schedule_save_current_profile )
7677 self .key_path_input .textChanged .connect (self ._schedule_save_current_profile )
78+ self .password_input .textChanged .connect (self ._schedule_save_current_profile )
79+
80+ # Connect authentication method change to update UI
81+ self .auth_key_radio .toggled .connect (self ._auth_method_changed )
82+ self .auth_password_radio .toggled .connect (self ._auth_method_changed )
7783
7884 # Connect tunnel control signals
7985 self .start_button .clicked .connect (self ._start_tunnel )
@@ -82,6 +88,7 @@ def __init__(self):
8288
8389 # --- Apply Initial State ---
8490 self ._update_ui_state ()
91+ self ._auth_method_changed () # Set initial field visibility
8592 QTimer .singleShot (0 , self ._adjust_window_height )
8693
8794 # Check if we should ask about creating a shortcut
@@ -113,29 +120,101 @@ def _create_connection_section(self):
113120 connection_layout = QFormLayout ()
114121 connection_layout .setRowWrapPolicy (QFormLayout .RowWrapPolicy .WrapLongRows )
115122
123+ server_layout = QHBoxLayout ()
124+ server_label = QLabel ("Server:" )
125+ server_label .setFixedWidth (100 ) # Same width as other labels for consistency
116126 self .server_input = QLineEdit ()
117127 self .server_input .setPlaceholderText ("user@hostname" )
118128 self .
server_input .
setToolTip (
"SSH server address (e.g., [email protected] )" )
119- connection_layout .addRow ("Server:" , self .server_input )
129+ server_layout .addWidget (server_label )
130+ server_layout .addWidget (self .server_input , 1 )
131+
132+ server_widget = QWidget ()
133+ server_widget .setLayout (server_layout )
134+ connection_layout .addRow (server_widget )
120135
121136 ssh_port_layout = QHBoxLayout ()
137+ port_label = QLabel ("SSH Port:" )
138+ port_label .setFixedWidth (100 ) # Same width as other labels for consistency
122139 self .port_input = QSpinBox ()
123140 self .port_input .setRange (1 , 65535 )
124141 self .port_input .setValue (22 )
125142 self .port_input .setToolTip ("SSH server port (1-65535)" )
143+ ssh_port_layout .addWidget (port_label )
126144 ssh_port_layout .addWidget (self .port_input )
127145 ssh_port_layout .addStretch ()
128- connection_layout .addRow ("SSH Port:" , ssh_port_layout )
129-
130- key_layout = QHBoxLayout ()
146+
147+ port_widget = QWidget ()
148+ port_widget .setLayout (ssh_port_layout )
149+ connection_layout .addRow (port_widget )
150+
151+ # Authentication method selection
152+ auth_layout = QHBoxLayout ()
153+ auth_label = QLabel ("Authentication:" )
154+ auth_label .setFixedWidth (100 ) # Same width as other labels for consistency
155+ self .auth_button_group = QButtonGroup ()
156+ self .auth_key_radio = QRadioButton ("SSH Key" )
157+ self .auth_password_radio = QRadioButton ("Password" )
158+ self .auth_key_radio .setChecked (True ) # Default to SSH key
159+ self .auth_key_radio .setToolTip ("Use SSH key file for authentication" )
160+ self .auth_password_radio .setToolTip ("Use password for authentication" )
161+
162+ self .auth_button_group .addButton (self .auth_key_radio )
163+ self .auth_button_group .addButton (self .auth_password_radio )
164+
165+ auth_layout .addWidget (auth_label )
166+ auth_layout .addWidget (self .auth_key_radio )
167+ auth_layout .addWidget (self .auth_password_radio )
168+ auth_layout .addStretch ()
169+
170+ auth_widget = QWidget ()
171+ auth_widget .setLayout (auth_layout )
172+ connection_layout .addRow (auth_widget )
173+
174+ # SSH Key File row - create a horizontal layout with label and controls
175+ ssh_key_row_layout = QHBoxLayout ()
176+ self .ssh_key_label = QLabel ("SSH Key File:" )
177+ self .ssh_key_label .setFixedWidth (100 ) # Fixed width for consistent alignment
178+
131179 self .key_path_input = QLineEdit ()
132- self .key_path_input .setPlaceholderText ("/path/to/your/private_key (optional) " )
180+ self .key_path_input .setPlaceholderText ("/path/to/your/private_key" )
133181 self .key_path_input .setToolTip ("Path to your SSH private key file (e.g., ~/.ssh/id_rsa)" )
134182 self .browse_button = QPushButton ("Browse..." )
135183 self .browse_button .setToolTip ("Browse for your SSH private key file." )
136- key_layout .addWidget (self .key_path_input , 1 )
137- key_layout .addWidget (self .browse_button )
138- connection_layout .addRow ("SSH Key File:" , key_layout )
184+
185+ ssh_key_row_layout .addWidget (self .ssh_key_label )
186+ ssh_key_row_layout .addWidget (self .key_path_input , 1 )
187+ ssh_key_row_layout .addWidget (self .browse_button )
188+
189+ self .ssh_key_widget = QWidget ()
190+ self .ssh_key_widget .setLayout (ssh_key_row_layout )
191+
192+ # Add the SSH key row to the main layout
193+ connection_layout .addRow (self .ssh_key_widget )
194+
195+ # Password row - create a horizontal layout with label and controls
196+ password_row_layout = QHBoxLayout ()
197+ self .password_label = QLabel ("Password:" )
198+ self .password_label .setFixedWidth (100 ) # Same width as SSH key label
199+
200+ self .password_input = QLineEdit ()
201+ self .password_input .setEchoMode (QLineEdit .EchoMode .Password )
202+ self .password_input .setPlaceholderText ("Enter SSH password" )
203+ self .password_input .setToolTip ("SSH password for authentication" )
204+
205+ # Add an invisible spacer to maintain consistent spacing with the SSH key row
206+ password_spacer = QWidget ()
207+ password_spacer .setFixedWidth (self .browse_button .sizeHint ().width ())
208+
209+ password_row_layout .addWidget (self .password_label )
210+ password_row_layout .addWidget (self .password_input , 1 )
211+ password_row_layout .addWidget (password_spacer )
212+
213+ self .password_widget = QWidget ()
214+ self .password_widget .setLayout (password_row_layout )
215+
216+ # Add the password row to the main layout
217+ connection_layout .addRow (self .password_widget )
139218
140219 connection_group .setLayout (connection_layout )
141220 self .main_layout .addWidget (connection_group )
@@ -241,6 +320,18 @@ def load_profile(self, profile_name: str):
241320 self .server_input .setText (profile_data .get ("server" , "" ))
242321 self .port_input .setValue (profile_data .get ("port" , 22 ))
243322 self .key_path_input .setText (profile_data .get ("key_path" , "" ))
323+
324+ # Handle authentication method - default to "key" for backward compatibility
325+ auth_method = profile_data .get ("auth_method" , "key" )
326+ if auth_method == "password" :
327+ self .auth_password_radio .setChecked (True )
328+ self .password_input .setText (profile_data .get ("password" , "" ))
329+ else :
330+ self .auth_key_radio .setChecked (True )
331+ # Don't clear password field - preserve any user input from current session
332+
333+ # Update field enabled states based on auth method
334+ self ._auth_method_changed ()
244335
245336 self .port_mappings_table .setRowCount (0 ) # Clear table
246337 mappings = profile_data .get ("port_mappings" , [])
@@ -290,8 +381,15 @@ def _update_ui_state(self):
290381 self .delete_profile_button .setEnabled (not is_running and can_delete )
291382 self .server_input .setEnabled (not is_running )
292383 self .port_input .setEnabled (not is_running )
384+
385+ # Authentication controls
386+ self .auth_key_radio .setEnabled (not is_running )
387+ self .auth_password_radio .setEnabled (not is_running )
388+ # Key/password fields are only enabled based on tunnel state (visibility is handled by _auth_method_changed)
293389 self .key_path_input .setEnabled (not is_running )
294390 self .browse_button .setEnabled (not is_running )
391+ self .password_input .setEnabled (not is_running )
392+
295393 self .port_mappings_table .setEnabled (not is_running )
296394 self .add_mapping_button .setEnabled (not is_running )
297395 self .remove_mapping_button .setEnabled (not is_running )
@@ -311,6 +409,21 @@ def _update_ui_state(self):
311409 self .status_label .setText ("Status: Idle" )
312410 self .status_label .setStyleSheet (f"color: { text_color } " ) # Use theme text color
313411
412+ def _auth_method_changed (self ):
413+ """Handles changes in authentication method selection."""
414+ is_key_auth = self .auth_key_radio .isChecked ()
415+
416+ # Show/hide the entire row widgets based on authentication method
417+ # Each widget contains both the label and input controls in a horizontal layout
418+ self .ssh_key_widget .setVisible (is_key_auth )
419+ self .password_widget .setVisible (not is_key_auth )
420+
421+ # Don't clear the fields - preserve user input so they can switch back
422+ # This way users don't lose their SSH key path or password when experimenting
423+
424+ # Trigger auto-save
425+ self ._schedule_save_current_profile ()
426+
314427 def _profile_changed (self , profile_name ):
315428 """Handles selection changes in the profile dropdown."""
316429 if profile_name and profile_name != self .current_profile_name :
@@ -341,6 +454,8 @@ def _get_current_profile_from_ui(self) -> Dict[str, Any]:
341454 "server" : self .server_input .text ().strip (),
342455 "port" : self .port_input .value (),
343456 "key_path" : self .key_path_input .text ().strip (),
457+ "auth_method" : "key" if self .auth_key_radio .isChecked () else "password" ,
458+ "password" : self .password_input .text (),
344459 "port_mappings" : mappings
345460 }
346461 return current_data
@@ -485,6 +600,7 @@ def _port_mapping_edited(self, item: QTableWidgetItem):
485600
486601 # Basic validation: Check if it's an integer
487602 is_valid = False
603+ port_num = None
488604 if text :
489605 try :
490606 port_num = int (text )
@@ -508,6 +624,13 @@ def _port_mapping_edited(self, item: QTableWidgetItem):
508624 # Reset to the table's base color to respect theme
509625 base_color = self .port_mappings_table .palette ().base ().color ()
510626 item .setBackground (base_color )
627+
628+ # Check for privileged port warning (only for local ports - column 0)
629+ if port_num is not None and col == 0 : # Local port column
630+ warning = utils .get_privileged_port_warning (port_num )
631+ if warning :
632+ QMessageBox .warning (self , "Privileged Port Warning" , warning )
633+
511634 # Trigger save only if the content is valid or empty
512635 self ._save_current_profile ()
513636 self ._adjust_window_height ()
@@ -547,6 +670,33 @@ def _start_tunnel(self):
547670 if not utils .is_valid_port (profile_data .get ("port" , - 1 )): # Use -1 for invalid default
548671 QMessageBox .critical (self , "Error" , f"Invalid SSH port: { profile_data .get ('port' )} " )
549672 return
673+
674+ # Check for privileged ports in port mappings
675+ privileged_ports = []
676+ for mapping_str in profile_data .get ("port_mappings" , []):
677+ parsed = utils .parse_port_mapping (mapping_str )
678+ if parsed :
679+ local_port , remote_port = parsed
680+ warning = utils .get_privileged_port_warning (local_port )
681+ if warning :
682+ privileged_ports .append (local_port )
683+
684+ # Warn about privileged ports before starting
685+ if privileged_ports :
686+ ports_str = ", " .join (map (str , privileged_ports ))
687+ reply = QMessageBox .question (
688+ self ,
689+ "Privileged Ports Detected" ,
690+ f"The following local ports require elevated privileges: { ports_str } \n \n "
691+ f"Ports below 1024 typically require root/sudo access to bind to. "
692+ f"The tunnel may fail to start unless you run this application with sudo.\n \n "
693+ f"Do you want to continue anyway?" ,
694+ QMessageBox .StandardButton .Yes | QMessageBox .StandardButton .No ,
695+ QMessageBox .StandardButton .No
696+ )
697+ if reply == QMessageBox .StandardButton .No :
698+ return
699+
550700 # Consider validating key path existence if provided?
551701 ssh_command_str = utils .generate_ssh_command (profile_data )
552702 if not ssh_command_str :
0 commit comments