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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/vault/profile.py ADDED
@@ -0,0 +1,219 @@
1
+ """
2
+ Connection profile definitions for SSH sessions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum, auto
8
+ from typing import Optional, List
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class AuthMethod(Enum):
15
+ """SSH authentication method."""
16
+ PASSWORD = auto()
17
+ KEY_FILE = auto()
18
+ KEY_STORED = auto() # Key data stored in vault
19
+ AGENT = auto()
20
+
21
+
22
+ @dataclass
23
+ class AuthConfig:
24
+ """Authentication configuration."""
25
+ method: AuthMethod
26
+ username: str
27
+
28
+ # For PASSWORD method
29
+ password: Optional[str] = None
30
+
31
+ # For KEY_FILE method
32
+ key_path: Optional[str] = None
33
+
34
+ # For KEY_STORED method
35
+ key_data: Optional[str] = None
36
+
37
+ # For KEY_FILE and KEY_STORED
38
+ key_passphrase: Optional[str] = None
39
+
40
+ @classmethod
41
+ def password_auth(cls, username: str, password: str) -> AuthConfig:
42
+ """Create password authentication config."""
43
+ return cls(
44
+ method=AuthMethod.PASSWORD,
45
+ username=username,
46
+ password=password,
47
+ )
48
+
49
+ @classmethod
50
+ def key_file_auth(
51
+ cls,
52
+ username: str,
53
+ key_path: str,
54
+ passphrase: str = None
55
+ ) -> AuthConfig:
56
+ """Create key file authentication config."""
57
+ return cls(
58
+ method=AuthMethod.KEY_FILE,
59
+ username=username,
60
+ key_path=key_path,
61
+ key_passphrase=passphrase,
62
+ )
63
+
64
+ @classmethod
65
+ def key_data_auth(
66
+ cls,
67
+ username: str,
68
+ key_data: str,
69
+ passphrase: str = None
70
+ ) -> AuthConfig:
71
+ """Create stored key authentication config."""
72
+ return cls(
73
+ method=AuthMethod.KEY_STORED,
74
+ username=username,
75
+ key_data=key_data,
76
+ key_passphrase=passphrase,
77
+ )
78
+
79
+ @classmethod
80
+ def agent_auth(cls, username: str) -> AuthConfig:
81
+ """Create SSH agent authentication config."""
82
+ return cls(
83
+ method=AuthMethod.AGENT,
84
+ username=username,
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class JumpHostConfig:
90
+ """Jump host (bastion) configuration."""
91
+ hostname: str
92
+ port: int = 22
93
+ auth: Optional[AuthConfig] = None
94
+
95
+ # YubiKey / FIDO support
96
+ requires_touch: bool = False
97
+ touch_prompt: Optional[str] = None
98
+ touch_timeout: int = 30 # seconds
99
+
100
+ def __post_init__(self):
101
+ if self.requires_touch and not self.touch_prompt:
102
+ self.touch_prompt = f"Touch your security key for {self.hostname}..."
103
+
104
+
105
+ @dataclass
106
+ class ConnectionProfile:
107
+ """
108
+ Complete SSH connection profile.
109
+
110
+ Defines everything needed to establish an SSH connection,
111
+ including authentication, jump hosts, and reconnection behavior.
112
+ """
113
+ name: str
114
+ hostname: str
115
+ port: int = 22
116
+
117
+ # Authentication methods (tried in order)
118
+ auth_methods: List[AuthConfig] = field(default_factory=list)
119
+
120
+ # Jump hosts (chained in order)
121
+ jump_hosts: List[JumpHostConfig] = field(default_factory=list)
122
+
123
+ # Connection behavior
124
+ auto_reconnect: bool = False
125
+ reconnect_delay: float = 3.0
126
+ max_reconnect_attempts: int = 5
127
+ connect_timeout: float = 30.0
128
+ keepalive_interval: int = 60
129
+
130
+ # Terminal settings
131
+ terminal_type: str = "xterm-256color"
132
+ initial_env: dict = field(default_factory=dict)
133
+
134
+ # Matching metadata (for credential resolution)
135
+ match_patterns: List[str] = field(default_factory=list)
136
+ tags: List[str] = field(default_factory=list)
137
+
138
+ @property
139
+ def primary_username(self) -> Optional[str]:
140
+ """Get username from first auth method."""
141
+ if self.auth_methods:
142
+ return self.auth_methods[0].username
143
+ return None
144
+
145
+ @property
146
+ def has_jump_host(self) -> bool:
147
+ """Check if connection uses jump hosts."""
148
+ return len(self.jump_hosts) > 0
149
+
150
+ @property
151
+ def requires_touch(self) -> bool:
152
+ """Check if any jump host requires touch authentication."""
153
+ return any(jh.requires_touch for jh in self.jump_hosts)
154
+
155
+ def get_display_name(self) -> str:
156
+ """Get human-readable connection name."""
157
+ user = self.primary_username or "unknown"
158
+ if self.has_jump_host:
159
+ jump = self.jump_hosts[0].hostname
160
+ return f"{user}@{self.hostname} (via {jump})"
161
+ return f"{user}@{self.hostname}"
162
+
163
+ def to_dict(self) -> dict:
164
+ """Serialize profile to dictionary."""
165
+ return {
166
+ "name": self.name,
167
+ "hostname": self.hostname,
168
+ "port": self.port,
169
+ "auto_reconnect": self.auto_reconnect,
170
+ "reconnect_delay": self.reconnect_delay,
171
+ "max_reconnect_attempts": self.max_reconnect_attempts,
172
+ "connect_timeout": self.connect_timeout,
173
+ "keepalive_interval": self.keepalive_interval,
174
+ "terminal_type": self.terminal_type,
175
+ "match_patterns": self.match_patterns,
176
+ "tags": self.tags,
177
+ # Note: auth_methods and jump_hosts contain secrets
178
+ # and should not be serialized to plain dict
179
+ }
180
+
181
+ @classmethod
182
+ def simple(
183
+ cls,
184
+ hostname: str,
185
+ username: str,
186
+ password: str = None,
187
+ key_path: str = None,
188
+ port: int = 22,
189
+ ) -> ConnectionProfile:
190
+ """
191
+ Create a simple connection profile.
192
+
193
+ Args:
194
+ hostname: Target hostname
195
+ username: SSH username
196
+ password: Optional password
197
+ key_path: Optional path to private key
198
+ port: SSH port
199
+
200
+ Returns:
201
+ Configured ConnectionProfile
202
+ """
203
+ auth_methods = []
204
+
205
+ if key_path:
206
+ auth_methods.append(AuthConfig.key_file_auth(username, key_path))
207
+
208
+ # Try agent
209
+ auth_methods.append(AuthConfig.agent_auth(username))
210
+
211
+ if password:
212
+ auth_methods.append(AuthConfig.password_auth(username, password))
213
+
214
+ return cls(
215
+ name=f"{username}@{hostname}",
216
+ hostname=hostname,
217
+ port=port,
218
+ auth_methods=auth_methods,
219
+ )
@@ -0,0 +1,250 @@
1
+ """
2
+ Credential resolution - matches credentials to devices.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import fnmatch
7
+ import logging
8
+ from typing import Optional
9
+
10
+ from .store import CredentialStore, StoredCredential
11
+ from ..connection.profile import (
12
+ ConnectionProfile, AuthConfig, AuthMethod, JumpHostConfig
13
+ )
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class NoCredentialError(Exception):
19
+ """No matching credential found."""
20
+ pass
21
+
22
+
23
+ class CredentialResolver:
24
+ """
25
+ Resolves credentials for devices based on patterns and tags.
26
+ """
27
+
28
+ def __init__(self, store: CredentialStore = None):
29
+ """
30
+ Initialize resolver.
31
+
32
+ Args:
33
+ store: Credential store instance
34
+ """
35
+ self.store = store or CredentialStore()
36
+
37
+ @property
38
+ def db_path(self):
39
+ return self.store.db_path
40
+
41
+ def is_initialized(self) -> bool:
42
+ return self.store.is_initialized()
43
+
44
+ def init_vault(self, password: str) -> None:
45
+ self.store.init_vault(password)
46
+
47
+ def unlock_vault(self, password: str) -> bool:
48
+ return self.store.unlock(password)
49
+
50
+ def lock_vault(self) -> None:
51
+ self.store.lock()
52
+
53
+ def add_credential(self, **kwargs) -> int:
54
+ return self.store.add_credential(**kwargs)
55
+
56
+ def get_credential(self, name: str) -> Optional[StoredCredential]:
57
+ return self.store.get_credential(name)
58
+
59
+ def list_credentials(self) -> list[StoredCredential]:
60
+ return self.store.list_credentials()
61
+
62
+ def remove_credential(self, name: str) -> bool:
63
+ return self.store.remove_credential(name)
64
+
65
+ def set_default(self, name: str) -> bool:
66
+ return self.store.set_default(name)
67
+
68
+ def resolve_for_device(
69
+ self,
70
+ hostname: str,
71
+ tags: list[str] = None,
72
+ port: int = 22,
73
+ ) -> ConnectionProfile:
74
+ """
75
+ Find best credential match for a device and return a connection profile.
76
+
77
+ Args:
78
+ hostname: Device hostname or IP
79
+ tags: Optional device tags
80
+ port: SSH port
81
+
82
+ Returns:
83
+ ConnectionProfile configured for this device
84
+
85
+ Raises:
86
+ NoCredentialError: No matching credential found
87
+ """
88
+ if not self.store.is_unlocked:
89
+ raise RuntimeError("Vault not unlocked")
90
+
91
+ creds = self._get_all_credentials()
92
+ candidates = []
93
+
94
+ for cred in creds:
95
+ score = self._score_credential(cred, hostname, tags)
96
+ if score > 0:
97
+ candidates.append((score, cred))
98
+
99
+ if not candidates:
100
+ raise NoCredentialError(f"No credential matches {hostname}")
101
+
102
+ # Highest score wins
103
+ candidates.sort(key=lambda x: x[0], reverse=True)
104
+ best_cred = candidates[0][1]
105
+
106
+ logger.info(f"Resolved credential '{best_cred.name}' for {hostname}")
107
+ self.store.update_last_used(best_cred.name)
108
+
109
+ return self._credential_to_profile(best_cred, hostname, port)
110
+
111
+ def _get_all_credentials(self) -> list[StoredCredential]:
112
+ """Get all credentials with decrypted secrets."""
113
+ # Use the internal connection to get full data
114
+ cursor = self.store._conn.execute("SELECT * FROM credentials")
115
+ return [self.store._row_to_credential(row) for row in cursor]
116
+
117
+ def _score_credential(
118
+ self,
119
+ cred: StoredCredential,
120
+ hostname: str,
121
+ tags: list[str] = None
122
+ ) -> int:
123
+ """
124
+ Score how well a credential matches a device.
125
+
126
+ Higher score = better match.
127
+ """
128
+ score = 0
129
+
130
+ # Check hostname patterns
131
+ for pattern in cred.match_hosts:
132
+ if fnmatch.fnmatch(hostname, pattern):
133
+ # More specific patterns score higher
134
+ specificity = len(pattern) - pattern.count('*') - pattern.count('?')
135
+ score += 10 + specificity
136
+ break
137
+
138
+ # Check tags
139
+ if tags and cred.match_tags:
140
+ matching_tags = set(tags) & set(cred.match_tags)
141
+ score += len(matching_tags) * 5
142
+
143
+ # Default credential gets lowest priority
144
+ if cred.is_default and score == 0:
145
+ score = 1
146
+
147
+ return score
148
+
149
+ def _credential_to_profile(
150
+ self,
151
+ cred: StoredCredential,
152
+ hostname: str,
153
+ port: int = 22
154
+ ) -> ConnectionProfile:
155
+ """Convert stored credential to connection profile."""
156
+
157
+ # Build auth methods list
158
+ auth_methods = []
159
+
160
+ # Key auth takes priority
161
+ if cred.ssh_key:
162
+ auth_methods.append(AuthConfig(
163
+ method=AuthMethod.KEY_STORED,
164
+ username=cred.username,
165
+ key_data=cred.ssh_key,
166
+ key_passphrase=cred.ssh_key_passphrase,
167
+ ))
168
+
169
+ # Password auth as fallback
170
+ if cred.password:
171
+ auth_methods.append(AuthConfig(
172
+ method=AuthMethod.PASSWORD,
173
+ username=cred.username,
174
+ password=cred.password,
175
+ ))
176
+
177
+ # Build jump host config if specified
178
+ jump_hosts = []
179
+ if cred.jump_host:
180
+ jump_auth = None
181
+ if cred.jump_auth_method == "agent":
182
+ jump_auth = AuthConfig.agent_auth(cred.jump_username or cred.username)
183
+ elif cred.jump_auth_method == "password" and cred.password:
184
+ jump_auth = AuthConfig.password_auth(
185
+ cred.jump_username or cred.username,
186
+ cred.password
187
+ )
188
+ elif cred.jump_auth_method == "key" and cred.ssh_key:
189
+ jump_auth = AuthConfig(
190
+ method=AuthMethod.KEY_STORED,
191
+ username=cred.jump_username or cred.username,
192
+ key_data=cred.ssh_key,
193
+ key_passphrase=cred.ssh_key_passphrase,
194
+ )
195
+
196
+ jump_hosts.append(JumpHostConfig(
197
+ hostname=cred.jump_host,
198
+ auth=jump_auth,
199
+ requires_touch=cred.jump_requires_touch,
200
+ ))
201
+
202
+ return ConnectionProfile(
203
+ name=f"{hostname} ({cred.name})",
204
+ hostname=hostname,
205
+ port=port,
206
+ auth_methods=auth_methods,
207
+ jump_hosts=jump_hosts,
208
+ match_patterns=cred.match_hosts,
209
+ tags=cred.match_tags,
210
+ )
211
+
212
+ def create_profile_for_credential(
213
+ self,
214
+ credential_name: str,
215
+ hostname: str,
216
+ port: int = 22
217
+ ) -> ConnectionProfile:
218
+ """
219
+ Create connection profile using a specific credential.
220
+
221
+ Args:
222
+ credential_name: Name of credential in vault
223
+ hostname: Target hostname
224
+ port: SSH port
225
+
226
+ Returns:
227
+ Configured ConnectionProfile
228
+ """
229
+ cred = self.store.get_credential(credential_name)
230
+ if not cred:
231
+ raise NoCredentialError(f"Credential '{credential_name}' not found")
232
+
233
+ self.store.update_last_used(credential_name)
234
+ return self._credential_to_profile(cred, hostname, port)
235
+
236
+ def resolve_or_default(
237
+ self,
238
+ hostname: str,
239
+ tags: list[str] = None,
240
+ port: int = 22,
241
+ ) -> Optional[ConnectionProfile]:
242
+ """
243
+ Try to resolve credential, return None if not found.
244
+
245
+ Unlike resolve_for_device, this doesn't raise an exception.
246
+ """
247
+ try:
248
+ return self.resolve_for_device(hostname, tags, port)
249
+ except NoCredentialError:
250
+ return None