Skip to content

Commit 16e9d16

Browse files
committed
Added password auth possibilitiy && port warning
1 parent f6dfafe commit 16e9d16

File tree

6 files changed

+233
-15
lines changed

6 files changed

+233
-15
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,17 @@ Then, you should be able to run it from your terminal (the exact command might d
4141
* **Simple Tunnel Control:** Start/stop tunnels with a click. Copy the underlying SSH command.
4242
* **Real-time Status:** Clear visual feedback on tunnel status (Idle, Starting, Running, Stopped, Errors).
4343
* **Automatic Persistence:** Profiles are saved to `~/.config/ssh_tunnel_manager/config.json`.
44+
* **Privileged Port Warnings:** Automatic detection and warnings for ports < 1024 that require sudo/root access.
4445

4546
## Configuration
4647

4748
Once the application is running:
4849

4950
* **Server:** Enter the SSH server address (`user@hostname`).
5051
* **SSH Port:** Specify the SSH server port (defaults to 22).
51-
* **SSH Key File:** (Optional) Path to your SSH private key (tilde `~` expansion supported).
52+
* **Authentication:** Choose between SSH Key (default) or Password authentication.
53+
* **SSH Key File:** (Optional) Path to your SSH private key (tilde `~` expansion supported).
54+
* **Password:** Enter your SSH password (requires `sshpass` to be installed).
5255
* **Port Forwarding:** Add/remove `Local Port` to `Remote Port` mappings.
5356
* **Profiles:** Save, load, or delete configurations. Changes are auto-saved.
5457

@@ -88,6 +91,10 @@ If you want to contribute or run the latest development version:
8891
* PyQt6 (`PyQt6>=6.0.0`)
8992
* `typing-extensions>=4.0.0`
9093
* An SSH client installed and available in your system's PATH (e.g., OpenSSH).
94+
* `sshpass` (optional, required for password authentication):
95+
* Ubuntu/Debian: `sudo apt install sshpass`
96+
* macOS: `brew install sshpass`
97+
* CentOS/RHEL: `sudo yum install sshpass`
9198

9299
### Building from Source
93100

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ssh-tunnel-manager-gui"
7-
version = "0.2.3"
7+
version = "0.2.4"
88
description = "A manager for SSH tunnels with a GUI."
99
readme = "README.md"
1010
requires-python = ">=3.8"

requirements.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
- **GUI Elements:**
77
- Input field: Server (e.g., `user@hostname`)
88
- Input field: SSH Port (e.g., `22`), numeric input preferred.
9-
- Input field: SSH Certificate Path (e.g., `/path/to/id_rsa`) with Browse button.
9+
- Authentication Method: Radio buttons to select between SSH Key and Password authentication.
10+
- Input field: SSH Certificate Path (e.g., `/path/to/id_rsa`) with Browse button (for SSH Key authentication).
11+
- Input field: SSH Password (for Password authentication, masked input).
1012
- Port Mapping Table/List:
1113
- Mechanism to add/remove/edit multiple mappings.
1214
- Each mapping specifies a local port and a remote port.
@@ -20,7 +22,10 @@
2022
- Handles tilde (`~`) expansion for key path.
2123
- Handles quoting for key paths with spaces.
2224
- Includes basic recommended SSH options for stability (e.g., `ExitOnForwardFailure`, `ConnectTimeout`).
23-
- Example: `ssh -p <port> -i <key_path> -L <local1>:localhost:<remote1> -L <local2>:localhost:<remote2> <server> -N -T -o ...`
25+
- SSH Key Example: `ssh -p <port> -i <key_path> -L <local1>:localhost:<remote1> -L <local2>:localhost:<remote2> <server> -N -T -o ...`
26+
- Password Example: `sshpass -p <password> ssh -p <port> -L <local1>:localhost:<remote1> -L <local2>:localhost:<remote2> <server> -N -T -o ...`
27+
- **Dependencies:**
28+
- For password authentication: `sshpass` must be installed on the system (e.g., `sudo apt install sshpass` on Ubuntu/Debian)
2429
- **Execution:** Button to execute the generated SSH command in the background.
2530
- **State Management:**
2631
- Visual feedback indicating if the tunnel for the current profile is idle, starting, running (including PID), or stopped.

ssh_tunnel_manager/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"server": "",
1111
"port": 22,
1212
"key_path": "",
13+
"auth_method": "key", # "key" or "password"
14+
"password": "", # Note: passwords are stored in plain text - consider security implications
1315
"port_mappings": [] # List of "local_port:remote_port" strings
1416
}
1517

ssh_tunnel_manager/gui.py

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
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
)
1314
from PyQt6.QtCore import Qt, QProcess, QProcessEnvironment, QTimer
1415
from 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

Comments
 (0)