appmesh 1.4.8__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appmesh/http_client.py +247 -87
- appmesh/tcp_client.py +10 -10
- {appmesh-1.4.8.dist-info → appmesh-1.5.0.dist-info}/METADATA +2 -2
- {appmesh-1.4.8.dist-info → appmesh-1.5.0.dist-info}/RECORD +6 -7
- {appmesh-1.4.8.dist-info → appmesh-1.5.0.dist-info}/WHEEL +1 -1
- appmesh/keycloak.py +0 -248
- {appmesh-1.4.8.dist-info → appmesh-1.5.0.dist-info}/top_level.txt +0 -0
appmesh/http_client.py
CHANGED
@@ -3,14 +3,18 @@
|
|
3
3
|
import abc
|
4
4
|
import base64
|
5
5
|
import json
|
6
|
+
import logging
|
6
7
|
import os
|
7
8
|
from datetime import datetime
|
8
9
|
from enum import Enum, unique
|
9
10
|
from http import HTTPStatus
|
11
|
+
import threading
|
10
12
|
from typing import Optional, Tuple, Union
|
11
13
|
from urllib import parse
|
12
14
|
import aniso8601
|
15
|
+
import jwt
|
13
16
|
import requests
|
17
|
+
import time
|
14
18
|
from .app import App
|
15
19
|
from .app_run import AppRun
|
16
20
|
from .app_output import AppOutput
|
@@ -102,6 +106,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
102
106
|
DURATION_ONE_WEEK_ISO = "P1W"
|
103
107
|
DURATION_TWO_DAYS_ISO = "P2D"
|
104
108
|
DURATION_TWO_DAYS_HALF_ISO = "P2DT12H"
|
109
|
+
TOKEN_REFRESH_INTERVAL = 60
|
105
110
|
|
106
111
|
DEFAULT_SSL_CA_CERT_PATH = "/opt/appmesh/ssl/ca.pem"
|
107
112
|
DEFAULT_SSL_CLIENT_CERT_PATH = "/opt/appmesh/ssl/client.pem"
|
@@ -113,6 +118,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
113
118
|
HTTP_USER_AGENT = "appmesh/python"
|
114
119
|
HTTP_HEADER_KEY_USER_AGENT = "User-Agent"
|
115
120
|
HTTP_HEADER_KEY_X_TARGET_HOST = "X-Target-Host"
|
121
|
+
HTTP_HEADER_KEY_X_FILE_PATH = "X-File-Path"
|
116
122
|
|
117
123
|
@unique
|
118
124
|
class Method(Enum):
|
@@ -131,7 +137,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
131
137
|
rest_ssl_client_cert=(DEFAULT_SSL_CLIENT_CERT_PATH, DEFAULT_SSL_CLIENT_KEY_PATH) if os.path.exists(DEFAULT_SSL_CLIENT_CERT_PATH) else None,
|
132
138
|
rest_timeout=(60, 300),
|
133
139
|
jwt_token=None,
|
134
|
-
|
140
|
+
oauth2=None,
|
141
|
+
auto_refresh_token=False,
|
135
142
|
):
|
136
143
|
"""Initialize an App Mesh HTTP client for interacting with the App Mesh server via secure HTTPS.
|
137
144
|
|
@@ -154,12 +161,18 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
154
161
|
jwt_token (str, optional): JWT token for API authentication, used in headers to authorize requests where required.
|
155
162
|
|
156
163
|
oauth2 (Dict[str, Any], optional): Keycloak configuration for oauth2 authentication:
|
157
|
-
-
|
164
|
+
- server_url: Keycloak server URL (e.g. "https://keycloak.example.com/auth/")
|
158
165
|
- realm: Keycloak realm
|
159
166
|
- client_id: Keycloak client ID
|
160
167
|
- client_secret: Keycloak client secret (optional)
|
161
|
-
|
168
|
+
Using this parameter enables Keycloak integration for authentication. The 'python-keycloak' package
|
169
|
+
will be imported on-demand only when this parameter is, make sure package is installed (pip3 install python-keycloak).
|
162
170
|
|
171
|
+
auto_refresh_token (bool, optional): Enable automatic token refresh before expiration.
|
172
|
+
When enabled, a background timer will monitor token expiration and attempt to refresh
|
173
|
+
the token before it expires. This works with both native App Mesh tokens and Keycloak tokens.
|
174
|
+
"""
|
175
|
+
self._ensure_logging_configured()
|
163
176
|
self.session = requests.Session()
|
164
177
|
self.auth_server_url = rest_url
|
165
178
|
self._jwt_token = jwt_token
|
@@ -169,27 +182,131 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
169
182
|
self._forward_to = None
|
170
183
|
|
171
184
|
# Keycloak integration
|
172
|
-
self.
|
173
|
-
if
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
185
|
+
self._keycloak_openid = None
|
186
|
+
if oauth2:
|
187
|
+
try:
|
188
|
+
from keycloak import KeycloakOpenID
|
189
|
+
|
190
|
+
self._keycloak_openid = KeycloakOpenID(
|
191
|
+
server_url=oauth2.get("auth_server_url"),
|
192
|
+
client_id=oauth2.get("client_id"),
|
193
|
+
realm_name=oauth2.get("realm"),
|
194
|
+
client_secret_key=oauth2.get("client_secret"),
|
195
|
+
verify=self.ssl_verify,
|
196
|
+
timeout=rest_timeout,
|
197
|
+
)
|
198
|
+
except ImportError:
|
199
|
+
logging.error("Keycloak package not installed. Install with: pip install python-keycloak")
|
200
|
+
raise Exception("Keycloak integration requested but python-keycloak package is not installed")
|
201
|
+
|
202
|
+
# Token auto-refresh
|
203
|
+
self._token_refresh_timer = None
|
204
|
+
self._auto_refresh_token = auto_refresh_token
|
205
|
+
if auto_refresh_token and jwt_token:
|
206
|
+
self._schedule_token_refresh()
|
207
|
+
|
208
|
+
@staticmethod
|
209
|
+
def _ensure_logging_configured():
|
210
|
+
"""Ensure logging is configured. If no handlers are configured, add a default console handler."""
|
211
|
+
if not logging.root.handlers:
|
212
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
213
|
+
|
214
|
+
def _check_and_refresh_token(self):
|
215
|
+
"""Check and refresh token if needed, then schedule next check.
|
216
|
+
|
217
|
+
This method is triggered by the refresh timer and will:
|
218
|
+
1. Check if token needs refresh based on expiration time
|
219
|
+
2. Refresh the token if needed
|
220
|
+
3. Schedule the next refresh check
|
221
|
+
"""
|
222
|
+
if not self.jwt_token:
|
223
|
+
return
|
224
|
+
|
225
|
+
# Check if token needs refresh
|
226
|
+
needs_refresh = True
|
227
|
+
time_to_expiry = float("inf")
|
228
|
+
|
229
|
+
# Check token expiration directly from JWT
|
230
|
+
try:
|
231
|
+
decoded_token = jwt.decode(self.jwt_token if isinstance(self.jwt_token, str) else self.jwt_token.get("access_token", ""), options={"verify_signature": False})
|
232
|
+
expiry = decoded_token.get("exp", 0)
|
233
|
+
current_time = time.time()
|
234
|
+
time_to_expiry = expiry - current_time
|
235
|
+
# Refresh if token expires within 5 minutes
|
236
|
+
needs_refresh = time_to_expiry < 300
|
237
|
+
except Exception as e:
|
238
|
+
logging.debug("Failed to parse JWT token for expiration check: %s", str(e))
|
239
|
+
|
240
|
+
# Refresh token if needed
|
241
|
+
if needs_refresh:
|
242
|
+
try:
|
243
|
+
self.renew_token()
|
244
|
+
logging.info("Token successfully refreshed")
|
245
|
+
except Exception as e:
|
246
|
+
logging.error("Token refresh failed: %s", str(e))
|
247
|
+
|
248
|
+
# Schedule next check if auto-refresh is still enabled
|
249
|
+
if self._auto_refresh_token and self.jwt_token:
|
250
|
+
self._schedule_token_refresh(time_to_expiry)
|
251
|
+
|
252
|
+
def _schedule_token_refresh(self, time_to_expiry=None):
|
253
|
+
"""Schedule next token refresh check.
|
254
|
+
|
255
|
+
Args:
|
256
|
+
time_to_expiry (float, optional): Time in seconds until token expiration.
|
257
|
+
When provided, helps calculate optimal refresh timing.
|
258
|
+
|
259
|
+
Calculates appropriate check interval:
|
260
|
+
- If token expires soon (within 5 minutes), refresh immediately
|
261
|
+
- Otherwise schedule refresh for the earlier of:
|
262
|
+
1. 5 minutes before expiration
|
263
|
+
2. 60 seconds from now
|
264
|
+
"""
|
265
|
+
# Cancel existing timer if any
|
266
|
+
if self._token_refresh_timer:
|
267
|
+
self._token_refresh_timer.cancel()
|
268
|
+
self._token_refresh_timer = None
|
269
|
+
|
270
|
+
try:
|
271
|
+
# Default to checking after 60 seconds
|
272
|
+
check_interval = self.TOKEN_REFRESH_INTERVAL
|
273
|
+
|
274
|
+
# Calculate more precise check time if expiry is known
|
275
|
+
if time_to_expiry is not None:
|
276
|
+
if time_to_expiry <= 300: # Expires within 5 minutes
|
277
|
+
check_interval = 1 # Almost immediate refresh
|
278
|
+
else:
|
279
|
+
# Check at earlier of 5 minutes before expiry or regular interval
|
280
|
+
check_interval = min(time_to_expiry - 300, self.TOKEN_REFRESH_INTERVAL)
|
281
|
+
|
282
|
+
# Create timer to execute refresh check
|
283
|
+
self._token_refresh_timer = threading.Timer(check_interval, self._check_and_refresh_token)
|
284
|
+
self._token_refresh_timer.daemon = True
|
285
|
+
self._token_refresh_timer.start()
|
286
|
+
logging.debug("Auto-refresh: Next token check scheduled in %.1f seconds", check_interval)
|
287
|
+
except Exception as e:
|
288
|
+
logging.error("Auto-refresh: Failed to schedule token refresh: %s", str(e))
|
184
289
|
|
185
290
|
def close(self):
|
186
291
|
"""Close the session and release resources."""
|
292
|
+
# Cancel token refresh timer
|
293
|
+
if hasattr(self, "_token_refresh_timer") and self._token_refresh_timer:
|
294
|
+
self._token_refresh_timer.cancel()
|
295
|
+
self._token_refresh_timer = None
|
296
|
+
|
297
|
+
# Close the session
|
187
298
|
if hasattr(self, "session") and self.session:
|
188
299
|
self.session.close()
|
189
300
|
self.session = None
|
190
|
-
|
191
|
-
|
192
|
-
|
301
|
+
|
302
|
+
# Logout from Keycloak if needed
|
303
|
+
if hasattr(self, "_keycloak_openid") and self._keycloak_openid and hasattr(self, "_jwt_token") and self._jwt_token and isinstance(self._jwt_token, dict) and "refresh_token" in self._jwt_token:
|
304
|
+
try:
|
305
|
+
self._keycloak_openid.logout(self._jwt_token.get("refresh_token"))
|
306
|
+
except Exception as e:
|
307
|
+
logging.warning("Failed to logout from Keycloak: %s", str(e))
|
308
|
+
finally:
|
309
|
+
self._keycloak_openid = None
|
193
310
|
|
194
311
|
def __enter__(self):
|
195
312
|
"""Support for context manager protocol."""
|
@@ -312,8 +429,17 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
312
429
|
str: JWT token.
|
313
430
|
"""
|
314
431
|
# Keycloak authentication if configured
|
315
|
-
if self.
|
316
|
-
self.jwt_token = self.
|
432
|
+
if self._keycloak_openid:
|
433
|
+
self.jwt_token = self._keycloak_openid.token(
|
434
|
+
username=user_name,
|
435
|
+
password=user_pwd,
|
436
|
+
totp=totp_code if totp_code else None,
|
437
|
+
grant_type="password", # grant type for token request: "password" / "client_credentials" / "refresh_token"
|
438
|
+
scope="openid", # what information to include in the token, such as "openid profile email"
|
439
|
+
)
|
440
|
+
|
441
|
+
if self._auto_refresh_token:
|
442
|
+
self._schedule_token_refresh()
|
317
443
|
return self.jwt_token
|
318
444
|
|
319
445
|
# Standard App Mesh authentication
|
@@ -323,18 +449,22 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
323
449
|
path="/appmesh/login",
|
324
450
|
header={
|
325
451
|
"Authorization": "Basic " + base64.b64encode((user_name + ":" + user_pwd).encode()).decode(),
|
326
|
-
"Expire-Seconds": self._parse_duration(timeout_seconds),
|
327
|
-
**({"Audience": audience} if audience else {}),
|
452
|
+
"X-Expire-Seconds": self._parse_duration(timeout_seconds),
|
453
|
+
**({"X-Audience": audience} if audience else {}),
|
454
|
+
# **({"X-Totp-Code": totp_code} if totp_code else {}),
|
328
455
|
},
|
329
456
|
)
|
330
457
|
if resp.status_code == HTTPStatus.OK:
|
331
|
-
if "
|
332
|
-
self.jwt_token = resp.json()["
|
333
|
-
elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "
|
334
|
-
challenge = resp.json()["
|
458
|
+
if "access_token" in resp.json():
|
459
|
+
self.jwt_token = resp.json()["access_token"]
|
460
|
+
elif resp.status_code == HTTPStatus.PRECONDITION_REQUIRED and "totp_challenge" in resp.json():
|
461
|
+
challenge = resp.json()["totp_challenge"]
|
335
462
|
self.validate_totp(user_name, challenge, totp_code, timeout_seconds)
|
336
463
|
else:
|
337
464
|
raise Exception(resp.text)
|
465
|
+
|
466
|
+
if self._auto_refresh_token:
|
467
|
+
self._schedule_token_refresh()
|
338
468
|
return self.jwt_token
|
339
469
|
|
340
470
|
def validate_totp(self, username: str, challenge: str, code: str, timeout: Union[int, str] = DURATION_ONE_WEEK_ISO) -> str:
|
@@ -357,17 +487,16 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
357
487
|
resp = self._request_http(
|
358
488
|
AppMeshClient.Method.POST,
|
359
489
|
path="/appmesh/totp/validate",
|
360
|
-
|
361
|
-
"
|
362
|
-
"
|
363
|
-
"
|
364
|
-
"
|
490
|
+
body={
|
491
|
+
"user_name": username,
|
492
|
+
"totp_code": code,
|
493
|
+
"totp_challenge": challenge,
|
494
|
+
"expire_seconds": self._parse_duration(timeout),
|
365
495
|
},
|
366
496
|
)
|
367
|
-
if resp.status_code == HTTPStatus.OK:
|
368
|
-
|
369
|
-
|
370
|
-
return self.jwt_token
|
497
|
+
if resp.status_code == HTTPStatus.OK and "access_token" in resp.json():
|
498
|
+
self.jwt_token = resp.json()["access_token"]
|
499
|
+
return self.jwt_token
|
371
500
|
raise Exception(resp.text)
|
372
501
|
|
373
502
|
def logoff(self) -> bool:
|
@@ -376,15 +505,30 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
376
505
|
Returns:
|
377
506
|
bool: logoff success or failure.
|
378
507
|
"""
|
379
|
-
|
508
|
+
result = False
|
509
|
+
# Handle Keycloak logout if configured
|
510
|
+
if self._keycloak_openid and self.jwt_token and isinstance(self.jwt_token, dict) and "refresh_token" in self.jwt_token:
|
511
|
+
refresh_token = self.jwt_token.get("refresh_token")
|
512
|
+
self._keycloak_openid.logout(refresh_token)
|
513
|
+
self.jwt_token = None
|
514
|
+
result = True
|
515
|
+
|
516
|
+
# Standard App Mesh logout
|
517
|
+
if self.jwt_token and isinstance(self.jwt_token, str):
|
380
518
|
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/self/logoff")
|
381
519
|
self.jwt_token = None
|
382
|
-
|
383
|
-
|
520
|
+
result = resp.status_code == HTTPStatus.OK
|
521
|
+
|
522
|
+
# Cancel token refresh timer
|
523
|
+
if self._token_refresh_timer:
|
524
|
+
self._token_refresh_timer.cancel()
|
525
|
+
self._token_refresh_timer = None
|
526
|
+
|
527
|
+
return result
|
384
528
|
|
385
529
|
def authentication(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
|
386
530
|
"""Deprecated: Use authenticate() instead."""
|
387
|
-
return self.authenticate(token, permission)
|
531
|
+
return self.authenticate(token, permission, audience)
|
388
532
|
|
389
533
|
def authenticate(self, token: str, permission: Optional[str] = None, audience: Optional[str] = None) -> bool:
|
390
534
|
"""Authenticate with a token and verify permission if specified.
|
@@ -403,8 +547,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
403
547
|
old_token = self.jwt_token
|
404
548
|
self.jwt_token = token
|
405
549
|
headers = {
|
406
|
-
**({"Audience": audience} if audience else {}),
|
407
|
-
**({"
|
550
|
+
**({"X-Audience": audience} if audience else {}),
|
551
|
+
**({"X-Permission": permission} if permission else {}),
|
408
552
|
}
|
409
553
|
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/auth", header=headers)
|
410
554
|
if resp.status_code != HTTPStatus.OK:
|
@@ -419,27 +563,46 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
419
563
|
timeout_seconds (int | str, optional): token expire timeout of seconds. support ISO 8601 durations (e.g., 'P1Y2M3DT4H5M6S' 'P1W').
|
420
564
|
|
421
565
|
Returns:
|
422
|
-
str: The new JWT token
|
566
|
+
str: The new JWT token. The old token will be invalidated.
|
567
|
+
|
568
|
+
Raises:
|
569
|
+
Exception: If token renewal fails or no token exists to renew
|
423
570
|
"""
|
424
|
-
#
|
425
|
-
if self.
|
426
|
-
|
571
|
+
# Ensure token exists
|
572
|
+
if not self.jwt_token:
|
573
|
+
raise Exception("No token to renew")
|
574
|
+
|
575
|
+
try:
|
576
|
+
# Handle Keycloak token (dictionary format)
|
577
|
+
if self._keycloak_openid and isinstance(self.jwt_token, dict) and "refresh_token" in self.jwt_token:
|
578
|
+
new_token = self._keycloak_openid.refresh_token(self.jwt_token.get("refresh_token"))
|
579
|
+
self.jwt_token = new_token
|
580
|
+
|
581
|
+
# Handle App Mesh token (string format)
|
582
|
+
elif isinstance(self.jwt_token, str):
|
583
|
+
resp = self._request_http(
|
584
|
+
AppMeshClient.Method.POST,
|
585
|
+
path="/appmesh/token/renew",
|
586
|
+
header={
|
587
|
+
"X-Expire-Seconds": self._parse_duration(timeout),
|
588
|
+
},
|
589
|
+
)
|
590
|
+
if resp.status_code == HTTPStatus.OK:
|
591
|
+
if "access_token" in resp.json():
|
592
|
+
new_token = resp.json()["access_token"]
|
593
|
+
self.jwt_token = new_token
|
594
|
+
else:
|
595
|
+
raise Exception("Token renewal response missing access_token")
|
596
|
+
else:
|
597
|
+
raise Exception(resp.text)
|
598
|
+
else:
|
599
|
+
raise Exception("Unsupported token format")
|
600
|
+
|
427
601
|
return self.jwt_token
|
428
602
|
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
AppMeshClient.Method.POST,
|
433
|
-
path="/appmesh/token/renew",
|
434
|
-
header={
|
435
|
-
"Expire-Seconds": self._parse_duration(timeout),
|
436
|
-
},
|
437
|
-
)
|
438
|
-
if resp.status_code == HTTPStatus.OK:
|
439
|
-
if "Access-Token" in resp.json():
|
440
|
-
self.jwt_token = resp.json()["Access-Token"]
|
441
|
-
return self.jwt_token
|
442
|
-
raise Exception(resp.text)
|
603
|
+
except Exception as e:
|
604
|
+
logging.error("Token renewal failed: %s", str(e))
|
605
|
+
raise Exception(f"Token renewal failed: {str(e)}") from e
|
443
606
|
|
444
607
|
def get_totp_secret(self) -> str:
|
445
608
|
"""Generate TOTP secret for the current user and return MFA URI.
|
@@ -449,7 +612,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
449
612
|
"""
|
450
613
|
resp = self._request_http(method=AppMeshClient.Method.POST, path="/appmesh/totp/secret")
|
451
614
|
if resp.status_code == HTTPStatus.OK:
|
452
|
-
totp_uri = base64.b64decode(resp.json()["
|
615
|
+
totp_uri = base64.b64decode(resp.json()["mfa_uri"]).decode()
|
453
616
|
return self._parse_totp_uri(totp_uri).get("secret")
|
454
617
|
raise Exception(resp.text)
|
455
618
|
|
@@ -465,11 +628,11 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
465
628
|
resp = self._request_http(
|
466
629
|
method=AppMeshClient.Method.POST,
|
467
630
|
path="/appmesh/totp/setup",
|
468
|
-
header={"Totp": totp_code},
|
631
|
+
header={"X-Totp-Code": totp_code},
|
469
632
|
)
|
470
633
|
if resp.status_code == HTTPStatus.OK:
|
471
|
-
if "
|
472
|
-
self.jwt_token = resp.json()["
|
634
|
+
if "access_token" in resp.json():
|
635
|
+
self.jwt_token = resp.json()["access_token"]
|
473
636
|
return self.jwt_token
|
474
637
|
else:
|
475
638
|
raise Exception(resp.text)
|
@@ -576,8 +739,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
576
739
|
"timeout": str(timeout),
|
577
740
|
},
|
578
741
|
)
|
579
|
-
out_position = int(resp.headers["Output-Position"]) if "Output-Position" in resp.headers else None
|
580
|
-
exit_code = int(resp.headers["Exit-Code"]) if "Exit-Code" in resp.headers else None
|
742
|
+
out_position = int(resp.headers["X-Output-Position"]) if "X-Output-Position" in resp.headers else None
|
743
|
+
exit_code = int(resp.headers["X-Exit-Code"]) if "X-Exit-Code" in resp.headers else None
|
581
744
|
return AppOutput(status_code=resp.status_code, output=resp.text, out_position=out_position, exit_code=exit_code)
|
582
745
|
|
583
746
|
def check_app_health(self, app_name: str) -> bool:
|
@@ -728,7 +891,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
728
891
|
resp = self._request_http(
|
729
892
|
method=AppMeshClient.Method.POST,
|
730
893
|
path=f"/appmesh/user/{user_name}/passwd",
|
731
|
-
|
894
|
+
body={"new_password": base64.b64encode(new_password.encode()).decode()},
|
732
895
|
)
|
733
896
|
if resp.status_code != HTTPStatus.OK:
|
734
897
|
raise Exception(resp.text)
|
@@ -817,6 +980,9 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
817
980
|
Returns:
|
818
981
|
dict: user definition.
|
819
982
|
"""
|
983
|
+
if self._keycloak_openid and isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token:
|
984
|
+
return self._keycloak_openid.userinfo(self.jwt_token.get("access_token"))
|
985
|
+
|
820
986
|
resp = self._request_http(method=AppMeshClient.Method.GET, path="/appmesh/user/self")
|
821
987
|
if resp.status_code != HTTPStatus.OK:
|
822
988
|
raise Exception(resp.text)
|
@@ -970,7 +1136,7 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
970
1136
|
local_file (str): the local file path to be downloaded.
|
971
1137
|
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
972
1138
|
"""
|
973
|
-
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={
|
1139
|
+
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header={self.HTTP_HEADER_KEY_X_FILE_PATH: remote_file})
|
974
1140
|
resp.raise_for_status()
|
975
1141
|
|
976
1142
|
# Write the file content locally
|
@@ -981,15 +1147,15 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
981
1147
|
|
982
1148
|
# Apply file attributes (permissions, owner, group) if requested
|
983
1149
|
if apply_file_attributes:
|
984
|
-
if "File-Mode" in resp.headers:
|
985
|
-
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
986
|
-
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
987
|
-
file_uid = int(resp.headers["File-User"])
|
988
|
-
file_gid = int(resp.headers["File-Group"])
|
1150
|
+
if "X-File-Mode" in resp.headers:
|
1151
|
+
os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
|
1152
|
+
if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
|
1153
|
+
file_uid = int(resp.headers["X-File-User"])
|
1154
|
+
file_gid = int(resp.headers["X-File-Group"])
|
989
1155
|
try:
|
990
1156
|
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
991
1157
|
except PermissionError:
|
992
|
-
|
1158
|
+
logging.warning(f"Warning: Unable to change owner/group of {local_file}. Operation requires elevated privileges.")
|
993
1159
|
|
994
1160
|
def upload_file(self, local_file: str, remote_file: str, apply_file_attributes: bool = True) -> None:
|
995
1161
|
"""Upload a local file to the remote server. Optionally, the remote file will have the same permission as the local file.
|
@@ -1010,14 +1176,14 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1010
1176
|
|
1011
1177
|
with open(file=local_file, mode="rb") as fp:
|
1012
1178
|
encoder = MultipartEncoder(fields={"filename": os.path.basename(remote_file), "file": ("filename", fp, "application/octet-stream")})
|
1013
|
-
header = {
|
1179
|
+
header = {self.HTTP_HEADER_KEY_X_FILE_PATH: parse.quote(remote_file), "Content-Type": encoder.content_type}
|
1014
1180
|
|
1015
1181
|
# Include file attributes (permissions, owner, group) if requested
|
1016
1182
|
if apply_file_attributes:
|
1017
1183
|
file_stat = os.stat(local_file)
|
1018
|
-
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1019
|
-
header["File-User"] = str(file_stat.st_uid)
|
1020
|
-
header["File-Group"] = str(file_stat.st_gid)
|
1184
|
+
header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
1185
|
+
header["X-File-User"] = str(file_stat.st_uid)
|
1186
|
+
header["X-File-Group"] = str(file_stat.st_gid)
|
1021
1187
|
|
1022
1188
|
# Upload file with or without attributes
|
1023
1189
|
# https://stackoverflow.com/questions/22567306/python-requests-file-upload
|
@@ -1160,8 +1326,8 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1160
1326
|
if resp.status_code == HTTPStatus.OK:
|
1161
1327
|
if stdout_print:
|
1162
1328
|
print(resp.text, end="")
|
1163
|
-
if "Exit-Code" in resp.headers:
|
1164
|
-
exit_code = int(resp.headers.get("Exit-Code"))
|
1329
|
+
if "X-Exit-Code" in resp.headers:
|
1330
|
+
exit_code = int(resp.headers.get("X-Exit-Code"))
|
1165
1331
|
elif stdout_print:
|
1166
1332
|
print(resp.text)
|
1167
1333
|
|
@@ -1180,18 +1346,12 @@ class AppMeshClient(metaclass=abc.ABCMeta):
|
|
1180
1346
|
Returns:
|
1181
1347
|
requests.Response: HTTP response
|
1182
1348
|
"""
|
1183
|
-
# Try to refresh token via Keycloak if using Keycloak and token needs refreshing
|
1184
|
-
if self._keycloak_client and self.jwt_token and not self._keycloak_client.validate_token():
|
1185
|
-
try:
|
1186
|
-
self.jwt_token = self._keycloak_client.get_active_token()
|
1187
|
-
except Exception as e:
|
1188
|
-
print(f"Token refresh failed: {str(e)}")
|
1189
|
-
|
1190
1349
|
rest_url = parse.urljoin(self.auth_server_url, path)
|
1191
1350
|
|
1192
1351
|
header = {} if header is None else header
|
1193
1352
|
if self.jwt_token:
|
1194
|
-
|
1353
|
+
token = self.jwt_token["access_token"] if isinstance(self.jwt_token, dict) and "access_token" in self.jwt_token else self.jwt_token
|
1354
|
+
header["Authorization"] = "Bearer " + token
|
1195
1355
|
if self.forward_to and len(self.forward_to) > 0:
|
1196
1356
|
if ":" in self.forward_to:
|
1197
1357
|
header[self.HTTP_HEADER_KEY_X_TARGET_HOST] = self.forward_to
|
appmesh/tcp_client.py
CHANGED
@@ -153,7 +153,7 @@ class AppMeshClientTCP(AppMeshClient):
|
|
153
153
|
local_file (str): the local file path to be downloaded.
|
154
154
|
apply_file_attributes (bool): whether to apply file attributes (permissions, owner, group) to the local file.
|
155
155
|
"""
|
156
|
-
header = {
|
156
|
+
header = {AppMeshClient.HTTP_HEADER_KEY_X_FILE_PATH: remote_file}
|
157
157
|
header[self.HTTP_HEADER_KEY_X_RECV_FILE_SOCKET] = "true"
|
158
158
|
resp = self._request_http(AppMeshClient.Method.GET, path="/appmesh/file/download", header=header)
|
159
159
|
|
@@ -169,11 +169,11 @@ class AppMeshClientTCP(AppMeshClient):
|
|
169
169
|
fp.write(chunk_data)
|
170
170
|
|
171
171
|
if apply_file_attributes:
|
172
|
-
if "File-Mode" in resp.headers:
|
173
|
-
os.chmod(path=local_file, mode=int(resp.headers["File-Mode"]))
|
174
|
-
if "File-User" in resp.headers and "File-Group" in resp.headers:
|
175
|
-
file_uid = int(resp.headers["File-User"])
|
176
|
-
file_gid = int(resp.headers["File-Group"])
|
172
|
+
if "X-File-Mode" in resp.headers:
|
173
|
+
os.chmod(path=local_file, mode=int(resp.headers["X-File-Mode"]))
|
174
|
+
if "X-File-User" in resp.headers and "X-File-Group" in resp.headers:
|
175
|
+
file_uid = int(resp.headers["X-File-User"])
|
176
|
+
file_gid = int(resp.headers["X-File-Group"])
|
177
177
|
try:
|
178
178
|
os.chown(path=local_file, uid=file_uid, gid=file_gid)
|
179
179
|
except PermissionError:
|
@@ -195,14 +195,14 @@ class AppMeshClientTCP(AppMeshClient):
|
|
195
195
|
raise FileNotFoundError(f"Local file not found: {local_file}")
|
196
196
|
|
197
197
|
with open(file=local_file, mode="rb") as fp:
|
198
|
-
header = {
|
198
|
+
header = {AppMeshClient.HTTP_HEADER_KEY_X_FILE_PATH: remote_file, "Content-Type": "text/plain"}
|
199
199
|
header[self.HTTP_HEADER_KEY_X_SEND_FILE_SOCKET] = "true"
|
200
200
|
|
201
201
|
if apply_file_attributes:
|
202
202
|
file_stat = os.stat(local_file)
|
203
|
-
header["File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
204
|
-
header["File-User"] = str(file_stat.st_uid)
|
205
|
-
header["File-Group"] = str(file_stat.st_gid)
|
203
|
+
header["X-File-Mode"] = str(file_stat.st_mode & 0o777) # Mask to keep only permission bits
|
204
|
+
header["X-File-User"] = str(file_stat.st_uid)
|
205
|
+
header["X-File-Group"] = str(file_stat.st_gid)
|
206
206
|
|
207
207
|
resp = self._request_http(AppMeshClient.Method.POST, path="/appmesh/file/upload", header=header)
|
208
208
|
|
@@ -3,12 +3,11 @@ appmesh/app.py,sha256=9Q-SOOej-MH13BU5Dv2iTa-p-sECCJQp6ZX9DjWWmwE,10526
|
|
3
3
|
appmesh/app_output.py,sha256=JK_TMKgjvaw4n_ys_vmN5S4MyWVZpmD7NlKz_UyMIM8,1015
|
4
4
|
appmesh/app_run.py,sha256=9ISKGZ3k3kkbQvSsPfRfkOLqD9xhbqNOM7ork9F4w9c,1712
|
5
5
|
appmesh/appmesh_client.py,sha256=0ltkqHZUq094gKneYmC0bEZCP0X9kHTp9fccKdWFWP0,339
|
6
|
-
appmesh/http_client.py,sha256=
|
7
|
-
appmesh/
|
8
|
-
appmesh/tcp_client.py,sha256=RkHl5s8jE333BJOgxJqJ_fvjbdRQza7ciV49vLT6YO4,10923
|
6
|
+
appmesh/http_client.py,sha256=2qrJYd9MpPZz5RNWTQIgxAFmHjn0rNsxLnKt1Ajn7hU,55810
|
7
|
+
appmesh/tcp_client.py,sha256=Id1aIKVWncTSZiKRVa4sgwo1tFX2wRqOLiTnI9-dNkE,11001
|
9
8
|
appmesh/tcp_messages.py,sha256=w1Kehz_aX4X2CYAUsy9mFVJRhxnLQwwc6L58W4YkQqs,969
|
10
9
|
appmesh/tcp_transport.py,sha256=-XDTQbsKL3yCbguHeW2jNqXpYgnCyHsH4rwcaJ46AS8,8645
|
11
|
-
appmesh-1.
|
12
|
-
appmesh-1.
|
13
|
-
appmesh-1.
|
14
|
-
appmesh-1.
|
10
|
+
appmesh-1.5.0.dist-info/METADATA,sha256=AsGZC2UcCoZNNn-fPmKnP8rmGujy65aqvnPxKH6Ix1Y,11663
|
11
|
+
appmesh-1.5.0.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
|
12
|
+
appmesh-1.5.0.dist-info/top_level.txt,sha256=-y0MNQOGJxUzLdHZ6E_Rfv5_LNCkV-GTmOBME_b6pg8,8
|
13
|
+
appmesh-1.5.0.dist-info/RECORD,,
|
appmesh/keycloak.py
DELETED
@@ -1,248 +0,0 @@
|
|
1
|
-
# Keycloak authentication client for App Mesh Python SDK
|
2
|
-
|
3
|
-
import base64
|
4
|
-
import json
|
5
|
-
import time
|
6
|
-
from typing import Dict, Optional, Tuple, Any
|
7
|
-
import requests
|
8
|
-
|
9
|
-
|
10
|
-
class KeycloakClient:
|
11
|
-
"""
|
12
|
-
Client for authenticating with Keycloak and obtaining tokens for App Mesh.
|
13
|
-
|
14
|
-
This class handles the OAuth2/OIDC workflow with Keycloak, including:
|
15
|
-
- Direct authentication with username/password
|
16
|
-
- Token refresh
|
17
|
-
- Token validation
|
18
|
-
"""
|
19
|
-
|
20
|
-
def __init__(
|
21
|
-
self,
|
22
|
-
auth_server_url: str,
|
23
|
-
realm: str,
|
24
|
-
client_id: str,
|
25
|
-
client_secret: Optional[str] = None,
|
26
|
-
ssl_verify: bool = True,
|
27
|
-
timeout: Tuple[int, int] = (10, 60),
|
28
|
-
token_refresh_threshold: int = 30,
|
29
|
-
):
|
30
|
-
"""Initialize Keycloak client.
|
31
|
-
|
32
|
-
Args:
|
33
|
-
auth_server_url (str): Keycloak server URL (e.g. https://keycloak.example.com/auth)
|
34
|
-
realm (str): Keycloak realm name
|
35
|
-
client_id (str): Client ID registered in Keycloak
|
36
|
-
client_secret (Optional[str], optional): Client secret if using confidential client. Defaults to None.
|
37
|
-
ssl_verify (bool, optional): Verify SSL certificates. Defaults to True.
|
38
|
-
timeout (Tuple[int, int], optional): Connection and read timeouts. Defaults to (10, 60).
|
39
|
-
token_refresh_threshold (int, optional): Seconds before token expiry to trigger refresh. Defaults to 30.
|
40
|
-
"""
|
41
|
-
self.auth_server_url = auth_server_url.rstrip("/")
|
42
|
-
self.realm = realm
|
43
|
-
self.client_id = client_id
|
44
|
-
self.client_secret = client_secret
|
45
|
-
self.ssl_verify = ssl_verify
|
46
|
-
self.timeout = timeout
|
47
|
-
self.token_refresh_threshold = token_refresh_threshold
|
48
|
-
|
49
|
-
# Token storage
|
50
|
-
self.access_token = None
|
51
|
-
self.refresh_token = None
|
52
|
-
self.token_expires_at = 0
|
53
|
-
|
54
|
-
# Construct the token endpoint URL
|
55
|
-
self.token_endpoint = f"{self.auth_server_url}/realms/{self.realm}/protocol/openid-connect/token"
|
56
|
-
self.userinfo_endpoint = f"{self.auth_server_url}/realms/{self.realm}/protocol/openid-connect/userinfo"
|
57
|
-
|
58
|
-
# Create a session for connection pooling
|
59
|
-
self.session = requests.Session()
|
60
|
-
|
61
|
-
def __enter__(self):
|
62
|
-
"""Support for context manager protocol."""
|
63
|
-
return self
|
64
|
-
|
65
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
66
|
-
"""Clean up resources when exiting context."""
|
67
|
-
self.close()
|
68
|
-
|
69
|
-
def __del__(self):
|
70
|
-
"""Ensure resources are properly released when the object is garbage collected."""
|
71
|
-
try:
|
72
|
-
self.close()
|
73
|
-
except Exception:
|
74
|
-
pass # Avoid exceptions during garbage collection
|
75
|
-
|
76
|
-
def authenticate(self, username: str, password: str) -> str:
|
77
|
-
"""Authenticate with username and password.
|
78
|
-
|
79
|
-
Args:
|
80
|
-
username (str): Username
|
81
|
-
password (str): Password
|
82
|
-
|
83
|
-
Returns:
|
84
|
-
str: Access token
|
85
|
-
|
86
|
-
Raises:
|
87
|
-
Exception: If authentication fails
|
88
|
-
"""
|
89
|
-
data = {
|
90
|
-
"client_id": self.client_id,
|
91
|
-
"grant_type": "password",
|
92
|
-
"username": username,
|
93
|
-
"password": password,
|
94
|
-
}
|
95
|
-
|
96
|
-
if self.client_secret:
|
97
|
-
data["client_secret"] = self.client_secret
|
98
|
-
|
99
|
-
try:
|
100
|
-
response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
|
101
|
-
|
102
|
-
if response.status_code != 200:
|
103
|
-
raise Exception(f"Authentication failed: {response.text}")
|
104
|
-
|
105
|
-
token_data = response.json()
|
106
|
-
self._update_token_data(token_data)
|
107
|
-
return self.access_token
|
108
|
-
|
109
|
-
except requests.RequestException as e:
|
110
|
-
raise Exception(f"Failed to connect to Keycloak: {str(e)}")
|
111
|
-
|
112
|
-
def refresh_tokens(self) -> str:
|
113
|
-
"""Refresh the access token using the refresh token.
|
114
|
-
|
115
|
-
Returns:
|
116
|
-
str: New access token
|
117
|
-
|
118
|
-
Raises:
|
119
|
-
Exception: If refresh fails
|
120
|
-
"""
|
121
|
-
if not self.refresh_token:
|
122
|
-
raise Exception("No refresh token available")
|
123
|
-
|
124
|
-
data = {
|
125
|
-
"client_id": self.client_id,
|
126
|
-
"grant_type": "refresh_token",
|
127
|
-
"refresh_token": self.refresh_token,
|
128
|
-
}
|
129
|
-
|
130
|
-
if self.client_secret:
|
131
|
-
data["client_secret"] = self.client_secret
|
132
|
-
|
133
|
-
try:
|
134
|
-
response = self.session.post(self.token_endpoint, data=data, verify=self.ssl_verify, timeout=self.timeout)
|
135
|
-
|
136
|
-
if response.status_code != 200:
|
137
|
-
raise Exception(f"Token refresh failed: {response.text}")
|
138
|
-
|
139
|
-
token_data = response.json()
|
140
|
-
self._update_token_data(token_data)
|
141
|
-
return self.access_token
|
142
|
-
|
143
|
-
except requests.RequestException as e:
|
144
|
-
raise Exception(f"Failed to connect to Keycloak: {str(e)}")
|
145
|
-
|
146
|
-
def validate_token(self) -> bool:
|
147
|
-
"""Check if the current token is valid and not expired.
|
148
|
-
|
149
|
-
Returns:
|
150
|
-
bool: True if valid, False otherwise
|
151
|
-
"""
|
152
|
-
if not self.access_token:
|
153
|
-
return False
|
154
|
-
|
155
|
-
# Check if the token is expired based on our local expiry time
|
156
|
-
if time.time() > self.token_expires_at:
|
157
|
-
return False
|
158
|
-
|
159
|
-
return True
|
160
|
-
|
161
|
-
def get_active_token(self) -> str:
|
162
|
-
"""Get a valid access token, refreshing if necessary.
|
163
|
-
|
164
|
-
Returns:
|
165
|
-
str: Valid access token
|
166
|
-
|
167
|
-
Raises:
|
168
|
-
Exception: If no valid token can be obtained
|
169
|
-
"""
|
170
|
-
if self.validate_token():
|
171
|
-
return self.access_token
|
172
|
-
|
173
|
-
if self.refresh_token:
|
174
|
-
try:
|
175
|
-
return self.refresh_tokens()
|
176
|
-
except Exception:
|
177
|
-
pass
|
178
|
-
|
179
|
-
raise Exception("No valid token available and unable to refresh")
|
180
|
-
|
181
|
-
def get_user_info(self) -> Dict[str, Any]:
|
182
|
-
"""Get information about the authenticated user.
|
183
|
-
|
184
|
-
Returns:
|
185
|
-
Dict[str, Any]: User information
|
186
|
-
|
187
|
-
Raises:
|
188
|
-
Exception: If request fails
|
189
|
-
"""
|
190
|
-
if not self.access_token:
|
191
|
-
raise Exception("Not authenticated")
|
192
|
-
|
193
|
-
headers = {"Authorization": f"Bearer {self.access_token}"}
|
194
|
-
|
195
|
-
try:
|
196
|
-
response = self.session.get(self.userinfo_endpoint, headers=headers, verify=self.ssl_verify, timeout=self.timeout)
|
197
|
-
|
198
|
-
if response.status_code != 200:
|
199
|
-
raise Exception(f"Failed to get user info: {response.text}")
|
200
|
-
|
201
|
-
return response.json()
|
202
|
-
|
203
|
-
except requests.RequestException as e:
|
204
|
-
raise Exception(f"Failed to connect to Keycloak: {str(e)}")
|
205
|
-
|
206
|
-
def _update_token_data(self, token_data: Dict[str, Any]) -> None:
|
207
|
-
"""Update the stored token data.
|
208
|
-
|
209
|
-
Args:
|
210
|
-
token_data (Dict[str, Any]): Token data from Keycloak
|
211
|
-
"""
|
212
|
-
self.access_token = token_data["access_token"]
|
213
|
-
self.refresh_token = token_data.get("refresh_token")
|
214
|
-
|
215
|
-
# Calculate token expiration time with configurable buffer
|
216
|
-
expires_in = token_data.get("expires_in", 300)
|
217
|
-
self.token_expires_at = time.time() + expires_in - self.token_refresh_threshold
|
218
|
-
|
219
|
-
def close(self) -> None:
|
220
|
-
"""Close the session and release resources."""
|
221
|
-
if hasattr(self, "session") and self.session:
|
222
|
-
self.session.close()
|
223
|
-
self.session = None
|
224
|
-
|
225
|
-
@staticmethod
|
226
|
-
def decode_token(token: str) -> Dict[str, Any]:
|
227
|
-
"""Decode a JWT token without verification.
|
228
|
-
|
229
|
-
Args:
|
230
|
-
token (str): JWT token
|
231
|
-
|
232
|
-
Returns:
|
233
|
-
Dict[str, Any]: Decoded token payload
|
234
|
-
"""
|
235
|
-
parts = token.split(".")
|
236
|
-
if len(parts) != 3:
|
237
|
-
raise Exception("Invalid token format")
|
238
|
-
|
239
|
-
# Decode the payload (second part)
|
240
|
-
payload = parts[1]
|
241
|
-
# Add padding if necessary
|
242
|
-
payload += "=" * (4 - len(payload) % 4) if len(payload) % 4 else ""
|
243
|
-
|
244
|
-
try:
|
245
|
-
decoded = base64.b64decode(payload)
|
246
|
-
return json.loads(decoded)
|
247
|
-
except Exception as e:
|
248
|
-
raise Exception(f"Failed to decode token: {str(e)}")
|
File without changes
|