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.
Files changed (30) hide show
  1. illumio_pylo/API/APIConnector.py +138 -106
  2. illumio_pylo/API/CredentialsManager.py +168 -3
  3. illumio_pylo/API/Explorer.py +619 -14
  4. illumio_pylo/API/JsonPayloadTypes.py +64 -4
  5. illumio_pylo/FilterQuery.py +892 -0
  6. illumio_pylo/Helpers/exports.py +1 -1
  7. illumio_pylo/LabelCommon.py +13 -3
  8. illumio_pylo/LabelDimension.py +109 -0
  9. illumio_pylo/LabelStore.py +97 -38
  10. illumio_pylo/WorkloadStore.py +58 -0
  11. illumio_pylo/__init__.py +9 -3
  12. illumio_pylo/cli/__init__.py +5 -2
  13. illumio_pylo/cli/commands/__init__.py +1 -0
  14. illumio_pylo/cli/commands/credential_manager.py +555 -4
  15. illumio_pylo/cli/commands/label_delete_unused.py +0 -3
  16. illumio_pylo/cli/commands/traffic_export.py +358 -0
  17. illumio_pylo/cli/commands/ui/credential_manager_ui/app.js +638 -0
  18. illumio_pylo/cli/commands/ui/credential_manager_ui/index.html +217 -0
  19. illumio_pylo/cli/commands/ui/credential_manager_ui/styles.css +581 -0
  20. illumio_pylo/cli/commands/update_pce_objects_cache.py +1 -2
  21. illumio_pylo/cli/commands/ven_duplicate_remover.py +79 -59
  22. illumio_pylo/cli/commands/workload_export.py +29 -0
  23. illumio_pylo/utilities/cli.py +4 -1
  24. illumio_pylo/utilities/health_monitoring.py +5 -1
  25. {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/METADATA +2 -1
  26. {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/RECORD +29 -24
  27. {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/WHEEL +1 -1
  28. illumio_pylo/Query.py +0 -331
  29. {illumio_pylo-0.3.11.dist-info → illumio_pylo-0.3.13.dist-info}/licenses/LICENSE +0 -0
  30. {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 = [ # (name, width)
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
+
@@ -2,10 +2,7 @@ import argparse
2
2
  from typing import Optional, List
3
3
 
4
4
  import illumio_pylo as pylo
5
- import json
6
- import hashlib
7
5
 
8
- from illumio_pylo import log
9
6
  from . import Command
10
7
  from illumio_pylo.API.JsonPayloadTypes import LabelObjectJsonStructure
11
8