illumio-pylo 0.3.10__py3-none-any.whl → 0.3.12__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.
- illumio_pylo/API/APIConnector.py +145 -103
- illumio_pylo/API/CredentialsManager.py +38 -0
- illumio_pylo/API/Explorer.py +44 -0
- illumio_pylo/API/JsonPayloadTypes.py +39 -0
- illumio_pylo/Helpers/exports.py +1 -1
- illumio_pylo/IPList.py +15 -8
- illumio_pylo/IPMap.py +9 -0
- illumio_pylo/__init__.py +1 -1
- illumio_pylo/cli/commands/__init__.py +1 -0
- illumio_pylo/cli/commands/credential_manager.py +379 -4
- illumio_pylo/cli/commands/label_delete_unused.py +79 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/app.js +449 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/index.html +168 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/styles.css +430 -0
- illumio_pylo/cli/commands/ven_duplicate_remover.py +145 -93
- illumio_pylo/utilities/cli.py +4 -1
- illumio_pylo/utilities/health_monitoring.py +5 -1
- {illumio_pylo-0.3.10.dist-info → illumio_pylo-0.3.12.dist-info}/METADATA +18 -11
- {illumio_pylo-0.3.10.dist-info → illumio_pylo-0.3.12.dist-info}/RECORD +22 -18
- {illumio_pylo-0.3.10.dist-info → illumio_pylo-0.3.12.dist-info}/WHEEL +1 -1
- {illumio_pylo-0.3.10.dist-info → illumio_pylo-0.3.12.dist-info/licenses}/LICENSE +0 -0
- {illumio_pylo-0.3.10.dist-info → illumio_pylo-0.3.12.dist-info}/top_level.txt +0 -0
|
@@ -25,6 +25,26 @@ class ServiceHrefRef(TypedDict):
|
|
|
25
25
|
class VirtualServiceHrefRef(TypedDict):
|
|
26
26
|
virtual_service: HrefReference
|
|
27
27
|
|
|
28
|
+
|
|
29
|
+
class LabelObjectUsageJsonStructure(TypedDict):
|
|
30
|
+
virtual_server: bool
|
|
31
|
+
label_group: bool
|
|
32
|
+
static_policy_scopes: bool
|
|
33
|
+
pairing_profile: bool
|
|
34
|
+
permission: bool
|
|
35
|
+
workload: bool
|
|
36
|
+
container_workload: bool
|
|
37
|
+
firewall_coexistence_scope: bool
|
|
38
|
+
containers_inherit_host_policy_scopes: bool
|
|
39
|
+
container_workload_profile: bool
|
|
40
|
+
blocked_connection_reject_scopes: bool
|
|
41
|
+
enforcement_boundary: bool
|
|
42
|
+
loopback_interfaces_in_policy_scopes: bool
|
|
43
|
+
ip_forwarding_enabled_scopes: bool
|
|
44
|
+
rule_hit_count_enabled_scopes: bool
|
|
45
|
+
label_mapping_rule: bool
|
|
46
|
+
virtual_service: bool
|
|
47
|
+
|
|
28
48
|
class LabelObjectJsonStructure(TypedDict):
|
|
29
49
|
created_at: str
|
|
30
50
|
created_by: Optional[HrefReferenceWithName]
|
|
@@ -33,6 +53,7 @@ class LabelObjectJsonStructure(TypedDict):
|
|
|
33
53
|
key: str
|
|
34
54
|
updated_at: str
|
|
35
55
|
updated_by: Optional[HrefReferenceWithName]
|
|
56
|
+
usage: Optional[LabelObjectUsageJsonStructure]
|
|
36
57
|
value: str
|
|
37
58
|
|
|
38
59
|
|
|
@@ -81,6 +102,7 @@ class WorkloadInterfaceObjectJsonStructure(TypedDict):
|
|
|
81
102
|
name: str
|
|
82
103
|
address: str
|
|
83
104
|
|
|
105
|
+
|
|
84
106
|
class WorkloadObjectJsonStructure(TypedDict):
|
|
85
107
|
created_at: str
|
|
86
108
|
created_by: Optional[HrefReferenceWithName]
|
|
@@ -95,6 +117,7 @@ class WorkloadObjectJsonStructure(TypedDict):
|
|
|
95
117
|
updated_at: str
|
|
96
118
|
updated_by: Optional[HrefReferenceWithName]
|
|
97
119
|
|
|
120
|
+
|
|
98
121
|
class WorkloadObjectCreateJsonStructure(TypedDict):
|
|
99
122
|
"""
|
|
100
123
|
This is the structure of the JSON payload for creating a workload.
|
|
@@ -106,14 +129,18 @@ class WorkloadObjectCreateJsonStructure(TypedDict):
|
|
|
106
129
|
name: NotRequired[str]
|
|
107
130
|
public_ip: NotRequired[Optional[str]]
|
|
108
131
|
|
|
132
|
+
|
|
109
133
|
class WorkloadObjectMultiCreateJsonStructure(WorkloadObjectCreateJsonStructure):
|
|
110
134
|
href: str
|
|
111
135
|
|
|
136
|
+
|
|
112
137
|
WorkloadObjectMultiCreateJsonRequestPayload = List[WorkloadObjectMultiCreateJsonStructure]
|
|
113
138
|
|
|
139
|
+
|
|
114
140
|
class WorkloadBulkUpdateEntryJsonStructure(WorkloadObjectCreateJsonStructure):
|
|
115
141
|
href: str
|
|
116
142
|
|
|
143
|
+
|
|
117
144
|
class WorkloadBulkUpdateResponseEntry(TypedDict):
|
|
118
145
|
href: str
|
|
119
146
|
status: Literal['updated', 'error', 'validation_failure']
|
|
@@ -150,11 +177,13 @@ class VenObjectJsonStructure(TypedDict):
|
|
|
150
177
|
os_platform: Optional[str]
|
|
151
178
|
uid: Optional[str]
|
|
152
179
|
|
|
180
|
+
|
|
153
181
|
class VENUnpairApiResponseSingleErrorObjectJsonStructure(TypedDict):
|
|
154
182
|
token: str
|
|
155
183
|
message: str
|
|
156
184
|
hrefs: List[str]
|
|
157
185
|
|
|
186
|
+
|
|
158
187
|
class VENUnpairApiResponseObjectJsonStructure(TypedDict):
|
|
159
188
|
errors: List[VENUnpairApiResponseSingleErrorObjectJsonStructure]
|
|
160
189
|
|
|
@@ -173,6 +202,7 @@ class RuleDirectServiceReferenceObjectJsonStructure(TypedDict):
|
|
|
173
202
|
class RuleObjectJsonStructure(TypedDict):
|
|
174
203
|
created_at: str
|
|
175
204
|
created_by: Optional[HrefReferenceWithName]
|
|
205
|
+
description: str
|
|
176
206
|
href: str
|
|
177
207
|
ingress_services: List[RuleDirectServiceReferenceObjectJsonStructure|RuleServiceReferenceObjectJsonStructure]
|
|
178
208
|
updated_at: str
|
|
@@ -219,26 +249,31 @@ class VirtualServiceObjectJsonStructure(TypedDict):
|
|
|
219
249
|
updated_at: str
|
|
220
250
|
updated_by: Optional[HrefReferenceWithName]
|
|
221
251
|
|
|
252
|
+
|
|
222
253
|
class NetworkDeviceConfigObjectJsonStructure(TypedDict):
|
|
223
254
|
device_type: Literal['switch']
|
|
224
255
|
name: str
|
|
225
256
|
|
|
257
|
+
|
|
226
258
|
class NetworkDeviceObjectJsonStructure(TypedDict):
|
|
227
259
|
href: str
|
|
228
260
|
config: NetworkDeviceConfigObjectJsonStructure
|
|
229
261
|
supported_endpoint_type: Literal['switch_port']
|
|
230
262
|
|
|
263
|
+
|
|
231
264
|
class NetworkDeviceEndpointConfigObjectJsonStructure(TypedDict):
|
|
232
265
|
type: Literal['switch_port']
|
|
233
266
|
name: str
|
|
234
267
|
workload_discovery: bool
|
|
235
268
|
|
|
269
|
+
|
|
236
270
|
class NetworkDeviceEndpointObjectJsonStructure(TypedDict):
|
|
237
271
|
href: str
|
|
238
272
|
config: NetworkDeviceEndpointConfigObjectJsonStructure
|
|
239
273
|
status: Literal['unmonitored', 'monitored']
|
|
240
274
|
workloads: List[HrefReference]
|
|
241
275
|
|
|
276
|
+
|
|
242
277
|
class SecurityPrincipalObjectJsonStructure(TypedDict):
|
|
243
278
|
created_at: str
|
|
244
279
|
created_by: Optional[HrefReferenceWithName]
|
|
@@ -247,6 +282,7 @@ class SecurityPrincipalObjectJsonStructure(TypedDict):
|
|
|
247
282
|
updated_at: str
|
|
248
283
|
updated_by: Optional[HrefReferenceWithName]
|
|
249
284
|
|
|
285
|
+
|
|
250
286
|
class LabelDimensionObjectStructure(TypedDict):
|
|
251
287
|
created_at: str
|
|
252
288
|
created_by: Optional[HrefReferenceWithName]
|
|
@@ -286,13 +322,16 @@ WorkloadsGetQueryLabelFilterJsonStructure = List[List[str]]
|
|
|
286
322
|
|
|
287
323
|
AuditLogApiEventType = Literal['agent.clone_detected', 'workloads.update', 'workload.update', 'workload_interfaces.update']
|
|
288
324
|
|
|
325
|
+
|
|
289
326
|
class AuditLogEntryJsonStructure(TypedDict):
|
|
290
327
|
event_type: AuditLogApiEventType
|
|
291
328
|
timestamp: str
|
|
292
329
|
|
|
330
|
+
|
|
293
331
|
class AuditLogApiRequestPayloadStructure(TypedDict):
|
|
294
332
|
pass
|
|
295
333
|
|
|
334
|
+
|
|
296
335
|
class AuditLogApiReplyEventJsonStructure(TypedDict):
|
|
297
336
|
pass
|
|
298
337
|
|
illumio_pylo/Helpers/exports.py
CHANGED
|
@@ -9,7 +9,7 @@ import illumio_pylo as pylo
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class ExcelHeader:
|
|
12
|
-
def __init__(self, name: str, nice_name: Optional[str] = None, max_width: Optional[int] = None, wrap_text: Optional[bool] = None, is_url:
|
|
12
|
+
def __init__(self, name: str, nice_name: Optional[str] = None, max_width: Optional[int] = None, wrap_text: Optional[bool] = None, is_url: bool = False, url_text: str = 'Link'):
|
|
13
13
|
self.name = name
|
|
14
14
|
self.nice_name:str = nice_name if nice_name is not None else name
|
|
15
15
|
self.max_width = max_width
|
illumio_pylo/IPList.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .API.JsonPayloadTypes import IPListObjectJsonStructure
|
|
2
|
-
from illumio_pylo import log
|
|
2
|
+
from illumio_pylo import log, IP4Map
|
|
3
3
|
from .Helpers import *
|
|
4
4
|
|
|
5
5
|
|
|
@@ -20,6 +20,7 @@ class IPList(pylo.ReferenceTracker):
|
|
|
20
20
|
self.description = description
|
|
21
21
|
self.raw_json = None
|
|
22
22
|
self.raw_entries = {}
|
|
23
|
+
self._ip4map: IP4Map = None
|
|
23
24
|
|
|
24
25
|
def count_entries(self) -> int:
|
|
25
26
|
return len(self.raw_entries)
|
|
@@ -56,15 +57,21 @@ class IPList(pylo.ReferenceTracker):
|
|
|
56
57
|
self.raw_entries[entry] = entry
|
|
57
58
|
|
|
58
59
|
def get_ip4map(self) -> pylo.IP4Map:
|
|
59
|
-
|
|
60
|
+
return self.ip4map
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
@property
|
|
63
|
+
def ip4map(self) -> pylo.IP4Map:
|
|
64
|
+
if self._ip4map is None:
|
|
65
|
+
new_map = pylo.IP4Map()
|
|
66
|
+
self._ip4map = new_map
|
|
67
|
+
|
|
68
|
+
for entry in self.raw_entries:
|
|
69
|
+
if entry[0] == '!':
|
|
70
|
+
new_map.subtract_from_text(entry[1:], ignore_ipv6=True)
|
|
71
|
+
else:
|
|
72
|
+
new_map.add_from_text(entry, ignore_ipv6=True)
|
|
66
73
|
|
|
67
|
-
return
|
|
74
|
+
return self._ip4map
|
|
68
75
|
|
|
69
76
|
def get_raw_entries_as_string_list(self, separator=',') -> str:
|
|
70
77
|
return pylo.string_list_to_text(self.raw_entries.values(), separator=separator)
|
illumio_pylo/IPMap.py
CHANGED
|
@@ -107,6 +107,15 @@ class IP4Map:
|
|
|
107
107
|
return True
|
|
108
108
|
return False
|
|
109
109
|
|
|
110
|
+
def match_single_ip(self, ip: str) -> bool:
|
|
111
|
+
ip_object = ipaddress.IPv4Address(ip)
|
|
112
|
+
for entry in self._entries:
|
|
113
|
+
if entry[start] <= int(ip_object) <= entry[end]:
|
|
114
|
+
return True
|
|
115
|
+
if entry[start] > int(ip_object):
|
|
116
|
+
return False
|
|
117
|
+
return False
|
|
118
|
+
|
|
110
119
|
def substract(self, another_map: 'IP4Map'):
|
|
111
120
|
affected_rows = 0
|
|
112
121
|
for entry in another_map._entries:
|
illumio_pylo/__init__.py
CHANGED
|
@@ -9,7 +9,7 @@ import paramiko
|
|
|
9
9
|
import illumio_pylo as pylo
|
|
10
10
|
import click
|
|
11
11
|
from illumio_pylo.API.CredentialsManager import get_all_credentials, create_credential_in_file, CredentialFileEntry, \
|
|
12
|
-
create_credential_in_default_file, \
|
|
12
|
+
create_credential_in_default_file, delete_credential_from_file, \
|
|
13
13
|
get_credentials_from_file, encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305, \
|
|
14
14
|
decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305, get_supported_keys_from_ssh_agent, is_encryption_available
|
|
15
15
|
|
|
@@ -42,15 +42,42 @@ def fill_parser(parser: argparse.ArgumentParser):
|
|
|
42
42
|
create_parser.add_argument('--verify-ssl', required=False, type=bool, default=None,
|
|
43
43
|
help='Verify SSL')
|
|
44
44
|
|
|
45
|
+
update_parser = sub_parser.add_parser('update', help='Update a credential')
|
|
46
|
+
update_parser.add_argument('--name', required=False, type=str, default=None,
|
|
47
|
+
help='Name of the credential to update')
|
|
48
|
+
|
|
49
|
+
update_parser.add_argument('--fqdn', required=False, type=str, default=None,
|
|
50
|
+
help='FQDN of the PCE')
|
|
51
|
+
update_parser.add_argument('--port', required=False, type=int, default=None,
|
|
52
|
+
help='Port of the PCE')
|
|
53
|
+
update_parser.add_argument('--org', required=False, type=int, default=None,
|
|
54
|
+
help='Organization ID')
|
|
55
|
+
update_parser.add_argument('--api-user', required=False, type=str, default=None,
|
|
56
|
+
help='API user')
|
|
57
|
+
update_parser.add_argument('--verify-ssl', required=False, type=bool, default=None,
|
|
58
|
+
help='Verify SSL')
|
|
59
|
+
|
|
60
|
+
# Delete sub-command
|
|
61
|
+
delete_parser = sub_parser.add_parser('delete', help='Delete a credential')
|
|
62
|
+
delete_parser.add_argument('--name', required=False, type=str, default=None,
|
|
63
|
+
help='Name of the credential to delete')
|
|
64
|
+
delete_parser.add_argument('--yes', '-y', action='store_true', default=False,
|
|
65
|
+
help='Skip confirmation prompt')
|
|
66
|
+
|
|
67
|
+
# Web editor sub-command
|
|
68
|
+
web_editor_parser = sub_parser.add_parser('web-editor', help='Start web-based credential editor')
|
|
69
|
+
web_editor_parser.add_argument('--host', required=False, type=str, default='127.0.0.1',
|
|
70
|
+
help='Host to bind the web server to')
|
|
71
|
+
web_editor_parser.add_argument('--port', required=False, type=int, default=5000,
|
|
72
|
+
help='Port to bind the web server to')
|
|
73
|
+
|
|
45
74
|
|
|
46
75
|
def __main(args, **kwargs):
|
|
47
76
|
if args['sub_command'] == 'list':
|
|
48
77
|
table = PrettyTable(field_names=["Name", "URL", "API User", "Originating File"])
|
|
49
|
-
# all should be left justified
|
|
50
78
|
table.align = "l"
|
|
51
79
|
|
|
52
80
|
credentials = get_all_credentials()
|
|
53
|
-
# sort credentials by name
|
|
54
81
|
credentials.sort(key=lambda x: x.name)
|
|
55
82
|
|
|
56
83
|
for credential in credentials:
|
|
@@ -153,6 +180,116 @@ def __main(args, **kwargs):
|
|
|
153
180
|
|
|
154
181
|
print("OK! ({})".format(file_path))
|
|
155
182
|
|
|
183
|
+
elif args['sub_command'] == 'update':
|
|
184
|
+
# if name is not provided, prompt for it
|
|
185
|
+
if args['name'] is None:
|
|
186
|
+
wanted_name = click.prompt('> Input a Profile Name to update (ie: prod-pce)', type=str)
|
|
187
|
+
args['name'] = wanted_name
|
|
188
|
+
|
|
189
|
+
# find the credential by name
|
|
190
|
+
wanted_name = args['name']
|
|
191
|
+
found_profile = get_credentials_from_file(wanted_name, fail_with_an_exception=False)
|
|
192
|
+
if found_profile is None:
|
|
193
|
+
print("Cannot find a profile named '{}'".format(wanted_name))
|
|
194
|
+
print("Available profiles:")
|
|
195
|
+
credentials = get_all_credentials()
|
|
196
|
+
for credential in credentials:
|
|
197
|
+
print(" - {}".format(credential.name))
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
print("Found profile '{}' to update in file '{}'".format(found_profile.name, found_profile.originating_file))
|
|
201
|
+
|
|
202
|
+
if args['fqdn'] is not None:
|
|
203
|
+
found_profile.fqdn = args['fqdn']
|
|
204
|
+
if args['port'] is not None:
|
|
205
|
+
found_profile.port = args['port']
|
|
206
|
+
if args['org'] is not None:
|
|
207
|
+
found_profile.org_id = args['org']
|
|
208
|
+
if args['api_user'] is not None:
|
|
209
|
+
found_profile.api_user = args['api_user']
|
|
210
|
+
if args['verify_ssl'] is not None:
|
|
211
|
+
found_profile.verify_ssl = args['verify_ssl']
|
|
212
|
+
|
|
213
|
+
# ask if user wants to update API key
|
|
214
|
+
update_api_key = click.prompt('> Do you want to update the API key? Y/N', type=bool)
|
|
215
|
+
if update_api_key:
|
|
216
|
+
api_key = click.prompt('> New API Key', hide_input=True)
|
|
217
|
+
found_profile.api_key = api_key
|
|
218
|
+
print()
|
|
219
|
+
|
|
220
|
+
if is_encryption_available():
|
|
221
|
+
encrypt_api_key = click.prompt('> Encrypt API (requires an SSH agent running and an RSA or Ed25519 key added to them) ? Y/N', type=bool)
|
|
222
|
+
if encrypt_api_key:
|
|
223
|
+
print("Available keys (ECDSA NISTPXXX keys and a few others are not supported and will be filtered out):")
|
|
224
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
225
|
+
|
|
226
|
+
# display a table of keys
|
|
227
|
+
print_keys(keys=ssh_keys, display_index=True)
|
|
228
|
+
print()
|
|
229
|
+
|
|
230
|
+
index_of_selected_key = click.prompt('> Select key by ID#', type=click.IntRange(0, len(ssh_keys)-1))
|
|
231
|
+
selected_ssh_key = ssh_keys[index_of_selected_key]
|
|
232
|
+
print("Selected key: {} | {} | {}".format(selected_ssh_key.get_name(),
|
|
233
|
+
selected_ssh_key.get_fingerprint().hex(),
|
|
234
|
+
selected_ssh_key.comment))
|
|
235
|
+
print(" * encrypting API key with selected key (you may be prompted by your SSH agent for confirmation or PIN code) ...", flush=True, end="")
|
|
236
|
+
# encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_fernet(ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
237
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
238
|
+
print("OK!")
|
|
239
|
+
print(" * trying to decrypt the encrypted API key...", flush=True, end="")
|
|
240
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(encrypted_api_key_payload=encrypted_api_key)
|
|
241
|
+
if decrypted_api_key != found_profile.api_key:
|
|
242
|
+
raise pylo.PyloEx("Decrypted API key does not match original API key")
|
|
243
|
+
print("OK!")
|
|
244
|
+
found_profile.api_key = encrypted_api_key
|
|
245
|
+
|
|
246
|
+
credentials_data: CredentialFileEntry = {
|
|
247
|
+
"name": found_profile.name,
|
|
248
|
+
"fqdn": found_profile.fqdn,
|
|
249
|
+
"port": found_profile.port,
|
|
250
|
+
"org_id": found_profile.org_id,
|
|
251
|
+
"api_user": found_profile.api_user,
|
|
252
|
+
"verify_ssl": found_profile.verify_ssl,
|
|
253
|
+
"api_key": found_profile.api_key
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
print("* Updating credential in file '{}'...".format(found_profile.originating_file), flush=True, end="")
|
|
257
|
+
create_credential_in_file(file_full_path=found_profile.originating_file, data=credentials_data, overwrite_existing_profile=True)
|
|
258
|
+
print("OK!")
|
|
259
|
+
|
|
260
|
+
elif args['sub_command'] == 'delete':
|
|
261
|
+
# if name is not provided, prompt for it
|
|
262
|
+
wanted_name = args['name']
|
|
263
|
+
if wanted_name is None:
|
|
264
|
+
wanted_name = click.prompt('> Input a Profile Name to delete (ie: prod-pce)', type=str)
|
|
265
|
+
|
|
266
|
+
# find the credential by name
|
|
267
|
+
found_profile = get_credentials_from_file(wanted_name, fail_with_an_exception=False)
|
|
268
|
+
if found_profile is None:
|
|
269
|
+
print("Cannot find a profile named '{}'".format(wanted_name))
|
|
270
|
+
print("Available profiles:")
|
|
271
|
+
credentials = get_all_credentials()
|
|
272
|
+
for credential in credentials:
|
|
273
|
+
print(" - {}".format(credential.name))
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
|
|
276
|
+
print("Found profile '{}' in file '{}'".format(found_profile.name, found_profile.originating_file))
|
|
277
|
+
print(" - FQDN: {}".format(found_profile.fqdn))
|
|
278
|
+
print(" - Port: {}".format(found_profile.port))
|
|
279
|
+
print(" - Org ID: {}".format(found_profile.org_id))
|
|
280
|
+
print(" - API User: {}".format(found_profile.api_user))
|
|
281
|
+
|
|
282
|
+
# Confirm deletion unless --yes flag is provided
|
|
283
|
+
if not args['yes']:
|
|
284
|
+
confirm = click.prompt('> Are you sure you want to delete this credential? Y/N', type=bool)
|
|
285
|
+
if not confirm:
|
|
286
|
+
print("Deletion cancelled.")
|
|
287
|
+
sys.exit(0)
|
|
288
|
+
|
|
289
|
+
print("* Deleting credential...", flush=True, end="")
|
|
290
|
+
delete_credential_from_file(profile_name=found_profile.name, file_path=found_profile.originating_file)
|
|
291
|
+
print("OK!")
|
|
292
|
+
|
|
156
293
|
elif args['sub_command'] == 'test':
|
|
157
294
|
print("* Profile Tester command")
|
|
158
295
|
wanted_name = args['name']
|
|
@@ -180,6 +317,9 @@ def __main(args, **kwargs):
|
|
|
180
317
|
connector.objects_label_dimension_get()
|
|
181
318
|
print("OK!")
|
|
182
319
|
|
|
320
|
+
elif args['sub_command'] == 'web-editor':
|
|
321
|
+
run_web_editor(host=args['host'], port=args['port'])
|
|
322
|
+
|
|
183
323
|
else:
|
|
184
324
|
raise pylo.PyloEx("Unknown sub-command '{}'".format(args['sub_command']))
|
|
185
325
|
|
|
@@ -189,7 +329,7 @@ command_object = Command(command_name, __main, fill_parser, credentials_manager_
|
|
|
189
329
|
|
|
190
330
|
def print_keys(keys: list[paramiko.AgentKey], display_index=True) -> None:
|
|
191
331
|
|
|
192
|
-
column_properties = [
|
|
332
|
+
column_properties = [
|
|
193
333
|
("ID#", 4),
|
|
194
334
|
("Type", 20),
|
|
195
335
|
("Fingerprint", 40),
|
|
@@ -214,3 +354,238 @@ def print_keys(keys: list[paramiko.AgentKey], display_index=True) -> None:
|
|
|
214
354
|
table.add_row(display_values)
|
|
215
355
|
|
|
216
356
|
print(table)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def run_web_editor(host: str = '127.0.0.1', port: int = 5000) -> None:
|
|
360
|
+
"""Start the Flask web server for credential management."""
|
|
361
|
+
try:
|
|
362
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
363
|
+
except ImportError:
|
|
364
|
+
print("Flask is not installed. Please install it with: pip install flask")
|
|
365
|
+
sys.exit(1)
|
|
366
|
+
|
|
367
|
+
# Determine paths for static files
|
|
368
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
369
|
+
web_editor_dir = os.path.join(current_dir, 'ui/credential_manager_ui')
|
|
370
|
+
# That directory should contain index.html, error if not
|
|
371
|
+
if not os.path.exists(os.path.join(web_editor_dir, 'index.html')):
|
|
372
|
+
print("Cannot find web editor static files in expected location: {}".format(web_editor_dir))
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
|
|
375
|
+
app = Flask(__name__, static_folder=web_editor_dir)
|
|
376
|
+
|
|
377
|
+
# Serve static files
|
|
378
|
+
@app.route('/')
|
|
379
|
+
def index():
|
|
380
|
+
return send_from_directory(web_editor_dir, 'index.html')
|
|
381
|
+
|
|
382
|
+
@app.route('/static/<path:filename>')
|
|
383
|
+
def serve_static(filename):
|
|
384
|
+
return send_from_directory(web_editor_dir, filename)
|
|
385
|
+
|
|
386
|
+
# API: List all credentials
|
|
387
|
+
@app.route('/api/credentials', methods=['GET'])
|
|
388
|
+
def api_list_credentials():
|
|
389
|
+
credentials = get_all_credentials()
|
|
390
|
+
credentials.sort(key=lambda x: x.name)
|
|
391
|
+
result = []
|
|
392
|
+
for cred in credentials:
|
|
393
|
+
result.append({
|
|
394
|
+
'name': cred.name,
|
|
395
|
+
'fqdn': cred.fqdn,
|
|
396
|
+
'port': cred.port,
|
|
397
|
+
'org_id': cred.org_id,
|
|
398
|
+
'api_user': cred.api_user,
|
|
399
|
+
'verify_ssl': cred.verify_ssl,
|
|
400
|
+
'originating_file': cred.originating_file
|
|
401
|
+
})
|
|
402
|
+
return jsonify(result)
|
|
403
|
+
|
|
404
|
+
# API: Get a single credential
|
|
405
|
+
@app.route('/api/credentials/<name>', methods=['GET'])
|
|
406
|
+
def api_get_credential(name):
|
|
407
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
408
|
+
if found_profile is None:
|
|
409
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
410
|
+
return jsonify({
|
|
411
|
+
'name': found_profile.name,
|
|
412
|
+
'fqdn': found_profile.fqdn,
|
|
413
|
+
'port': found_profile.port,
|
|
414
|
+
'org_id': found_profile.org_id,
|
|
415
|
+
'api_user': found_profile.api_user,
|
|
416
|
+
'verify_ssl': found_profile.verify_ssl,
|
|
417
|
+
'originating_file': found_profile.originating_file
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
# API: Create a new credential
|
|
421
|
+
@app.route('/api/credentials', methods=['POST'])
|
|
422
|
+
def api_create_credential():
|
|
423
|
+
data = request.get_json()
|
|
424
|
+
if not data:
|
|
425
|
+
return jsonify({'error': 'No data provided'}), 400
|
|
426
|
+
|
|
427
|
+
required_fields = ['name', 'fqdn', 'port', 'org_id', 'api_user', 'api_key']
|
|
428
|
+
for field in required_fields:
|
|
429
|
+
if field not in data:
|
|
430
|
+
return jsonify({'error': f'Missing required field: {field}'}), 400
|
|
431
|
+
|
|
432
|
+
# Check if credential already exists
|
|
433
|
+
credentials = get_all_credentials()
|
|
434
|
+
for credential in credentials:
|
|
435
|
+
if credential.name == data['name']:
|
|
436
|
+
return jsonify({'error': f"A credential named '{data['name']}' already exists"}), 400
|
|
437
|
+
|
|
438
|
+
credentials_data: CredentialFileEntry = {
|
|
439
|
+
"name": data['name'],
|
|
440
|
+
"fqdn": data['fqdn'],
|
|
441
|
+
"port": int(data['port']),
|
|
442
|
+
"org_id": int(data['org_id']),
|
|
443
|
+
"api_user": data['api_user'],
|
|
444
|
+
"verify_ssl": data.get('verify_ssl', True),
|
|
445
|
+
"api_key": data['api_key']
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Handle encryption if requested
|
|
449
|
+
if data.get('encrypt') and data.get('ssh_key_index') is not None:
|
|
450
|
+
if is_encryption_available():
|
|
451
|
+
try:
|
|
452
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
453
|
+
key_index = int(data['ssh_key_index'])
|
|
454
|
+
if 0 <= key_index < len(ssh_keys):
|
|
455
|
+
selected_ssh_key = ssh_keys[key_index]
|
|
456
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
457
|
+
ssh_key=selected_ssh_key, api_key=data['api_key'])
|
|
458
|
+
# Verify encryption
|
|
459
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
460
|
+
encrypted_api_key_payload=encrypted_api_key)
|
|
461
|
+
if decrypted_api_key != data['api_key']:
|
|
462
|
+
return jsonify({'error': 'Encryption verification failed'}), 500
|
|
463
|
+
credentials_data["api_key"] = encrypted_api_key
|
|
464
|
+
except Exception as e:
|
|
465
|
+
return jsonify({'error': f'Encryption failed: {str(e)}'}), 500
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
if data.get('use_current_workdir'):
|
|
469
|
+
file_path = create_credential_in_file(file_full_path=os.getcwd(), data=credentials_data)
|
|
470
|
+
else:
|
|
471
|
+
file_path = create_credential_in_default_file(data=credentials_data)
|
|
472
|
+
return jsonify({'success': True, 'file_path': file_path})
|
|
473
|
+
except Exception as e:
|
|
474
|
+
return jsonify({'error': str(e)}), 500
|
|
475
|
+
|
|
476
|
+
# API: Update a credential
|
|
477
|
+
@app.route('/api/credentials/<name>', methods=['PUT'])
|
|
478
|
+
def api_update_credential(name):
|
|
479
|
+
data = request.get_json()
|
|
480
|
+
if not data:
|
|
481
|
+
return jsonify({'error': 'No data provided'}), 400
|
|
482
|
+
|
|
483
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
484
|
+
if found_profile is None:
|
|
485
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
486
|
+
|
|
487
|
+
# Update fields if provided
|
|
488
|
+
if 'fqdn' in data:
|
|
489
|
+
found_profile.fqdn = data['fqdn']
|
|
490
|
+
if 'port' in data:
|
|
491
|
+
found_profile.port = int(data['port'])
|
|
492
|
+
if 'org_id' in data:
|
|
493
|
+
found_profile.org_id = int(data['org_id'])
|
|
494
|
+
if 'api_user' in data:
|
|
495
|
+
found_profile.api_user = data['api_user']
|
|
496
|
+
if 'verify_ssl' in data:
|
|
497
|
+
found_profile.verify_ssl = data['verify_ssl']
|
|
498
|
+
if 'api_key' in data and data['api_key']:
|
|
499
|
+
found_profile.api_key = data['api_key']
|
|
500
|
+
|
|
501
|
+
# Handle encryption if requested
|
|
502
|
+
if data.get('encrypt') and data.get('ssh_key_index') is not None:
|
|
503
|
+
if is_encryption_available():
|
|
504
|
+
try:
|
|
505
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
506
|
+
key_index = int(data['ssh_key_index'])
|
|
507
|
+
if 0 <= key_index < len(ssh_keys):
|
|
508
|
+
selected_ssh_key = ssh_keys[key_index]
|
|
509
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
510
|
+
ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
511
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
512
|
+
encrypted_api_key_payload=encrypted_api_key)
|
|
513
|
+
if decrypted_api_key != found_profile.api_key:
|
|
514
|
+
return jsonify({'error': 'Encryption verification failed'}), 500
|
|
515
|
+
found_profile.api_key = encrypted_api_key
|
|
516
|
+
except Exception as e:
|
|
517
|
+
return jsonify({'error': f'Encryption failed: {str(e)}'}), 500
|
|
518
|
+
|
|
519
|
+
credentials_data: CredentialFileEntry = {
|
|
520
|
+
"name": found_profile.name,
|
|
521
|
+
"fqdn": found_profile.fqdn,
|
|
522
|
+
"port": found_profile.port,
|
|
523
|
+
"org_id": found_profile.org_id,
|
|
524
|
+
"api_user": found_profile.api_user,
|
|
525
|
+
"verify_ssl": found_profile.verify_ssl,
|
|
526
|
+
"api_key": found_profile.api_key
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
create_credential_in_file(file_full_path=found_profile.originating_file,
|
|
531
|
+
data=credentials_data, overwrite_existing_profile=True)
|
|
532
|
+
return jsonify({'success': True})
|
|
533
|
+
except Exception as e:
|
|
534
|
+
return jsonify({'error': str(e)}), 500
|
|
535
|
+
|
|
536
|
+
# API: Test a credential
|
|
537
|
+
@app.route('/api/credentials/<name>/test', methods=['POST'])
|
|
538
|
+
def api_test_credential(name):
|
|
539
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
540
|
+
if found_profile is None:
|
|
541
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
connector = pylo.APIConnector.create_from_credentials_object(found_profile)
|
|
545
|
+
connector.objects_label_dimension_get()
|
|
546
|
+
return jsonify({'success': True, 'message': 'Connection successful'})
|
|
547
|
+
except Exception as e:
|
|
548
|
+
return jsonify({'error': str(e)}), 500
|
|
549
|
+
|
|
550
|
+
# API: Delete a credential
|
|
551
|
+
@app.route('/api/credentials/<name>', methods=['DELETE'])
|
|
552
|
+
def api_delete_credential(name):
|
|
553
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
554
|
+
if found_profile is None:
|
|
555
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
delete_credential_from_file(profile_name=found_profile.name, file_path=found_profile.originating_file)
|
|
559
|
+
return jsonify({'success': True, 'message': f"Credential '{name}' deleted successfully"})
|
|
560
|
+
except Exception as e:
|
|
561
|
+
return jsonify({'error': str(e)}), 500
|
|
562
|
+
|
|
563
|
+
# API: Get SSH keys for encryption
|
|
564
|
+
@app.route('/api/ssh-keys', methods=['GET'])
|
|
565
|
+
def api_get_ssh_keys():
|
|
566
|
+
if not is_encryption_available():
|
|
567
|
+
return jsonify({'available': False, 'keys': []})
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
571
|
+
keys_list = []
|
|
572
|
+
for i, key in enumerate(ssh_keys):
|
|
573
|
+
keys_list.append({
|
|
574
|
+
'index': i,
|
|
575
|
+
'type': key.get_name(),
|
|
576
|
+
'fingerprint': key.get_fingerprint().hex(),
|
|
577
|
+
'comment': key.comment
|
|
578
|
+
})
|
|
579
|
+
return jsonify({'available': True, 'keys': keys_list})
|
|
580
|
+
except Exception as e:
|
|
581
|
+
return jsonify({'available': False, 'error': str(e), 'keys': []})
|
|
582
|
+
|
|
583
|
+
# API: Check encryption availability
|
|
584
|
+
@app.route('/api/encryption-status', methods=['GET'])
|
|
585
|
+
def api_encryption_status():
|
|
586
|
+
return jsonify({'available': is_encryption_available()})
|
|
587
|
+
|
|
588
|
+
print(f"Starting web editor at http://{host}:{port}")
|
|
589
|
+
print("Press Ctrl+C to stop the server")
|
|
590
|
+
app.run(host=host, port=port, debug=False)
|
|
591
|
+
|