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.
@@ -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
 
@@ -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: [bool] = False, url_text: str = 'Link'):
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
- new_map = pylo.IP4Map()
60
+ return self.ip4map
60
61
 
61
- for entry in self.raw_entries:
62
- if entry[0] == '!':
63
- new_map.subtract_from_text(entry[1:], ignore_ipv6=True)
64
- else:
65
- new_map.add_from_text(entry, ignore_ipv6=True)
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 new_map
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
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.10"
1
+ __version__ = "0.3.12"
2
2
 
3
3
  from typing import Callable
4
4
 
@@ -32,3 +32,4 @@ from .workload_reset_names_to_null import command_object
32
32
  from .credential_manager import command_object
33
33
  from .iplist_analyzer import command_object
34
34
  from .ven_compatibility_report_export import command_object
35
+ from .label_delete_unused import command_object
@@ -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 = [ # (name, width)
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
+