illumio-pylo 0.3.11__py3-none-any.whl → 0.3.13__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 +138 -106
- illumio_pylo/API/CredentialsManager.py +168 -3
- illumio_pylo/API/Explorer.py +619 -14
- illumio_pylo/API/JsonPayloadTypes.py +64 -4
- illumio_pylo/FilterQuery.py +892 -0
- illumio_pylo/Helpers/exports.py +1 -1
- illumio_pylo/LabelCommon.py +13 -3
- illumio_pylo/LabelDimension.py +109 -0
- illumio_pylo/LabelStore.py +97 -38
- illumio_pylo/WorkloadStore.py +58 -0
- illumio_pylo/__init__.py +9 -3
- illumio_pylo/cli/__init__.py +5 -2
- illumio_pylo/cli/commands/__init__.py +1 -0
- illumio_pylo/cli/commands/credential_manager.py +555 -4
- illumio_pylo/cli/commands/label_delete_unused.py +0 -3
- illumio_pylo/cli/commands/traffic_export.py +358 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/app.js +638 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/index.html +217 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/styles.css +581 -0
- illumio_pylo/cli/commands/update_pce_objects_cache.py +1 -2
- illumio_pylo/cli/commands/ven_duplicate_remover.py +79 -59
- illumio_pylo/cli/commands/workload_export.py +29 -0
- illumio_pylo/utilities/cli.py +4 -1
- illumio_pylo/utilities/health_monitoring.py +5 -1
- {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/METADATA +2 -1
- {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/RECORD +29 -24
- {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/WHEEL +1 -1
- illumio_pylo/Query.py +0 -331
- {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/licenses/LICENSE +0 -0
- {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/top_level.txt +0 -0
|
@@ -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,47 @@ 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
|
+
# Encrypt sub-command
|
|
68
|
+
encrypt_parser = sub_parser.add_parser('encrypt', help='Encrypt an existing credential API key')
|
|
69
|
+
encrypt_parser.add_argument('--name', required=False, type=str, default=None,
|
|
70
|
+
help='Name of the credential to encrypt')
|
|
71
|
+
|
|
72
|
+
# Web editor sub-command
|
|
73
|
+
web_editor_parser = sub_parser.add_parser('web-editor', help='Start web-based credential editor')
|
|
74
|
+
web_editor_parser.add_argument('--host', required=False, type=str, default='127.0.0.1',
|
|
75
|
+
help='Host to bind the web server to')
|
|
76
|
+
web_editor_parser.add_argument('--port', required=False, type=int, default=5000,
|
|
77
|
+
help='Port to bind the web server to')
|
|
78
|
+
|
|
45
79
|
|
|
46
80
|
def __main(args, **kwargs):
|
|
47
81
|
if args['sub_command'] == 'list':
|
|
48
82
|
table = PrettyTable(field_names=["Name", "URL", "API User", "Originating File"])
|
|
49
|
-
# all should be left justified
|
|
50
83
|
table.align = "l"
|
|
51
84
|
|
|
52
85
|
credentials = get_all_credentials()
|
|
53
|
-
# sort credentials by name
|
|
54
86
|
credentials.sort(key=lambda x: x.name)
|
|
55
87
|
|
|
56
88
|
for credential in credentials:
|
|
@@ -153,6 +185,116 @@ def __main(args, **kwargs):
|
|
|
153
185
|
|
|
154
186
|
print("OK! ({})".format(file_path))
|
|
155
187
|
|
|
188
|
+
elif args['sub_command'] == 'update':
|
|
189
|
+
# if name is not provided, prompt for it
|
|
190
|
+
if args['name'] is None:
|
|
191
|
+
wanted_name = click.prompt('> Input a Profile Name to update (ie: prod-pce)', type=str)
|
|
192
|
+
args['name'] = wanted_name
|
|
193
|
+
|
|
194
|
+
# find the credential by name
|
|
195
|
+
wanted_name = args['name']
|
|
196
|
+
found_profile = get_credentials_from_file(wanted_name, fail_with_an_exception=False)
|
|
197
|
+
if found_profile is None:
|
|
198
|
+
print("Cannot find a profile named '{}'".format(wanted_name))
|
|
199
|
+
print("Available profiles:")
|
|
200
|
+
credentials = get_all_credentials()
|
|
201
|
+
for credential in credentials:
|
|
202
|
+
print(" - {}".format(credential.name))
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
|
|
205
|
+
print("Found profile '{}' to update in file '{}'".format(found_profile.name, found_profile.originating_file))
|
|
206
|
+
|
|
207
|
+
if args['fqdn'] is not None:
|
|
208
|
+
found_profile.fqdn = args['fqdn']
|
|
209
|
+
if args['port'] is not None:
|
|
210
|
+
found_profile.port = args['port']
|
|
211
|
+
if args['org'] is not None:
|
|
212
|
+
found_profile.org_id = args['org']
|
|
213
|
+
if args['api_user'] is not None:
|
|
214
|
+
found_profile.api_user = args['api_user']
|
|
215
|
+
if args['verify_ssl'] is not None:
|
|
216
|
+
found_profile.verify_ssl = args['verify_ssl']
|
|
217
|
+
|
|
218
|
+
# ask if user wants to update API key
|
|
219
|
+
update_api_key = click.prompt('> Do you want to update the API key? Y/N', type=bool)
|
|
220
|
+
if update_api_key:
|
|
221
|
+
api_key = click.prompt('> New API Key', hide_input=True)
|
|
222
|
+
found_profile.api_key = api_key
|
|
223
|
+
print()
|
|
224
|
+
|
|
225
|
+
if is_encryption_available():
|
|
226
|
+
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)
|
|
227
|
+
if encrypt_api_key:
|
|
228
|
+
print("Available keys (ECDSA NISTPXXX keys and a few others are not supported and will be filtered out):")
|
|
229
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
230
|
+
|
|
231
|
+
# display a table of keys
|
|
232
|
+
print_keys(keys=ssh_keys, display_index=True)
|
|
233
|
+
print()
|
|
234
|
+
|
|
235
|
+
index_of_selected_key = click.prompt('> Select key by ID#', type=click.IntRange(0, len(ssh_keys)-1))
|
|
236
|
+
selected_ssh_key = ssh_keys[index_of_selected_key]
|
|
237
|
+
print("Selected key: {} | {} | {}".format(selected_ssh_key.get_name(),
|
|
238
|
+
selected_ssh_key.get_fingerprint().hex(),
|
|
239
|
+
selected_ssh_key.comment))
|
|
240
|
+
print(" * encrypting API key with selected key (you may be prompted by your SSH agent for confirmation or PIN code) ...", flush=True, end="")
|
|
241
|
+
# encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_fernet(ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
242
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
243
|
+
print("OK!")
|
|
244
|
+
print(" * trying to decrypt the encrypted API key...", flush=True, end="")
|
|
245
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(encrypted_api_key_payload=encrypted_api_key)
|
|
246
|
+
if decrypted_api_key != found_profile.api_key:
|
|
247
|
+
raise pylo.PyloEx("Decrypted API key does not match original API key")
|
|
248
|
+
print("OK!")
|
|
249
|
+
found_profile.api_key = encrypted_api_key
|
|
250
|
+
|
|
251
|
+
credentials_data: CredentialFileEntry = {
|
|
252
|
+
"name": found_profile.name,
|
|
253
|
+
"fqdn": found_profile.fqdn,
|
|
254
|
+
"port": found_profile.port,
|
|
255
|
+
"org_id": found_profile.org_id,
|
|
256
|
+
"api_user": found_profile.api_user,
|
|
257
|
+
"verify_ssl": found_profile.verify_ssl,
|
|
258
|
+
"api_key": found_profile.api_key
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
print("* Updating credential in file '{}'...".format(found_profile.originating_file), flush=True, end="")
|
|
262
|
+
create_credential_in_file(file_full_path=found_profile.originating_file, data=credentials_data, overwrite_existing_profile=True)
|
|
263
|
+
print("OK!")
|
|
264
|
+
|
|
265
|
+
elif args['sub_command'] == 'delete':
|
|
266
|
+
# if name is not provided, prompt for it
|
|
267
|
+
wanted_name = args['name']
|
|
268
|
+
if wanted_name is None:
|
|
269
|
+
wanted_name = click.prompt('> Input a Profile Name to delete (ie: prod-pce)', type=str)
|
|
270
|
+
|
|
271
|
+
# find the credential by name
|
|
272
|
+
found_profile = get_credentials_from_file(wanted_name, fail_with_an_exception=False)
|
|
273
|
+
if found_profile is None:
|
|
274
|
+
print("Cannot find a profile named '{}'".format(wanted_name))
|
|
275
|
+
print("Available profiles:")
|
|
276
|
+
credentials = get_all_credentials()
|
|
277
|
+
for credential in credentials:
|
|
278
|
+
print(" - {}".format(credential.name))
|
|
279
|
+
sys.exit(1)
|
|
280
|
+
|
|
281
|
+
print("Found profile '{}' in file '{}'".format(found_profile.name, found_profile.originating_file))
|
|
282
|
+
print(" - FQDN: {}".format(found_profile.fqdn))
|
|
283
|
+
print(" - Port: {}".format(found_profile.port))
|
|
284
|
+
print(" - Org ID: {}".format(found_profile.org_id))
|
|
285
|
+
print(" - API User: {}".format(found_profile.api_user))
|
|
286
|
+
|
|
287
|
+
# Confirm deletion unless --yes flag is provided
|
|
288
|
+
if not args['yes']:
|
|
289
|
+
confirm = click.prompt('> Are you sure you want to delete this credential? Y/N', type=bool)
|
|
290
|
+
if not confirm:
|
|
291
|
+
print("Deletion cancelled.")
|
|
292
|
+
sys.exit(0)
|
|
293
|
+
|
|
294
|
+
print("* Deleting credential...", flush=True, end="")
|
|
295
|
+
delete_credential_from_file(profile_name=found_profile.name, file_path=found_profile.originating_file)
|
|
296
|
+
print("OK!")
|
|
297
|
+
|
|
156
298
|
elif args['sub_command'] == 'test':
|
|
157
299
|
print("* Profile Tester command")
|
|
158
300
|
wanted_name = args['name']
|
|
@@ -180,6 +322,82 @@ def __main(args, **kwargs):
|
|
|
180
322
|
connector.objects_label_dimension_get()
|
|
181
323
|
print("OK!")
|
|
182
324
|
|
|
325
|
+
elif args['sub_command'] == 'encrypt':
|
|
326
|
+
# Check if encryption is available first
|
|
327
|
+
if not is_encryption_available():
|
|
328
|
+
print("Encryption is not available. Please ensure an SSH agent is running with RSA or Ed25519 keys added.")
|
|
329
|
+
sys.exit(1)
|
|
330
|
+
|
|
331
|
+
# if name is not provided, prompt for it
|
|
332
|
+
wanted_name = args['name']
|
|
333
|
+
if wanted_name is None:
|
|
334
|
+
wanted_name = click.prompt('> Input a Profile Name to encrypt (ie: prod-pce)', type=str)
|
|
335
|
+
|
|
336
|
+
# find the credential by name
|
|
337
|
+
found_profile = get_credentials_from_file(wanted_name, fail_with_an_exception=False)
|
|
338
|
+
if found_profile is None:
|
|
339
|
+
print("Cannot find a profile named '{}'".format(wanted_name))
|
|
340
|
+
print("Available profiles:")
|
|
341
|
+
credentials = get_all_credentials()
|
|
342
|
+
for credential in credentials:
|
|
343
|
+
print(" - {}".format(credential.name))
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
346
|
+
print("Found profile '{}' in file '{}'".format(found_profile.name, found_profile.originating_file))
|
|
347
|
+
print(" - FQDN: {}".format(found_profile.fqdn))
|
|
348
|
+
print(" - Port: {}".format(found_profile.port))
|
|
349
|
+
print(" - Org ID: {}".format(found_profile.org_id))
|
|
350
|
+
print(" - API User: {}".format(found_profile.api_user))
|
|
351
|
+
|
|
352
|
+
# Check if already encrypted
|
|
353
|
+
if found_profile.is_api_key_encrypted():
|
|
354
|
+
print("ERROR: The API key for profile '{}' is already encrypted.".format(found_profile.name))
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
print()
|
|
358
|
+
print("Available SSH keys (ECDSA NISTPXXX keys and a few others are not supported and will be filtered out):")
|
|
359
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
360
|
+
|
|
361
|
+
if len(ssh_keys) == 0:
|
|
362
|
+
print("No supported SSH keys found in the agent.")
|
|
363
|
+
sys.exit(1)
|
|
364
|
+
|
|
365
|
+
# display a table of keys
|
|
366
|
+
print_keys(keys=ssh_keys, display_index=True)
|
|
367
|
+
print()
|
|
368
|
+
|
|
369
|
+
index_of_selected_key = click.prompt('> Select key by ID#', type=click.IntRange(0, len(ssh_keys)-1))
|
|
370
|
+
selected_ssh_key = ssh_keys[index_of_selected_key]
|
|
371
|
+
print("Selected key: {} | {} | {}".format(selected_ssh_key.get_name(),
|
|
372
|
+
selected_ssh_key.get_fingerprint().hex(),
|
|
373
|
+
selected_ssh_key.comment))
|
|
374
|
+
print(" * encrypting API key with selected key (you may be prompted by your SSH agent for confirmation or PIN code) ...", flush=True, end="")
|
|
375
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
376
|
+
print("OK!")
|
|
377
|
+
print(" * trying to decrypt the encrypted API key...", flush=True, end="")
|
|
378
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(encrypted_api_key_payload=encrypted_api_key)
|
|
379
|
+
if decrypted_api_key != found_profile.api_key:
|
|
380
|
+
raise pylo.PyloEx("Decrypted API key does not match original API key")
|
|
381
|
+
print("OK!")
|
|
382
|
+
|
|
383
|
+
credentials_data: CredentialFileEntry = {
|
|
384
|
+
"name": found_profile.name,
|
|
385
|
+
"fqdn": found_profile.fqdn,
|
|
386
|
+
"port": found_profile.port,
|
|
387
|
+
"org_id": found_profile.org_id,
|
|
388
|
+
"api_user": found_profile.api_user,
|
|
389
|
+
"verify_ssl": found_profile.verify_ssl,
|
|
390
|
+
"api_key": encrypted_api_key
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
print("* Updating credential in file '{}'...".format(found_profile.originating_file), flush=True, end="")
|
|
394
|
+
create_credential_in_file(file_full_path=found_profile.originating_file, data=credentials_data, overwrite_existing_profile=True)
|
|
395
|
+
print("OK!")
|
|
396
|
+
print("API key for profile '{}' has been encrypted successfully.".format(found_profile.name))
|
|
397
|
+
|
|
398
|
+
elif args['sub_command'] == 'web-editor':
|
|
399
|
+
run_web_editor(host=args['host'], port=args['port'])
|
|
400
|
+
|
|
183
401
|
else:
|
|
184
402
|
raise pylo.PyloEx("Unknown sub-command '{}'".format(args['sub_command']))
|
|
185
403
|
|
|
@@ -189,7 +407,7 @@ command_object = Command(command_name, __main, fill_parser, credentials_manager_
|
|
|
189
407
|
|
|
190
408
|
def print_keys(keys: list[paramiko.AgentKey], display_index=True) -> None:
|
|
191
409
|
|
|
192
|
-
column_properties = [
|
|
410
|
+
column_properties = [
|
|
193
411
|
("ID#", 4),
|
|
194
412
|
("Type", 20),
|
|
195
413
|
("Fingerprint", 40),
|
|
@@ -214,3 +432,336 @@ def print_keys(keys: list[paramiko.AgentKey], display_index=True) -> None:
|
|
|
214
432
|
table.add_row(display_values)
|
|
215
433
|
|
|
216
434
|
print(table)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def run_web_editor(host: str = '127.0.0.1', port: int = 5000) -> None:
|
|
438
|
+
"""Start the Flask web server for credential management."""
|
|
439
|
+
try:
|
|
440
|
+
# noinspection PyUnusedImports
|
|
441
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
442
|
+
except ImportError:
|
|
443
|
+
print("Flask is not installed. Please install it with: pip install flask")
|
|
444
|
+
sys.exit(1)
|
|
445
|
+
|
|
446
|
+
# Determine paths for static files
|
|
447
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
448
|
+
web_editor_dir = os.path.join(current_dir, 'ui/credential_manager_ui')
|
|
449
|
+
# That directory should contain index.html, error if not
|
|
450
|
+
if not os.path.exists(os.path.join(web_editor_dir, 'index.html')):
|
|
451
|
+
print("Cannot find web editor static files in expected location: {}".format(web_editor_dir))
|
|
452
|
+
sys.exit(1)
|
|
453
|
+
|
|
454
|
+
app = Flask(__name__, static_folder=web_editor_dir)
|
|
455
|
+
|
|
456
|
+
# Serve static files
|
|
457
|
+
@app.route('/')
|
|
458
|
+
def index():
|
|
459
|
+
return send_from_directory(web_editor_dir, 'index.html')
|
|
460
|
+
|
|
461
|
+
@app.route('/static/<path:filename>')
|
|
462
|
+
def serve_static(filename):
|
|
463
|
+
return send_from_directory(web_editor_dir, filename)
|
|
464
|
+
|
|
465
|
+
# API: List all credentials
|
|
466
|
+
@app.route('/api/credentials', methods=['GET'])
|
|
467
|
+
def api_list_credentials():
|
|
468
|
+
credentials = get_all_credentials()
|
|
469
|
+
credentials.sort(key=lambda x: x.name)
|
|
470
|
+
result = []
|
|
471
|
+
for cred in credentials:
|
|
472
|
+
result.append({
|
|
473
|
+
'name': cred.name,
|
|
474
|
+
'fqdn': cred.fqdn,
|
|
475
|
+
'port': cred.port,
|
|
476
|
+
'org_id': cred.org_id,
|
|
477
|
+
'api_user': cred.api_user,
|
|
478
|
+
'verify_ssl': cred.verify_ssl,
|
|
479
|
+
'api_key_encrypted': cred.is_api_key_encrypted(),
|
|
480
|
+
'originating_file': cred.originating_file
|
|
481
|
+
})
|
|
482
|
+
return jsonify(result)
|
|
483
|
+
|
|
484
|
+
# API: Get a single credential
|
|
485
|
+
@app.route('/api/credentials/<name>', methods=['GET'])
|
|
486
|
+
def api_get_credential(name):
|
|
487
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
488
|
+
if found_profile is None:
|
|
489
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
490
|
+
return jsonify({
|
|
491
|
+
'name': found_profile.name,
|
|
492
|
+
'fqdn': found_profile.fqdn,
|
|
493
|
+
'port': found_profile.port,
|
|
494
|
+
'org_id': found_profile.org_id,
|
|
495
|
+
'api_user': found_profile.api_user,
|
|
496
|
+
'verify_ssl': found_profile.verify_ssl,
|
|
497
|
+
'api_key_encrypted': found_profile.is_api_key_encrypted(),
|
|
498
|
+
'originating_file': found_profile.originating_file
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
# API: Create a new credential
|
|
502
|
+
@app.route('/api/credentials', methods=['POST'])
|
|
503
|
+
def api_create_credential():
|
|
504
|
+
data = request.get_json()
|
|
505
|
+
if not data:
|
|
506
|
+
return jsonify({'error': 'No data provided'}), 400
|
|
507
|
+
|
|
508
|
+
required_fields = ['name', 'fqdn', 'port', 'org_id', 'api_user', 'api_key']
|
|
509
|
+
for field in required_fields:
|
|
510
|
+
if field not in data:
|
|
511
|
+
return jsonify({'error': f'Missing required field: {field}'}), 400
|
|
512
|
+
|
|
513
|
+
# Check if credential already exists
|
|
514
|
+
credentials = get_all_credentials()
|
|
515
|
+
for credential in credentials:
|
|
516
|
+
if credential.name == data['name']:
|
|
517
|
+
return jsonify({'error': f"A credential named '{data['name']}' already exists"}), 400
|
|
518
|
+
|
|
519
|
+
credentials_data: CredentialFileEntry = {
|
|
520
|
+
"name": data['name'],
|
|
521
|
+
"fqdn": data['fqdn'],
|
|
522
|
+
"port": int(data['port']),
|
|
523
|
+
"org_id": int(data['org_id']),
|
|
524
|
+
"api_user": data['api_user'],
|
|
525
|
+
"verify_ssl": data.get('verify_ssl', True),
|
|
526
|
+
"api_key": data['api_key']
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Handle encryption if requested
|
|
530
|
+
if data.get('encrypt') and data.get('ssh_key_index') is not None:
|
|
531
|
+
if is_encryption_available():
|
|
532
|
+
try:
|
|
533
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
534
|
+
key_index = int(data['ssh_key_index'])
|
|
535
|
+
if 0 <= key_index < len(ssh_keys):
|
|
536
|
+
selected_ssh_key = ssh_keys[key_index]
|
|
537
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
538
|
+
ssh_key=selected_ssh_key, api_key=data['api_key'])
|
|
539
|
+
# Verify encryption
|
|
540
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
541
|
+
encrypted_api_key_payload=encrypted_api_key)
|
|
542
|
+
if decrypted_api_key != data['api_key']:
|
|
543
|
+
return jsonify({'error': 'Encryption verification failed'}), 500
|
|
544
|
+
credentials_data["api_key"] = encrypted_api_key
|
|
545
|
+
except Exception as e:
|
|
546
|
+
return jsonify({'error': f'Encryption failed: {str(e)}'}), 500
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
if data.get('use_current_workdir'):
|
|
550
|
+
file_path = create_credential_in_file(file_full_path=os.getcwd(), data=credentials_data)
|
|
551
|
+
else:
|
|
552
|
+
file_path = create_credential_in_default_file(data=credentials_data)
|
|
553
|
+
return jsonify({'success': True, 'file_path': file_path})
|
|
554
|
+
except Exception as e:
|
|
555
|
+
return jsonify({'error': str(e)}), 500
|
|
556
|
+
|
|
557
|
+
# API: Update a credential
|
|
558
|
+
@app.route('/api/credentials/<name>', methods=['PUT'])
|
|
559
|
+
def api_update_credential(name):
|
|
560
|
+
data = request.get_json()
|
|
561
|
+
if not data:
|
|
562
|
+
return jsonify({'error': 'No data provided'}), 400
|
|
563
|
+
|
|
564
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
565
|
+
if found_profile is None:
|
|
566
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
567
|
+
|
|
568
|
+
# Update fields if provided
|
|
569
|
+
if 'fqdn' in data:
|
|
570
|
+
found_profile.fqdn = data['fqdn']
|
|
571
|
+
if 'port' in data:
|
|
572
|
+
found_profile.port = int(data['port'])
|
|
573
|
+
if 'org_id' in data:
|
|
574
|
+
found_profile.org_id = int(data['org_id'])
|
|
575
|
+
if 'api_user' in data:
|
|
576
|
+
found_profile.api_user = data['api_user']
|
|
577
|
+
if 'verify_ssl' in data:
|
|
578
|
+
found_profile.verify_ssl = data['verify_ssl']
|
|
579
|
+
if 'api_key' in data and data['api_key']:
|
|
580
|
+
found_profile.api_key = data['api_key']
|
|
581
|
+
|
|
582
|
+
# Handle encryption if requested
|
|
583
|
+
if data.get('encrypt') and data.get('ssh_key_index') is not None:
|
|
584
|
+
if is_encryption_available():
|
|
585
|
+
try:
|
|
586
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
587
|
+
key_index = int(data['ssh_key_index'])
|
|
588
|
+
if 0 <= key_index < len(ssh_keys):
|
|
589
|
+
selected_ssh_key = ssh_keys[key_index]
|
|
590
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
591
|
+
ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
592
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
593
|
+
encrypted_api_key_payload=encrypted_api_key)
|
|
594
|
+
if decrypted_api_key != found_profile.api_key:
|
|
595
|
+
return jsonify({'error': 'Encryption verification failed'}), 500
|
|
596
|
+
found_profile.api_key = encrypted_api_key
|
|
597
|
+
except Exception as e:
|
|
598
|
+
return jsonify({'error': f'Encryption failed: {str(e)}'}), 500
|
|
599
|
+
|
|
600
|
+
credentials_data: CredentialFileEntry = {
|
|
601
|
+
"name": found_profile.name,
|
|
602
|
+
"fqdn": found_profile.fqdn,
|
|
603
|
+
"port": found_profile.port,
|
|
604
|
+
"org_id": found_profile.org_id,
|
|
605
|
+
"api_user": found_profile.api_user,
|
|
606
|
+
"verify_ssl": found_profile.verify_ssl,
|
|
607
|
+
"api_key": found_profile.api_key
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
create_credential_in_file(file_full_path=found_profile.originating_file,
|
|
612
|
+
data=credentials_data, overwrite_existing_profile=True)
|
|
613
|
+
return jsonify({'success': True})
|
|
614
|
+
except Exception as e:
|
|
615
|
+
return jsonify({'error': str(e)}), 500
|
|
616
|
+
|
|
617
|
+
# API: Test a credential
|
|
618
|
+
@app.route('/api/credentials/<name>/test', methods=['POST'])
|
|
619
|
+
def api_test_credential(name):
|
|
620
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
621
|
+
if found_profile is None:
|
|
622
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
connector = pylo.APIConnector.create_from_credentials_object(found_profile)
|
|
626
|
+
connector.objects_label_dimension_get()
|
|
627
|
+
return jsonify({'success': True, 'message': 'Connection successful'})
|
|
628
|
+
except Exception as e:
|
|
629
|
+
return jsonify({'error': str(e)}), 500
|
|
630
|
+
|
|
631
|
+
# API: Delete a credential
|
|
632
|
+
@app.route('/api/credentials/<name>', methods=['DELETE'])
|
|
633
|
+
def api_delete_credential(name):
|
|
634
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
635
|
+
if found_profile is None:
|
|
636
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
delete_credential_from_file(profile_name=found_profile.name, file_path=found_profile.originating_file)
|
|
640
|
+
return jsonify({'success': True, 'message': f"Credential '{name}' deleted successfully"})
|
|
641
|
+
except Exception as e:
|
|
642
|
+
return jsonify({'error': str(e)}), 500
|
|
643
|
+
|
|
644
|
+
# API: Get SSH keys for encryption
|
|
645
|
+
@app.route('/api/ssh-keys', methods=['GET'])
|
|
646
|
+
def api_get_ssh_keys():
|
|
647
|
+
if not is_encryption_available():
|
|
648
|
+
return jsonify({'available': False, 'keys': []})
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
652
|
+
keys_list = []
|
|
653
|
+
for i, key in enumerate(ssh_keys):
|
|
654
|
+
keys_list.append({
|
|
655
|
+
'index': i,
|
|
656
|
+
'type': key.get_name(),
|
|
657
|
+
'fingerprint': key.get_fingerprint().hex(),
|
|
658
|
+
'comment': key.comment
|
|
659
|
+
})
|
|
660
|
+
return jsonify({'available': True, 'keys': keys_list})
|
|
661
|
+
except Exception as e:
|
|
662
|
+
return jsonify({'available': False, 'error': str(e), 'keys': []})
|
|
663
|
+
|
|
664
|
+
# API: Check encryption availability
|
|
665
|
+
@app.route('/api/encryption-status', methods=['GET'])
|
|
666
|
+
def api_encryption_status():
|
|
667
|
+
return jsonify({'available': is_encryption_available()})
|
|
668
|
+
|
|
669
|
+
# API: Encrypt a credential's API key
|
|
670
|
+
@app.route('/api/credentials/<name>/encrypt', methods=['POST'])
|
|
671
|
+
def api_encrypt_credential(name):
|
|
672
|
+
data = request.get_json()
|
|
673
|
+
if not data:
|
|
674
|
+
return jsonify({'error': 'No data provided'}), 400
|
|
675
|
+
|
|
676
|
+
if not is_encryption_available():
|
|
677
|
+
return jsonify({'error': 'Encryption is not available. Please ensure an SSH agent is running with RSA or Ed25519 keys added.'}), 400
|
|
678
|
+
|
|
679
|
+
found_profile = get_credentials_from_file(name, fail_with_an_exception=False)
|
|
680
|
+
if found_profile is None:
|
|
681
|
+
return jsonify({'error': 'Credential not found'}), 404
|
|
682
|
+
|
|
683
|
+
# Check if already encrypted
|
|
684
|
+
if found_profile.is_api_key_encrypted():
|
|
685
|
+
return jsonify({'error': 'API key is already encrypted'}), 400
|
|
686
|
+
|
|
687
|
+
# Get the SSH key index
|
|
688
|
+
if 'ssh_key_index' not in data:
|
|
689
|
+
return jsonify({'error': 'ssh_key_index is required'}), 400
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
ssh_keys = get_supported_keys_from_ssh_agent()
|
|
693
|
+
key_index = int(data['ssh_key_index'])
|
|
694
|
+
if key_index < 0 or key_index >= len(ssh_keys):
|
|
695
|
+
return jsonify({'error': 'Invalid SSH key index'}), 400
|
|
696
|
+
|
|
697
|
+
selected_ssh_key = ssh_keys[key_index]
|
|
698
|
+
encrypted_api_key = encrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
699
|
+
ssh_key=selected_ssh_key, api_key=found_profile.api_key)
|
|
700
|
+
|
|
701
|
+
# Verify encryption
|
|
702
|
+
decrypted_api_key = decrypt_api_key_with_paramiko_ssh_key_chacha20poly1305(
|
|
703
|
+
encrypted_api_key_payload=encrypted_api_key)
|
|
704
|
+
if decrypted_api_key != found_profile.api_key:
|
|
705
|
+
return jsonify({'error': 'Encryption verification failed'}), 500
|
|
706
|
+
|
|
707
|
+
# Update the credential
|
|
708
|
+
credentials_data: CredentialFileEntry = {
|
|
709
|
+
"name": found_profile.name,
|
|
710
|
+
"fqdn": found_profile.fqdn,
|
|
711
|
+
"port": found_profile.port,
|
|
712
|
+
"org_id": found_profile.org_id,
|
|
713
|
+
"api_user": found_profile.api_user,
|
|
714
|
+
"verify_ssl": found_profile.verify_ssl,
|
|
715
|
+
"api_key": encrypted_api_key
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
create_credential_in_file(file_full_path=found_profile.originating_file,
|
|
719
|
+
data=credentials_data, overwrite_existing_profile=True)
|
|
720
|
+
return jsonify({'success': True, 'message': f"API key for '{name}' encrypted successfully"})
|
|
721
|
+
except Exception as e:
|
|
722
|
+
return jsonify({'error': f'Encryption failed: {str(e)}'}), 500
|
|
723
|
+
|
|
724
|
+
# Flag to track shutdown request
|
|
725
|
+
shutdown_requested = {'value': False}
|
|
726
|
+
|
|
727
|
+
# API: Request server shutdown
|
|
728
|
+
@app.route('/api/shutdown', methods=['POST'])
|
|
729
|
+
def api_shutdown():
|
|
730
|
+
shutdown_requested['value'] = True
|
|
731
|
+
return jsonify({'success': True, 'message': 'Shutdown acknowledged. Server will stop shortly.'})
|
|
732
|
+
|
|
733
|
+
# Shutdown check and security headers after each request
|
|
734
|
+
@app.after_request
|
|
735
|
+
def check_shutdown(response):
|
|
736
|
+
# Add security headers to prevent XSS and other attacks
|
|
737
|
+
response.headers['Content-Security-Policy'] = (
|
|
738
|
+
"default-src 'self'; "
|
|
739
|
+
"script-src 'self' 'unsafe-inline'; "
|
|
740
|
+
"style-src 'self' 'unsafe-inline'; "
|
|
741
|
+
"img-src 'self' data:; "
|
|
742
|
+
"font-src 'self'; "
|
|
743
|
+
"connect-src 'self'; "
|
|
744
|
+
"frame-ancestors 'none'; "
|
|
745
|
+
"base-uri 'self'; "
|
|
746
|
+
"form-action 'self'"
|
|
747
|
+
)
|
|
748
|
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
749
|
+
response.headers['X-Frame-Options'] = 'DENY'
|
|
750
|
+
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
751
|
+
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
|
752
|
+
|
|
753
|
+
if shutdown_requested['value']:
|
|
754
|
+
# Schedule shutdown after response is sent
|
|
755
|
+
def shutdown():
|
|
756
|
+
import time
|
|
757
|
+
time.sleep(1) # Give time for the response to be sent
|
|
758
|
+
print("\nShutdown requested via web UI. Stopping server...")
|
|
759
|
+
os._exit(0)
|
|
760
|
+
import threading
|
|
761
|
+
threading.Thread(target=shutdown, daemon=True).start()
|
|
762
|
+
return response
|
|
763
|
+
|
|
764
|
+
print(f"Starting web editor at http://{host}:{port}")
|
|
765
|
+
print("Press Ctrl+C to stop the server")
|
|
766
|
+
app.run(host=host, port=port, debug=False)
|
|
767
|
+
|