ntermqt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- ntermqt-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connection profiles - everything needed to establish and re-establish a connection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import json
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthMethod(Enum):
|
|
14
|
+
"""Supported authentication methods."""
|
|
15
|
+
PASSWORD = "password"
|
|
16
|
+
KEY_FILE = "key_file"
|
|
17
|
+
KEY_STORED = "key_stored" # Key material in vault
|
|
18
|
+
AGENT = "agent" # ssh-agent (YubiKey/FIDO2)
|
|
19
|
+
KEYBOARD_INTERACTIVE = "keyboard_interactive"
|
|
20
|
+
CERTIFICATE = "certificate"
|
|
21
|
+
|
|
22
|
+
def requires_interaction(self) -> bool:
|
|
23
|
+
"""Does this method potentially need user interaction?"""
|
|
24
|
+
return self in (
|
|
25
|
+
AuthMethod.AGENT, # YubiKey touch
|
|
26
|
+
AuthMethod.KEYBOARD_INTERACTIVE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AuthConfig:
|
|
32
|
+
"""Authentication configuration for a single method."""
|
|
33
|
+
method: AuthMethod
|
|
34
|
+
username: str
|
|
35
|
+
|
|
36
|
+
# Password auth
|
|
37
|
+
password: Optional[str] = None
|
|
38
|
+
credential_ref: Optional[str] = None # Vault reference instead of plaintext
|
|
39
|
+
|
|
40
|
+
# Key auth
|
|
41
|
+
key_path: Optional[str] = None # Path to key file
|
|
42
|
+
key_data: Optional[str] = None # Raw key (from vault)
|
|
43
|
+
key_passphrase: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
# Certificate auth
|
|
46
|
+
cert_path: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
# Agent settings
|
|
49
|
+
agent_socket: Optional[str] = None # Override SSH_AUTH_SOCK
|
|
50
|
+
|
|
51
|
+
# Behavior
|
|
52
|
+
allow_agent_fallback: bool = False
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
"""Serialize, excluding secrets."""
|
|
56
|
+
d = {
|
|
57
|
+
'method': self.method.value,
|
|
58
|
+
'username': self.username,
|
|
59
|
+
'key_path': self.key_path,
|
|
60
|
+
'cert_path': self.cert_path,
|
|
61
|
+
'credential_ref': self.credential_ref,
|
|
62
|
+
'allow_agent_fallback': self.allow_agent_fallback,
|
|
63
|
+
}
|
|
64
|
+
# Never serialize: password, key_data, key_passphrase
|
|
65
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, data: dict) -> AuthConfig:
|
|
69
|
+
"""Deserialize from dict."""
|
|
70
|
+
data = data.copy()
|
|
71
|
+
data['method'] = AuthMethod(data['method'])
|
|
72
|
+
return cls(**data)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def password_auth(
|
|
76
|
+
cls,
|
|
77
|
+
username: str,
|
|
78
|
+
password: str = None,
|
|
79
|
+
credential_ref: str = None
|
|
80
|
+
) -> AuthConfig:
|
|
81
|
+
"""Factory for password auth."""
|
|
82
|
+
return cls(
|
|
83
|
+
method=AuthMethod.PASSWORD,
|
|
84
|
+
username=username,
|
|
85
|
+
password=password,
|
|
86
|
+
credential_ref=credential_ref,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def agent_auth(cls, username: str, allow_fallback: bool = False) -> AuthConfig:
|
|
91
|
+
"""Factory for ssh-agent auth (YubiKey, etc)."""
|
|
92
|
+
return cls(
|
|
93
|
+
method=AuthMethod.AGENT,
|
|
94
|
+
username=username,
|
|
95
|
+
allow_agent_fallback=allow_fallback,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def key_file_auth(
|
|
100
|
+
cls,
|
|
101
|
+
username: str,
|
|
102
|
+
key_path: str,
|
|
103
|
+
passphrase: str = None
|
|
104
|
+
) -> AuthConfig:
|
|
105
|
+
"""Factory for key file auth."""
|
|
106
|
+
return cls(
|
|
107
|
+
method=AuthMethod.KEY_FILE,
|
|
108
|
+
username=username,
|
|
109
|
+
key_path=key_path,
|
|
110
|
+
key_passphrase=passphrase,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def stored_key_auth(
|
|
115
|
+
cls,
|
|
116
|
+
username: str,
|
|
117
|
+
credential_ref: str,
|
|
118
|
+
allow_fallback: bool = False
|
|
119
|
+
) -> AuthConfig:
|
|
120
|
+
"""Factory for vault-stored key auth."""
|
|
121
|
+
return cls(
|
|
122
|
+
method=AuthMethod.KEY_STORED,
|
|
123
|
+
username=username,
|
|
124
|
+
credential_ref=credential_ref,
|
|
125
|
+
allow_agent_fallback=allow_fallback,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class JumpHostConfig:
|
|
131
|
+
"""Jump host / bastion configuration."""
|
|
132
|
+
hostname: str
|
|
133
|
+
port: int = 22
|
|
134
|
+
auth: AuthConfig = None
|
|
135
|
+
|
|
136
|
+
# Interaction hints for UI
|
|
137
|
+
requires_touch: bool = False
|
|
138
|
+
touch_prompt: str = "Touch your security key..."
|
|
139
|
+
banner_timeout: float = 30.0
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict:
|
|
142
|
+
"""Serialize to dict."""
|
|
143
|
+
return {
|
|
144
|
+
'hostname': self.hostname,
|
|
145
|
+
'port': self.port,
|
|
146
|
+
'auth': self.auth.to_dict() if self.auth else None,
|
|
147
|
+
'requires_touch': self.requires_touch,
|
|
148
|
+
'touch_prompt': self.touch_prompt,
|
|
149
|
+
'banner_timeout': self.banner_timeout,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_dict(cls, data: dict) -> JumpHostConfig:
|
|
154
|
+
"""Deserialize from dict."""
|
|
155
|
+
data = data.copy()
|
|
156
|
+
if data.get('auth'):
|
|
157
|
+
data['auth'] = AuthConfig.from_dict(data['auth'])
|
|
158
|
+
return cls(**data)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class ConnectionProfile:
|
|
163
|
+
"""
|
|
164
|
+
Complete connection specification.
|
|
165
|
+
|
|
166
|
+
This is the "recipe" for a connection - everything needed to
|
|
167
|
+
establish it, and re-establish it if dropped.
|
|
168
|
+
"""
|
|
169
|
+
name: str
|
|
170
|
+
hostname: str
|
|
171
|
+
port: int = 22
|
|
172
|
+
|
|
173
|
+
# Auth methods - tried in order until one succeeds
|
|
174
|
+
auth_methods: list[AuthConfig] = field(default_factory=list)
|
|
175
|
+
|
|
176
|
+
# Jump host chain (in order)
|
|
177
|
+
jump_hosts: list[JumpHostConfig] = field(default_factory=list)
|
|
178
|
+
|
|
179
|
+
# Terminal settings
|
|
180
|
+
term_type: str = "xterm-256color"
|
|
181
|
+
term_cols: int = 120
|
|
182
|
+
term_rows: int = 40
|
|
183
|
+
|
|
184
|
+
# Connection behavior
|
|
185
|
+
keepalive_interval: int = 30
|
|
186
|
+
keepalive_count_max: int = 3
|
|
187
|
+
connect_timeout: float = 30.0
|
|
188
|
+
|
|
189
|
+
# Reconnection policy
|
|
190
|
+
auto_reconnect: bool = True
|
|
191
|
+
reconnect_delay: float = 2.0
|
|
192
|
+
reconnect_max_attempts: int = 5
|
|
193
|
+
reconnect_backoff: float = 1.5 # Exponential backoff multiplier
|
|
194
|
+
|
|
195
|
+
# Matching rules (for credential resolver)
|
|
196
|
+
match_patterns: list[str] = field(default_factory=list)
|
|
197
|
+
tags: list[str] = field(default_factory=list)
|
|
198
|
+
|
|
199
|
+
# Metadata
|
|
200
|
+
description: str = ""
|
|
201
|
+
group: str = "" # For UI grouping
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict:
|
|
204
|
+
"""Serialize to dict (for saving)."""
|
|
205
|
+
return {
|
|
206
|
+
'name': self.name,
|
|
207
|
+
'hostname': self.hostname,
|
|
208
|
+
'port': self.port,
|
|
209
|
+
'auth_methods': [a.to_dict() for a in self.auth_methods],
|
|
210
|
+
'jump_hosts': [j.to_dict() for j in self.jump_hosts],
|
|
211
|
+
'term_type': self.term_type,
|
|
212
|
+
'term_cols': self.term_cols,
|
|
213
|
+
'term_rows': self.term_rows,
|
|
214
|
+
'keepalive_interval': self.keepalive_interval,
|
|
215
|
+
'keepalive_count_max': self.keepalive_count_max,
|
|
216
|
+
'connect_timeout': self.connect_timeout,
|
|
217
|
+
'auto_reconnect': self.auto_reconnect,
|
|
218
|
+
'reconnect_delay': self.reconnect_delay,
|
|
219
|
+
'reconnect_max_attempts': self.reconnect_max_attempts,
|
|
220
|
+
'reconnect_backoff': self.reconnect_backoff,
|
|
221
|
+
'match_patterns': self.match_patterns,
|
|
222
|
+
'tags': self.tags,
|
|
223
|
+
'description': self.description,
|
|
224
|
+
'group': self.group,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def from_dict(cls, data: dict) -> ConnectionProfile:
|
|
229
|
+
"""Deserialize from dict."""
|
|
230
|
+
data = data.copy()
|
|
231
|
+
data['auth_methods'] = [
|
|
232
|
+
AuthConfig.from_dict(a) for a in data.get('auth_methods', [])
|
|
233
|
+
]
|
|
234
|
+
data['jump_hosts'] = [
|
|
235
|
+
JumpHostConfig.from_dict(j) for j in data.get('jump_hosts', [])
|
|
236
|
+
]
|
|
237
|
+
return cls(**data)
|
|
238
|
+
|
|
239
|
+
def to_yaml(self) -> str:
|
|
240
|
+
"""Serialize to YAML string."""
|
|
241
|
+
return yaml.dump(self.to_dict(), default_flow_style=False, sort_keys=False)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_yaml(cls, yaml_str: str) -> ConnectionProfile:
|
|
245
|
+
"""Deserialize from YAML string."""
|
|
246
|
+
return cls.from_dict(yaml.safe_load(yaml_str))
|
|
247
|
+
|
|
248
|
+
def to_json(self, indent: int = 2) -> str:
|
|
249
|
+
"""Serialize to JSON string."""
|
|
250
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def from_json(cls, json_str: str) -> ConnectionProfile:
|
|
254
|
+
"""Deserialize from JSON string."""
|
|
255
|
+
return cls.from_dict(json.loads(json_str))
|
|
256
|
+
|
|
257
|
+
def save(self, path: str) -> None:
|
|
258
|
+
"""Save to file (YAML or JSON based on extension)."""
|
|
259
|
+
from pathlib import Path
|
|
260
|
+
p = Path(path)
|
|
261
|
+
content = self.to_yaml() if p.suffix in ('.yaml', '.yml') else self.to_json()
|
|
262
|
+
p.write_text(content)
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def load(cls, path: str) -> ConnectionProfile:
|
|
266
|
+
"""Load from file (YAML or JSON based on extension)."""
|
|
267
|
+
from pathlib import Path
|
|
268
|
+
p = Path(path)
|
|
269
|
+
content = p.read_text()
|
|
270
|
+
if p.suffix in ('.yaml', '.yml'):
|
|
271
|
+
return cls.from_yaml(content)
|
|
272
|
+
return cls.from_json(content)
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def requires_interaction(self) -> bool:
|
|
276
|
+
"""Does this connection require user interaction to establish?"""
|
|
277
|
+
for jump in self.jump_hosts:
|
|
278
|
+
if jump.requires_touch:
|
|
279
|
+
return True
|
|
280
|
+
if jump.auth and jump.auth.method.requires_interaction():
|
|
281
|
+
return True
|
|
282
|
+
return any(a.method.requires_interaction() for a in self.auth_methods)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def display_name(self) -> str:
|
|
286
|
+
"""User-friendly display string."""
|
|
287
|
+
if self.jump_hosts:
|
|
288
|
+
chain = " → ".join(j.hostname for j in self.jump_hosts)
|
|
289
|
+
return f"{chain} → {self.hostname}"
|
|
290
|
+
return self.hostname
|
|
291
|
+
|
|
292
|
+
def clone(self, **overrides) -> ConnectionProfile:
|
|
293
|
+
"""Create a copy with optional overrides."""
|
|
294
|
+
data = self.to_dict()
|
|
295
|
+
data.update(overrides)
|
|
296
|
+
return ConnectionProfile.from_dict(data)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session manager - tree-based session browser and storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .models import SavedSession, SessionFolder, SessionStore
|
|
6
|
+
from .tree import SessionTreeWidget
|
|
7
|
+
from .editor import SessionEditorDialog, QuickConnectDialog
|
|
8
|
+
from .settings import SettingsDialog, ThemePreview
|
|
9
|
+
from .io import (
|
|
10
|
+
export_sessions, import_sessions, import_terminal_telemetry,
|
|
11
|
+
ExportDialog, ImportDialog, ImportTerminalTelemetryDialog
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SavedSession",
|
|
16
|
+
"SessionFolder",
|
|
17
|
+
"SessionStore",
|
|
18
|
+
"SessionTreeWidget",
|
|
19
|
+
"SessionEditorDialog",
|
|
20
|
+
"QuickConnectDialog",
|
|
21
|
+
"SettingsDialog",
|
|
22
|
+
"ThemePreview",
|
|
23
|
+
"export_sessions",
|
|
24
|
+
"import_sessions",
|
|
25
|
+
"import_terminal_telemetry",
|
|
26
|
+
"ExportDialog",
|
|
27
|
+
"ImportDialog",
|
|
28
|
+
"ImportTerminalTelemetryDialog",
|
|
29
|
+
]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH Connection Dialog - Authentication configuration before connecting.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
from PyQt6.QtWidgets import (
|
|
8
|
+
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox,
|
|
9
|
+
QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
|
|
10
|
+
QFileDialog, QTabWidget, QWidget, QMessageBox
|
|
11
|
+
)
|
|
12
|
+
from PyQt6.QtCore import Qt
|
|
13
|
+
|
|
14
|
+
from nterm.connection.profile import ConnectionProfile, AuthConfig, AuthMethod
|
|
15
|
+
from nterm.vault.resolver import CredentialResolver
|
|
16
|
+
from nterm.manager import SavedSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConnectDialog(QDialog):
|
|
20
|
+
"""
|
|
21
|
+
Connection dialog for SSH authentication.
|
|
22
|
+
|
|
23
|
+
Shows target host info and allows selecting/configuring auth method.
|
|
24
|
+
Pre-fills from saved session credentials if available.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
session: SavedSession,
|
|
30
|
+
credential_resolver: CredentialResolver = None,
|
|
31
|
+
credential_names: List[str] = None,
|
|
32
|
+
parent=None
|
|
33
|
+
):
|
|
34
|
+
super().__init__(parent)
|
|
35
|
+
self.session = session
|
|
36
|
+
self.credential_resolver = credential_resolver
|
|
37
|
+
self.credential_names = credential_names or []
|
|
38
|
+
|
|
39
|
+
self._profile: Optional[ConnectionProfile] = None
|
|
40
|
+
|
|
41
|
+
self.setWindowTitle(f"Connect to {session.name}")
|
|
42
|
+
self.setMinimumWidth(450)
|
|
43
|
+
self.setModal(True)
|
|
44
|
+
|
|
45
|
+
self._setup_ui()
|
|
46
|
+
self._load_defaults()
|
|
47
|
+
|
|
48
|
+
def _setup_ui(self):
|
|
49
|
+
"""Build the dialog UI."""
|
|
50
|
+
layout = QVBoxLayout(self)
|
|
51
|
+
|
|
52
|
+
# Target info header
|
|
53
|
+
header = QGroupBox("Target")
|
|
54
|
+
header_layout = QFormLayout(header)
|
|
55
|
+
|
|
56
|
+
self._host_label = QLabel(f"{self.session.hostname}:{self.session.port}")
|
|
57
|
+
self._host_label.setStyleSheet("font-weight: bold;")
|
|
58
|
+
header_layout.addRow("Host:", self._host_label)
|
|
59
|
+
|
|
60
|
+
layout.addWidget(header)
|
|
61
|
+
|
|
62
|
+
# Auth method tabs
|
|
63
|
+
self._tabs = QTabWidget()
|
|
64
|
+
|
|
65
|
+
# Tab 1: Saved Credential
|
|
66
|
+
if self.credential_names:
|
|
67
|
+
cred_tab = QWidget()
|
|
68
|
+
cred_layout = QVBoxLayout(cred_tab)
|
|
69
|
+
|
|
70
|
+
form = QFormLayout()
|
|
71
|
+
self._cred_combo = QComboBox()
|
|
72
|
+
self._cred_combo.addItem("(none)", None)
|
|
73
|
+
for name in self.credential_names:
|
|
74
|
+
self._cred_combo.addItem(name, name)
|
|
75
|
+
|
|
76
|
+
# Pre-select if session has a credential
|
|
77
|
+
if self.session.credential_name:
|
|
78
|
+
idx = self._cred_combo.findData(self.session.credential_name)
|
|
79
|
+
if idx >= 0:
|
|
80
|
+
self._cred_combo.setCurrentIndex(idx)
|
|
81
|
+
|
|
82
|
+
form.addRow("Credential:", self._cred_combo)
|
|
83
|
+
cred_layout.addLayout(form)
|
|
84
|
+
|
|
85
|
+
# Show credential details
|
|
86
|
+
self._cred_info = QLabel()
|
|
87
|
+
self._cred_info.setWordWrap(True)
|
|
88
|
+
self._cred_info.setStyleSheet("color: #888; font-size: 11px;")
|
|
89
|
+
cred_layout.addWidget(self._cred_info)
|
|
90
|
+
|
|
91
|
+
self._cred_combo.currentIndexChanged.connect(self._on_credential_changed)
|
|
92
|
+
self._on_credential_changed() # Initial update
|
|
93
|
+
|
|
94
|
+
cred_layout.addStretch()
|
|
95
|
+
self._tabs.addTab(cred_tab, "Saved Credential")
|
|
96
|
+
|
|
97
|
+
# Tab 2: Password Auth
|
|
98
|
+
pw_tab = QWidget()
|
|
99
|
+
pw_layout = QFormLayout(pw_tab)
|
|
100
|
+
|
|
101
|
+
self._pw_username = QLineEdit()
|
|
102
|
+
self._pw_username.setPlaceholderText("e.g., admin")
|
|
103
|
+
pw_layout.addRow("Username:", self._pw_username)
|
|
104
|
+
|
|
105
|
+
self._pw_password = QLineEdit()
|
|
106
|
+
self._pw_password.setEchoMode(QLineEdit.EchoMode.Password)
|
|
107
|
+
self._pw_password.setPlaceholderText("Password")
|
|
108
|
+
pw_layout.addRow("Password:", self._pw_password)
|
|
109
|
+
|
|
110
|
+
self._tabs.addTab(pw_tab, "Password")
|
|
111
|
+
|
|
112
|
+
# Tab 3: Key File Auth
|
|
113
|
+
key_tab = QWidget()
|
|
114
|
+
key_layout = QFormLayout(key_tab)
|
|
115
|
+
|
|
116
|
+
self._key_username = QLineEdit()
|
|
117
|
+
self._key_username.setPlaceholderText("e.g., admin")
|
|
118
|
+
key_layout.addRow("Username:", self._key_username)
|
|
119
|
+
|
|
120
|
+
key_path_layout = QHBoxLayout()
|
|
121
|
+
self._key_path = QLineEdit()
|
|
122
|
+
self._key_path.setPlaceholderText("~/.ssh/id_rsa")
|
|
123
|
+
key_path_layout.addWidget(self._key_path)
|
|
124
|
+
|
|
125
|
+
browse_btn = QPushButton("Browse...")
|
|
126
|
+
browse_btn.clicked.connect(self._browse_key)
|
|
127
|
+
key_path_layout.addWidget(browse_btn)
|
|
128
|
+
key_layout.addRow("Key File:", key_path_layout)
|
|
129
|
+
|
|
130
|
+
self._key_passphrase = QLineEdit()
|
|
131
|
+
self._key_passphrase.setEchoMode(QLineEdit.EchoMode.Password)
|
|
132
|
+
self._key_passphrase.setPlaceholderText("(optional)")
|
|
133
|
+
key_layout.addRow("Passphrase:", self._key_passphrase)
|
|
134
|
+
|
|
135
|
+
self._tabs.addTab(key_tab, "Key File")
|
|
136
|
+
|
|
137
|
+
# Tab 4: SSH Agent
|
|
138
|
+
agent_tab = QWidget()
|
|
139
|
+
agent_layout = QVBoxLayout(agent_tab)
|
|
140
|
+
|
|
141
|
+
agent_form = QFormLayout()
|
|
142
|
+
self._agent_username = QLineEdit()
|
|
143
|
+
self._agent_username.setPlaceholderText("e.g., admin")
|
|
144
|
+
agent_form.addRow("Username:", self._agent_username)
|
|
145
|
+
agent_layout.addLayout(agent_form)
|
|
146
|
+
|
|
147
|
+
agent_info = QLabel(
|
|
148
|
+
"Uses keys loaded in your SSH agent (ssh-agent, gpg-agent, etc.).\n"
|
|
149
|
+
"Make sure your agent is running and has keys loaded."
|
|
150
|
+
)
|
|
151
|
+
agent_info.setStyleSheet("color: #888; font-size: 11px;")
|
|
152
|
+
agent_info.setWordWrap(True)
|
|
153
|
+
agent_layout.addWidget(agent_info)
|
|
154
|
+
agent_layout.addStretch()
|
|
155
|
+
|
|
156
|
+
self._tabs.addTab(agent_tab, "SSH Agent")
|
|
157
|
+
|
|
158
|
+
layout.addWidget(self._tabs)
|
|
159
|
+
|
|
160
|
+
# Buttons
|
|
161
|
+
btn_layout = QHBoxLayout()
|
|
162
|
+
btn_layout.addStretch()
|
|
163
|
+
|
|
164
|
+
cancel_btn = QPushButton("Cancel")
|
|
165
|
+
cancel_btn.clicked.connect(self.reject)
|
|
166
|
+
btn_layout.addWidget(cancel_btn)
|
|
167
|
+
|
|
168
|
+
self._connect_btn = QPushButton("Connect")
|
|
169
|
+
self._connect_btn.setDefault(True)
|
|
170
|
+
self._connect_btn.clicked.connect(self._on_connect)
|
|
171
|
+
btn_layout.addWidget(self._connect_btn)
|
|
172
|
+
|
|
173
|
+
layout.addLayout(btn_layout)
|
|
174
|
+
|
|
175
|
+
def _load_defaults(self):
|
|
176
|
+
"""Load default values from session."""
|
|
177
|
+
import os
|
|
178
|
+
default_user = os.environ.get("USER", "admin")
|
|
179
|
+
|
|
180
|
+
# Set defaults for all username fields
|
|
181
|
+
self._pw_username.setText(default_user)
|
|
182
|
+
self._key_username.setText(default_user)
|
|
183
|
+
self._agent_username.setText(default_user)
|
|
184
|
+
|
|
185
|
+
# Default key path
|
|
186
|
+
default_key = Path.home() / ".ssh" / "id_rsa"
|
|
187
|
+
if default_key.exists():
|
|
188
|
+
self._key_path.setText(str(default_key))
|
|
189
|
+
|
|
190
|
+
# If session has a credential, select the credential tab
|
|
191
|
+
if self.session.credential_name and self.credential_names:
|
|
192
|
+
self._tabs.setCurrentIndex(0)
|
|
193
|
+
else:
|
|
194
|
+
# Default to SSH Agent tab if no saved credential
|
|
195
|
+
self._tabs.setCurrentIndex(self._tabs.count() - 1)
|
|
196
|
+
|
|
197
|
+
def _on_credential_changed(self):
|
|
198
|
+
"""Update credential info display."""
|
|
199
|
+
if not hasattr(self, '_cred_combo'):
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
cred_name = self._cred_combo.currentData()
|
|
203
|
+
if cred_name and self.credential_resolver:
|
|
204
|
+
try:
|
|
205
|
+
# Try to get credential info
|
|
206
|
+
creds = self.credential_resolver.list_credentials()
|
|
207
|
+
for cred in creds:
|
|
208
|
+
if cred.name == cred_name:
|
|
209
|
+
info_parts = [f"Username: {cred.username}"]
|
|
210
|
+
if hasattr(cred, 'auth_type'):
|
|
211
|
+
info_parts.append(f"Type: {cred.auth_type}")
|
|
212
|
+
if hasattr(cred, 'has_key') and cred.has_key:
|
|
213
|
+
info_parts.append("Has SSH key")
|
|
214
|
+
self._cred_info.setText("\n".join(info_parts))
|
|
215
|
+
return
|
|
216
|
+
except Exception as e:
|
|
217
|
+
self._cred_info.setText(f"Error loading credential: {e}")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
self._cred_info.setText("")
|
|
221
|
+
|
|
222
|
+
def _browse_key(self):
|
|
223
|
+
"""Browse for SSH key file."""
|
|
224
|
+
ssh_dir = Path.home() / ".ssh"
|
|
225
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
226
|
+
self,
|
|
227
|
+
"Select SSH Key",
|
|
228
|
+
str(ssh_dir) if ssh_dir.exists() else str(Path.home()),
|
|
229
|
+
"All Files (*)"
|
|
230
|
+
)
|
|
231
|
+
if path:
|
|
232
|
+
self._key_path.setText(path)
|
|
233
|
+
|
|
234
|
+
def _on_connect(self):
|
|
235
|
+
"""Build profile and accept dialog."""
|
|
236
|
+
try:
|
|
237
|
+
self._profile = self._build_profile()
|
|
238
|
+
if self._profile:
|
|
239
|
+
self.accept()
|
|
240
|
+
except Exception as e:
|
|
241
|
+
QMessageBox.warning(self, "Error", f"Failed to build connection profile:\n{e}")
|
|
242
|
+
|
|
243
|
+
def _build_profile(self) -> Optional[ConnectionProfile]:
|
|
244
|
+
"""Build ConnectionProfile from current settings."""
|
|
245
|
+
tab_index = self._tabs.currentIndex()
|
|
246
|
+
tab_text = self._tabs.tabText(tab_index)
|
|
247
|
+
|
|
248
|
+
auth_methods = []
|
|
249
|
+
|
|
250
|
+
if "Saved Credential" in tab_text:
|
|
251
|
+
cred_name = self._cred_combo.currentData()
|
|
252
|
+
if cred_name and self.credential_resolver:
|
|
253
|
+
try:
|
|
254
|
+
profile = self.credential_resolver.create_profile_for_credential(
|
|
255
|
+
cred_name,
|
|
256
|
+
self.session.hostname,
|
|
257
|
+
self.session.port
|
|
258
|
+
)
|
|
259
|
+
# DEBUG: Print what we got
|
|
260
|
+
print(f"DEBUG: Profile from vault for '{cred_name}':")
|
|
261
|
+
print(f" hostname: {profile.hostname}")
|
|
262
|
+
print(f" port: {profile.port}")
|
|
263
|
+
for i, auth in enumerate(profile.auth_methods):
|
|
264
|
+
print(f" auth[{i}]: method={auth.method}, user={auth.username}")
|
|
265
|
+
print(f" password set: {bool(auth.password)}")
|
|
266
|
+
print(f" credential_ref: {auth.credential_ref}")
|
|
267
|
+
print(f" key_path: {auth.key_path}")
|
|
268
|
+
print(f" key_data set: {bool(auth.key_data)}")
|
|
269
|
+
return profile
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise ValueError(f"Failed to load credential '{cred_name}': {e}")
|
|
272
|
+
else:
|
|
273
|
+
raise ValueError("No credential selected")
|
|
274
|
+
|
|
275
|
+
elif "Password" in tab_text:
|
|
276
|
+
username = self._pw_username.text().strip()
|
|
277
|
+
password = self._pw_password.text()
|
|
278
|
+
|
|
279
|
+
if not username:
|
|
280
|
+
raise ValueError("Username required")
|
|
281
|
+
if not password:
|
|
282
|
+
raise ValueError("Password required")
|
|
283
|
+
|
|
284
|
+
auth_methods.append(AuthConfig.password_auth(username, password))
|
|
285
|
+
|
|
286
|
+
elif "Key File" in tab_text:
|
|
287
|
+
username = self._key_username.text().strip()
|
|
288
|
+
key_path = self._key_path.text().strip()
|
|
289
|
+
passphrase = self._key_passphrase.text() or None
|
|
290
|
+
|
|
291
|
+
if not username:
|
|
292
|
+
raise ValueError("Username required")
|
|
293
|
+
if not key_path:
|
|
294
|
+
raise ValueError("Key file path required")
|
|
295
|
+
|
|
296
|
+
key_path_obj = Path(key_path).expanduser()
|
|
297
|
+
if not key_path_obj.exists():
|
|
298
|
+
raise ValueError(f"Key file not found: {key_path}")
|
|
299
|
+
|
|
300
|
+
auth_methods.append(AuthConfig.key_file_auth(
|
|
301
|
+
username=username,
|
|
302
|
+
key_path=str(key_path_obj),
|
|
303
|
+
passphrase=passphrase
|
|
304
|
+
))
|
|
305
|
+
|
|
306
|
+
elif "Agent" in tab_text:
|
|
307
|
+
username = self._agent_username.text().strip()
|
|
308
|
+
if not username:
|
|
309
|
+
raise ValueError("Username required")
|
|
310
|
+
|
|
311
|
+
auth_methods.append(AuthConfig.agent_auth(username))
|
|
312
|
+
|
|
313
|
+
return ConnectionProfile(
|
|
314
|
+
name=self.session.name,
|
|
315
|
+
hostname=self.session.hostname,
|
|
316
|
+
port=self.session.port,
|
|
317
|
+
auth_methods=auth_methods,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def get_profile(self) -> Optional[ConnectionProfile]:
|
|
321
|
+
"""Get the built connection profile (after accept)."""
|
|
322
|
+
return self._profile
|