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
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
|
+
)
|
nterm/vault/resolver.py
ADDED
|
@@ -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
|