systemlink-cli 2.2.2__py3-none-any.whl → 2.3.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.
- slcli/_version.py +1 -1
- slcli/config_click.py +54 -14
- slcli/main.py +3 -3
- slcli/ssl_trust.py +317 -60
- slcli/webapp_click.py +51 -46
- {systemlink_cli-2.2.2.dist-info → systemlink_cli-2.3.0.dist-info}/METADATA +2 -1
- {systemlink_cli-2.2.2.dist-info → systemlink_cli-2.3.0.dist-info}/RECORD +10 -10
- {systemlink_cli-2.2.2.dist-info → systemlink_cli-2.3.0.dist-info}/WHEEL +0 -0
- {systemlink_cli-2.2.2.dist-info → systemlink_cli-2.3.0.dist-info}/entry_points.txt +0 -0
- {systemlink_cli-2.2.2.dist-info → systemlink_cli-2.3.0.dist-info}/licenses/LICENSE +0 -0
slcli/_version.py
CHANGED
slcli/config_click.py
CHANGED
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import re
|
|
7
7
|
import ssl
|
|
8
8
|
import sys
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from typing import Any, NoReturn, Optional
|
|
10
11
|
from urllib.parse import urlparse
|
|
11
12
|
|
|
@@ -26,14 +27,16 @@ from .platform import (
|
|
|
26
27
|
from .profiles import Profile, ProfileConfig, check_config_file_permissions
|
|
27
28
|
from .rich_output import render_table
|
|
28
29
|
from .ssl_trust import (
|
|
30
|
+
get_managed_trust_path,
|
|
29
31
|
get_managed_trust_records,
|
|
30
32
|
get_ssl_server_origin,
|
|
31
33
|
inspect_server_certificate,
|
|
34
|
+
load_ca_certificate,
|
|
32
35
|
remove_managed_trust,
|
|
33
36
|
save_managed_certificate,
|
|
34
37
|
)
|
|
35
38
|
from .table_utils import output_formatted_list
|
|
36
|
-
from .utils import ExitCodes,
|
|
39
|
+
from .utils import ExitCodes, get_base_url_resolution
|
|
37
40
|
|
|
38
41
|
API_KEY_LENGTH = 42
|
|
39
42
|
API_KEY_PATTERN = re.compile(rf"^[A-Za-z0-9_-]{{{API_KEY_LENGTH}}}$")
|
|
@@ -765,12 +768,31 @@ def register_config_commands(cli: Any) -> None:
|
|
|
765
768
|
)
|
|
766
769
|
|
|
767
770
|
certificate_details = certificate.to_dict()
|
|
771
|
+
trusted_certificate = None
|
|
772
|
+
try:
|
|
773
|
+
trusted_path = get_managed_trust_path(certificate.origin)
|
|
774
|
+
if trusted_path is not None:
|
|
775
|
+
trusted_certificate = json.loads(
|
|
776
|
+
trusted_path.with_suffix(".json").read_text(encoding="utf-8")
|
|
777
|
+
)
|
|
778
|
+
trusted_certificate["origin"] = certificate.origin
|
|
779
|
+
trusted_certificate.setdefault("trust-type", "leaf")
|
|
780
|
+
except (OSError, ValueError) as exc:
|
|
781
|
+
click.echo(f"Could not read managed certificate metadata: {exc}.", err=True)
|
|
782
|
+
trusted_certificate = None
|
|
768
783
|
if output_format == "json":
|
|
784
|
+
certificate_details["trusted-certificate"] = trusted_certificate
|
|
769
785
|
click.echo(json.dumps(certificate_details, indent=2))
|
|
770
786
|
return
|
|
771
787
|
|
|
772
788
|
click.echo(f"Server certificate for {certificate_details.get('origin', 'unknown')}")
|
|
773
789
|
_show_certificate_details(certificate_details)
|
|
790
|
+
if trusted_certificate:
|
|
791
|
+
kind = "CA" if trusted_certificate.get("trust-type") == "ca" else "leaf"
|
|
792
|
+
click.echo(f"\nTrusted {kind} certificate for this origin")
|
|
793
|
+
_show_certificate_details(trusted_certificate)
|
|
794
|
+
else:
|
|
795
|
+
click.echo("\nNo managed trust certificate for this origin.")
|
|
774
796
|
|
|
775
797
|
@trust.command(name="list")
|
|
776
798
|
@click.option(
|
|
@@ -794,13 +816,14 @@ def register_config_commands(cli: Any) -> None:
|
|
|
794
816
|
[
|
|
795
817
|
str(record.get("origin", "")),
|
|
796
818
|
str(record.get("fingerprint", "")),
|
|
819
|
+
"CA" if record.get("trust-type") == "ca" else "leaf",
|
|
797
820
|
"yes" if record.get("self-signed") else "no",
|
|
798
821
|
]
|
|
799
822
|
for record in records
|
|
800
823
|
]
|
|
801
824
|
render_table(
|
|
802
|
-
headers=["SERVER", "SHA-256", "SELF-SIGNED"],
|
|
803
|
-
column_widths=[40, 64, 12],
|
|
825
|
+
headers=["SERVER", "SHA-256", "TRUST TYPE", "SELF-SIGNED"],
|
|
826
|
+
column_widths=[40, 64, 10, 12],
|
|
804
827
|
rows=rows,
|
|
805
828
|
show_total=True,
|
|
806
829
|
total_label="certificate(s)",
|
|
@@ -808,30 +831,47 @@ def register_config_commands(cli: Any) -> None:
|
|
|
808
831
|
|
|
809
832
|
@trust.command(name="add")
|
|
810
833
|
@click.option("--url", help="HTTPS server URL (defaults to the active API URL)")
|
|
834
|
+
@click.option(
|
|
835
|
+
"--certificate",
|
|
836
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
837
|
+
help="Import one public CA certificate (PEM or DER), scoped to this server origin",
|
|
838
|
+
)
|
|
811
839
|
@click.option(
|
|
812
840
|
"--fingerprint",
|
|
813
841
|
required=True,
|
|
814
842
|
help="Expected SHA-256 fingerprint of the certificate to trust",
|
|
815
843
|
)
|
|
816
|
-
def add_trusted_certificate(
|
|
817
|
-
|
|
818
|
-
|
|
844
|
+
def add_trusted_certificate(
|
|
845
|
+
url: Optional[str], certificate: Optional[Path], fingerprint: str
|
|
846
|
+
) -> None:
|
|
847
|
+
"""Trust a live server certificate or imported CA after fingerprint verification."""
|
|
848
|
+
server_url = url or get_base_url_resolution().value
|
|
819
849
|
try:
|
|
820
850
|
expected_fingerprint = _normalize_fingerprint(fingerprint)
|
|
821
|
-
|
|
851
|
+
trusted_certificate = (
|
|
852
|
+
load_ca_certificate(server_url, certificate)
|
|
853
|
+
if certificate is not None
|
|
854
|
+
else inspect_server_certificate(server_url)
|
|
855
|
+
)
|
|
822
856
|
except (OSError, ValueError, ssl.SSLError) as exc:
|
|
823
|
-
_exit_with_validation_error(f"Could not
|
|
857
|
+
_exit_with_validation_error(f"Could not load the certificate: {exc}.")
|
|
824
858
|
|
|
825
|
-
|
|
826
|
-
if
|
|
859
|
+
_show_certificate_details(trusted_certificate.to_dict(), err=True)
|
|
860
|
+
if trusted_certificate.fingerprint != expected_fingerprint:
|
|
827
861
|
_exit_with_validation_error(
|
|
828
|
-
"The
|
|
862
|
+
"The certificate fingerprint does not match the supplied fingerprint."
|
|
863
|
+
)
|
|
864
|
+
if trusted_certificate.trust_type == "ca":
|
|
865
|
+
click.echo(
|
|
866
|
+
"Trusting certificates issued by this CA for this origin only; "
|
|
867
|
+
"hostname and validity verification remain enabled.",
|
|
868
|
+
err=True,
|
|
829
869
|
)
|
|
830
870
|
try:
|
|
831
|
-
path = save_managed_certificate(
|
|
871
|
+
path = save_managed_certificate(trusted_certificate)
|
|
832
872
|
except OSError as exc:
|
|
833
873
|
_exit_with_validation_error(f"Could not save the trusted certificate: {exc}.")
|
|
834
|
-
click.echo(f"✓ Trusted certificate for {
|
|
874
|
+
click.echo(f"✓ Trusted certificate for {trusted_certificate.origin}")
|
|
835
875
|
click.echo(f" Certificate file: {path}")
|
|
836
876
|
|
|
837
877
|
@trust.command(name="remove")
|
|
@@ -839,7 +879,7 @@ def register_config_commands(cli: Any) -> None:
|
|
|
839
879
|
@click.option("--force", is_flag=True, help="Skip confirmation prompt")
|
|
840
880
|
def remove_trusted_certificate(url: Optional[str], force: bool) -> None:
|
|
841
881
|
"""Remove a server certificate from the managed trust store."""
|
|
842
|
-
server_url = url or
|
|
882
|
+
server_url = url or get_base_url_resolution().value
|
|
843
883
|
try:
|
|
844
884
|
origin = get_ssl_server_origin(server_url)
|
|
845
885
|
except ValueError as exc:
|
slcli/main.py
CHANGED
|
@@ -35,7 +35,7 @@ from .rich_output import install_rich_output, render_table
|
|
|
35
35
|
from .routine_click import register_routine_commands
|
|
36
36
|
from .skill_click import register_skill_commands
|
|
37
37
|
from .spec_click import register_spec_commands
|
|
38
|
-
from .ssl_trust import OS_TRUST_INJECTED, OS_TRUST_REASON
|
|
38
|
+
from .ssl_trust import OS_TRUST_INJECTED, OS_TRUST_REASON, create_explicit_tls_context
|
|
39
39
|
from .state_click import register_state_commands
|
|
40
40
|
from .system_click import register_system_commands
|
|
41
41
|
from .tag_click import register_tag_commands
|
|
@@ -143,11 +143,11 @@ def _build_tls_debug_context(ssl_verify: Union[bool, str]) -> ssl.SSLContext:
|
|
|
143
143
|
return ctx
|
|
144
144
|
|
|
145
145
|
if isinstance(ssl_verify, str):
|
|
146
|
-
return
|
|
146
|
+
return create_explicit_tls_context(ssl_verify)
|
|
147
147
|
|
|
148
148
|
verify_env = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE")
|
|
149
149
|
if verify_env:
|
|
150
|
-
return
|
|
150
|
+
return create_explicit_tls_context(verify_env)
|
|
151
151
|
|
|
152
152
|
if OS_TRUST_INJECTED:
|
|
153
153
|
try:
|
slcli/ssl_trust.py
CHANGED
|
@@ -21,16 +21,21 @@ import sys
|
|
|
21
21
|
import traceback
|
|
22
22
|
from contextlib import contextmanager
|
|
23
23
|
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime, timezone
|
|
24
25
|
from pathlib import Path
|
|
26
|
+
from threading import RLock
|
|
25
27
|
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
26
28
|
from urllib.parse import urlparse
|
|
27
29
|
|
|
30
|
+
import idna
|
|
28
31
|
from cryptography import x509
|
|
32
|
+
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
|
29
33
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
30
34
|
|
|
31
35
|
OS_TRUST_INJECTED: bool = False
|
|
32
36
|
OS_TRUST_REASON: str = "not-attempted"
|
|
33
37
|
_STANDARD_SSL_CONTEXT = ssl.SSLContext
|
|
38
|
+
_SSL_CONTEXT_LOCK = RLock()
|
|
34
39
|
|
|
35
40
|
|
|
36
41
|
@dataclass(frozen=True)
|
|
@@ -46,6 +51,7 @@ class ServerCertificate:
|
|
|
46
51
|
not_before: str
|
|
47
52
|
not_after: str
|
|
48
53
|
self_signed: bool
|
|
54
|
+
trust_type: str = "leaf"
|
|
49
55
|
|
|
50
56
|
def to_dict(self) -> Dict[str, Any]:
|
|
51
57
|
"""Return certificate metadata without including the PEM bytes."""
|
|
@@ -58,6 +64,7 @@ class ServerCertificate:
|
|
|
58
64
|
"not-before": self.not_before,
|
|
59
65
|
"not-after": self.not_after,
|
|
60
66
|
"self-signed": self.self_signed,
|
|
67
|
+
"trust-type": self.trust_type,
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
|
|
@@ -65,10 +72,12 @@ __all__ = [
|
|
|
65
72
|
"OS_TRUST_INJECTED",
|
|
66
73
|
"OS_TRUST_REASON",
|
|
67
74
|
"ServerCertificate",
|
|
75
|
+
"create_explicit_tls_context",
|
|
68
76
|
"get_managed_trust_path",
|
|
69
77
|
"get_managed_trust_records",
|
|
70
78
|
"get_ssl_server_origin",
|
|
71
79
|
"inspect_server_certificate",
|
|
80
|
+
"load_ca_certificate",
|
|
72
81
|
"inject_os_trust",
|
|
73
82
|
"remove_managed_trust",
|
|
74
83
|
"save_managed_certificate",
|
|
@@ -83,6 +92,11 @@ def get_ssl_server_origin(api_url: str) -> str:
|
|
|
83
92
|
raise ValueError("Managed certificate trust requires an HTTPS server URL.")
|
|
84
93
|
|
|
85
94
|
hostname = parsed.hostname.lower()
|
|
95
|
+
try:
|
|
96
|
+
if not hostname.isascii():
|
|
97
|
+
hostname = idna.encode(hostname, uts46=True).decode("ascii")
|
|
98
|
+
except idna.IDNAError as error:
|
|
99
|
+
raise ValueError("Managed certificate trust requires a valid server hostname.") from error
|
|
86
100
|
if ":" in hostname and not hostname.startswith("["):
|
|
87
101
|
hostname = f"[{hostname}]"
|
|
88
102
|
port = parsed.port or 443
|
|
@@ -96,11 +110,41 @@ def use_standard_ssl_context(ssl_verify: Union[bool, str]) -> Iterator[None]:
|
|
|
96
110
|
The OS trust integration replaces SSL context implementations globally. For
|
|
97
111
|
explicit CA bundle paths, requests must use the standard implementation so
|
|
98
112
|
the supplied bundle is evaluated instead of the platform trust verifier.
|
|
113
|
+
For validated managed bundles, partial-chain verification permits the
|
|
114
|
+
fingerprint-matched leaf or imported CA to act as the trust anchor without
|
|
115
|
+
bypassing hostname or validity checks. Other bundles retain native defaults.
|
|
116
|
+
Managed bundles reject cross-origin redirects. Explicit contexts are serialized
|
|
117
|
+
so their process-global patches are restored in the correct order.
|
|
99
118
|
"""
|
|
100
119
|
if not isinstance(ssl_verify, str):
|
|
101
120
|
yield
|
|
102
121
|
return
|
|
103
122
|
|
|
123
|
+
with _SSL_CONTEXT_LOCK:
|
|
124
|
+
with _use_standard_ssl_context(ssl_verify):
|
|
125
|
+
yield
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def create_explicit_tls_context(cafile: str) -> ssl.SSLContext:
|
|
129
|
+
"""Build a verified TLS client context using an explicit certificate bundle.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
cafile: Path to a managed certificate or an ordinary CA bundle.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A standard TLS context with partial-chain trust enabled for validated
|
|
136
|
+
managed certificates and native verification defaults for other bundles.
|
|
137
|
+
"""
|
|
138
|
+
with use_standard_ssl_context(cafile):
|
|
139
|
+
context = ssl.create_default_context(cafile=cafile)
|
|
140
|
+
if _get_managed_origin(cafile) is not None:
|
|
141
|
+
context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
|
|
142
|
+
return context
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@contextmanager
|
|
146
|
+
def _use_standard_ssl_context(ssl_verify: str) -> Iterator[None]:
|
|
147
|
+
"""Apply process-global explicit trust patches while holding the context lock."""
|
|
104
148
|
import requests.adapters
|
|
105
149
|
import urllib3.util.ssl_ as urllib3_ssl
|
|
106
150
|
|
|
@@ -108,33 +152,62 @@ def use_standard_ssl_context(ssl_verify: Union[bool, str]) -> Iterator[None]:
|
|
|
108
152
|
patched_urllib3_context = urllib3_ssl.SSLContext
|
|
109
153
|
has_preloaded_context = hasattr(requests.adapters, "_preloaded_ssl_context")
|
|
110
154
|
patched_preloaded_context = getattr(requests.adapters, "_preloaded_ssl_context", None)
|
|
155
|
+
original_send = requests.sessions.Session.send
|
|
156
|
+
managed_origin = _get_managed_origin(ssl_verify)
|
|
157
|
+
|
|
158
|
+
def send(
|
|
159
|
+
session: requests.Session, request: requests.PreparedRequest, **kwargs: Any
|
|
160
|
+
) -> requests.Response:
|
|
161
|
+
if managed_origin and kwargs.get("verify") == ssl_verify:
|
|
162
|
+
try:
|
|
163
|
+
request_origin = get_ssl_server_origin(request.url or "")
|
|
164
|
+
except ValueError:
|
|
165
|
+
request_origin = None
|
|
166
|
+
if request_origin != managed_origin:
|
|
167
|
+
raise requests.exceptions.SSLError(
|
|
168
|
+
f"Managed certificate trust is restricted to {managed_origin}; "
|
|
169
|
+
"cross-origin redirects are not allowed."
|
|
170
|
+
)
|
|
171
|
+
return original_send(session, request, **kwargs)
|
|
172
|
+
|
|
173
|
+
def create_context(protocol: int) -> ssl.SSLContext:
|
|
174
|
+
context = _STANDARD_SSL_CONTEXT(protocol)
|
|
175
|
+
if managed_origin is not None:
|
|
176
|
+
context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
|
|
177
|
+
return context
|
|
178
|
+
|
|
111
179
|
try:
|
|
112
180
|
setattr(ssl, "SSLContext", _STANDARD_SSL_CONTEXT)
|
|
113
|
-
setattr(urllib3_ssl, "SSLContext",
|
|
181
|
+
setattr(urllib3_ssl, "SSLContext", create_context)
|
|
182
|
+
if managed_origin:
|
|
183
|
+
setattr(requests.sessions.Session, "send", send)
|
|
114
184
|
if has_preloaded_context:
|
|
115
185
|
setattr(
|
|
116
186
|
requests.adapters,
|
|
117
187
|
"_preloaded_ssl_context",
|
|
118
|
-
|
|
188
|
+
create_context(ssl.PROTOCOL_TLS_CLIENT),
|
|
119
189
|
)
|
|
120
190
|
yield
|
|
121
191
|
finally:
|
|
122
192
|
setattr(ssl, "SSLContext", patched_ssl_context)
|
|
123
193
|
setattr(urllib3_ssl, "SSLContext", patched_urllib3_context)
|
|
194
|
+
if managed_origin:
|
|
195
|
+
setattr(requests.sessions.Session, "send", original_send)
|
|
124
196
|
if has_preloaded_context:
|
|
125
197
|
setattr(requests.adapters, "_preloaded_ssl_context", patched_preloaded_context)
|
|
126
198
|
|
|
127
199
|
|
|
128
|
-
def _get_trust_directory() -> Path:
|
|
129
|
-
"""Return the managed certificate directory, creating it
|
|
200
|
+
def _get_trust_directory(create: bool = False) -> Path:
|
|
201
|
+
"""Return the managed certificate directory, optionally creating it for writes."""
|
|
130
202
|
from .profiles import ProfileConfig
|
|
131
203
|
|
|
132
204
|
trust_directory = ProfileConfig.get_config_path().parent / "trust"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
205
|
+
if create:
|
|
206
|
+
trust_directory.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
try:
|
|
208
|
+
trust_directory.chmod(0o700)
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
138
211
|
return trust_directory
|
|
139
212
|
|
|
140
213
|
|
|
@@ -143,22 +216,143 @@ def _get_trust_stem(origin: str) -> str:
|
|
|
143
216
|
return hashlib.sha256(origin.encode("utf-8")).hexdigest()
|
|
144
217
|
|
|
145
218
|
|
|
219
|
+
def _get_managed_trust_pairs(origin: str) -> List[tuple[Path, Path]]:
|
|
220
|
+
"""Find metadata and PEM pairs whose stored origin normalizes to the given origin."""
|
|
221
|
+
pairs: List[tuple[Path, Path]] = []
|
|
222
|
+
for metadata_path in sorted(_get_trust_directory().glob("*.json")):
|
|
223
|
+
try:
|
|
224
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
225
|
+
stored_origin = metadata.get("origin") if isinstance(metadata, dict) else None
|
|
226
|
+
if (
|
|
227
|
+
not isinstance(stored_origin, str)
|
|
228
|
+
or metadata_path.stem != _get_trust_stem(stored_origin)
|
|
229
|
+
or get_ssl_server_origin(stored_origin) != origin
|
|
230
|
+
):
|
|
231
|
+
continue
|
|
232
|
+
except (OSError, ValueError):
|
|
233
|
+
continue
|
|
234
|
+
pairs.append((metadata_path.with_suffix(".pem"), metadata_path))
|
|
235
|
+
return pairs
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _remove_managed_trust_pairs(origin: str, keep_metadata_path: Optional[Path] = None) -> bool:
|
|
239
|
+
"""Remove all valid trust pairs for an origin except an optional replacement pair."""
|
|
240
|
+
removed = False
|
|
241
|
+
for pem_path, metadata_path in _get_managed_trust_pairs(origin):
|
|
242
|
+
if metadata_path == keep_metadata_path:
|
|
243
|
+
continue
|
|
244
|
+
for path in (pem_path, metadata_path):
|
|
245
|
+
try:
|
|
246
|
+
path.unlink()
|
|
247
|
+
removed = True
|
|
248
|
+
except FileNotFoundError:
|
|
249
|
+
pass
|
|
250
|
+
return removed
|
|
251
|
+
|
|
252
|
+
|
|
146
253
|
def get_managed_trust_path(api_url: str) -> Optional[Path]:
|
|
147
254
|
"""Return the managed PEM path for a URL when a trusted certificate exists."""
|
|
148
255
|
origin = get_ssl_server_origin(api_url)
|
|
149
|
-
|
|
256
|
+
trust_directory = _get_trust_directory()
|
|
257
|
+
pem_path = trust_directory / f"{_get_trust_stem(origin)}.pem"
|
|
150
258
|
metadata_path = pem_path.with_suffix(".json")
|
|
259
|
+
|
|
260
|
+
if not pem_path.is_file() or not metadata_path.is_file():
|
|
261
|
+
for legacy_pem_path, legacy_metadata_path in _get_managed_trust_pairs(origin):
|
|
262
|
+
if legacy_pem_path.is_file():
|
|
263
|
+
pem_path = legacy_pem_path
|
|
264
|
+
metadata_path = legacy_metadata_path
|
|
265
|
+
break
|
|
266
|
+
|
|
151
267
|
if not pem_path.is_file() or not metadata_path.is_file():
|
|
152
268
|
return None
|
|
153
269
|
try:
|
|
154
270
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
271
|
+
stored_origin = metadata.get("origin") if isinstance(metadata, dict) else None
|
|
272
|
+
if not isinstance(stored_origin, str) or get_ssl_server_origin(stored_origin) != origin:
|
|
273
|
+
return None
|
|
274
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
158
275
|
return None
|
|
276
|
+
with _SSL_CONTEXT_LOCK:
|
|
277
|
+
if not _restrict_managed_trust_bundle(pem_path, metadata):
|
|
278
|
+
return None
|
|
159
279
|
return pem_path
|
|
160
280
|
|
|
161
281
|
|
|
282
|
+
def _restrict_managed_trust_bundle(pem_path: Path, metadata: Dict[str, Any]) -> bool:
|
|
283
|
+
"""Keep only the metadata-fingerprinted certificate in a managed PEM file."""
|
|
284
|
+
trust_type = metadata.get("trust-type", "leaf")
|
|
285
|
+
fingerprint = metadata.get("fingerprint")
|
|
286
|
+
if trust_type not in ("leaf", "ca") or not isinstance(fingerprint, str):
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
original_pem = pem_path.read_bytes()
|
|
291
|
+
certificates = x509.load_pem_x509_certificates(original_pem)
|
|
292
|
+
except (OSError, ValueError):
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
expected_fingerprint = fingerprint.replace(":", "").upper()
|
|
296
|
+
trusted_certificate = next(
|
|
297
|
+
(
|
|
298
|
+
certificate
|
|
299
|
+
for certificate in certificates
|
|
300
|
+
if certificate.fingerprint(hashes.SHA256()).hex().upper() == expected_fingerprint
|
|
301
|
+
),
|
|
302
|
+
None,
|
|
303
|
+
)
|
|
304
|
+
if trusted_certificate is None:
|
|
305
|
+
return False
|
|
306
|
+
if trust_type == "ca":
|
|
307
|
+
try:
|
|
308
|
+
constraints = trusted_certificate.extensions.get_extension_for_class(
|
|
309
|
+
x509.BasicConstraints
|
|
310
|
+
)
|
|
311
|
+
except x509.ExtensionNotFound:
|
|
312
|
+
return False
|
|
313
|
+
if not constraints.value.ca:
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
trusted_pem = trusted_certificate.public_bytes(serialization.Encoding.PEM)
|
|
317
|
+
if original_pem != trusted_pem:
|
|
318
|
+
temporary_pem = pem_path.with_suffix(".pem.tmp")
|
|
319
|
+
try:
|
|
320
|
+
temporary_pem.write_bytes(trusted_pem)
|
|
321
|
+
temporary_pem.chmod(0o600)
|
|
322
|
+
temporary_pem.replace(pem_path)
|
|
323
|
+
except OSError:
|
|
324
|
+
try:
|
|
325
|
+
temporary_pem.unlink()
|
|
326
|
+
except OSError:
|
|
327
|
+
pass
|
|
328
|
+
return False
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _get_managed_origin(pem_path: str) -> Optional[str]:
|
|
333
|
+
"""Identify an explicit PEM that belongs to the origin-scoped managed store."""
|
|
334
|
+
try:
|
|
335
|
+
path = Path(pem_path)
|
|
336
|
+
metadata = json.loads(path.with_suffix(".json").read_text(encoding="utf-8"))
|
|
337
|
+
origin = metadata.get("origin") if isinstance(metadata, dict) else None
|
|
338
|
+
if isinstance(origin, str) and get_managed_trust_path(origin) == path:
|
|
339
|
+
return get_ssl_server_origin(origin)
|
|
340
|
+
except (OSError, ValueError):
|
|
341
|
+
pass
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _is_self_signed(certificate: x509.Certificate) -> bool:
|
|
346
|
+
"""Distinguish self-signed certificates from self-issued rollover certificates."""
|
|
347
|
+
if certificate.subject != certificate.issuer:
|
|
348
|
+
return False
|
|
349
|
+
try:
|
|
350
|
+
certificate.verify_directly_issued_by(certificate)
|
|
351
|
+
except (InvalidSignature, UnsupportedAlgorithm, ValueError):
|
|
352
|
+
return False
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
162
356
|
def _certificate_name(name: x509.Name) -> str:
|
|
163
357
|
"""Format a certificate distinguished name for user-facing output."""
|
|
164
358
|
return ", ".join(
|
|
@@ -190,32 +384,33 @@ def inspect_server_certificate(api_url: str, timeout: float = 5) -> ServerCertif
|
|
|
190
384
|
assert parsed.hostname is not None
|
|
191
385
|
port = parsed.port or 443
|
|
192
386
|
certificate_chain_pem: Optional[bytes] = None
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
with
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
387
|
+
with _SSL_CONTEXT_LOCK:
|
|
388
|
+
patched_ssl_context = ssl.SSLContext
|
|
389
|
+
try:
|
|
390
|
+
setattr(ssl, "SSLContext", _STANDARD_SSL_CONTEXT)
|
|
391
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
392
|
+
context.check_hostname = False
|
|
393
|
+
context.verify_mode = ssl.CERT_NONE
|
|
394
|
+
|
|
395
|
+
with socket.create_connection((parsed.hostname, port), timeout=timeout) as tcp_socket:
|
|
396
|
+
with context.wrap_socket(tcp_socket, server_hostname=parsed.hostname) as tls_socket:
|
|
397
|
+
certificate_der = tls_socket.getpeercert(binary_form=True)
|
|
398
|
+
ssl_object = getattr(tls_socket, "_sslobj", None)
|
|
399
|
+
get_unverified_chain = getattr(ssl_object, "get_unverified_chain", None)
|
|
400
|
+
if callable(get_unverified_chain):
|
|
401
|
+
try:
|
|
402
|
+
chain = get_unverified_chain()
|
|
403
|
+
chain_pem = b"".join(
|
|
404
|
+
(pem.encode("ascii") if isinstance(pem, str) else pem)
|
|
405
|
+
for chain_certificate in chain
|
|
406
|
+
for pem in (chain_certificate.public_bytes(),)
|
|
407
|
+
)
|
|
408
|
+
if chain_pem:
|
|
409
|
+
certificate_chain_pem = chain_pem
|
|
410
|
+
except (AttributeError, OSError, ValueError):
|
|
411
|
+
pass
|
|
412
|
+
finally:
|
|
413
|
+
setattr(ssl, "SSLContext", patched_ssl_context)
|
|
219
414
|
|
|
220
415
|
if not certificate_der:
|
|
221
416
|
raise ssl.SSLError("The server did not provide a TLS certificate.")
|
|
@@ -238,26 +433,81 @@ def inspect_server_certificate(api_url: str, timeout: float = 5) -> ServerCertif
|
|
|
238
433
|
sans=sans,
|
|
239
434
|
not_before=not_before,
|
|
240
435
|
not_after=not_after,
|
|
241
|
-
self_signed=certificate
|
|
436
|
+
self_signed=_is_self_signed(certificate),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def load_ca_certificate(api_url: str, certificate_path: Path) -> ServerCertificate:
|
|
441
|
+
"""Load one PEM or DER CA certificate for origin-scoped trust.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
api_url: HTTPS origin to associate with the CA.
|
|
445
|
+
certificate_path: Public CA certificate file, not a private key or bundle.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Validated CA certificate and identity metadata.
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
ValueError: The file is not a currently valid CA certificate.
|
|
452
|
+
OSError: The certificate file cannot be read.
|
|
453
|
+
"""
|
|
454
|
+
origin = get_ssl_server_origin(api_url)
|
|
455
|
+
content = certificate_path.read_bytes()
|
|
456
|
+
if b"-----BEGIN" in content:
|
|
457
|
+
certificates = x509.load_pem_x509_certificates(content)
|
|
458
|
+
if len(certificates) != 1 or b"PRIVATE KEY" in content:
|
|
459
|
+
raise ValueError("Import exactly one public CA certificate, not a bundle or key.")
|
|
460
|
+
certificate = certificates[0]
|
|
461
|
+
else:
|
|
462
|
+
certificate = x509.load_der_x509_certificate(content)
|
|
463
|
+
try:
|
|
464
|
+
extensions = certificate.extensions
|
|
465
|
+
except (x509.DuplicateExtension, x509.UnsupportedGeneralNameType, ValueError) as exc:
|
|
466
|
+
raise ValueError(f"Invalid CA certificate extensions: {exc}") from exc
|
|
467
|
+
try:
|
|
468
|
+
constraints = extensions.get_extension_for_class(x509.BasicConstraints)
|
|
469
|
+
except x509.ExtensionNotFound as exc:
|
|
470
|
+
raise ValueError("The certificate must have CA basic constraints.") from exc
|
|
471
|
+
if not constraints.value.ca:
|
|
472
|
+
raise ValueError("The certificate is not a CA certificate.")
|
|
473
|
+
try:
|
|
474
|
+
usage = extensions.get_extension_for_class(x509.KeyUsage)
|
|
475
|
+
except x509.ExtensionNotFound:
|
|
476
|
+
pass
|
|
477
|
+
else:
|
|
478
|
+
if not usage.value.key_cert_sign:
|
|
479
|
+
raise ValueError("The CA certificate does not permit certificate signing.")
|
|
480
|
+
not_before, not_after = _certificate_validity(certificate)
|
|
481
|
+
now = datetime.now(timezone.utc)
|
|
482
|
+
if not (
|
|
483
|
+
datetime.fromisoformat(not_before).replace(tzinfo=timezone.utc)
|
|
484
|
+
<= now
|
|
485
|
+
<= datetime.fromisoformat(not_after).replace(tzinfo=timezone.utc)
|
|
486
|
+
):
|
|
487
|
+
raise ValueError("The CA certificate is expired or not yet valid.")
|
|
488
|
+
return ServerCertificate(
|
|
489
|
+
origin=origin,
|
|
490
|
+
pem=certificate.public_bytes(serialization.Encoding.PEM),
|
|
491
|
+
fingerprint=certificate.fingerprint(hashes.SHA256()).hex().upper(),
|
|
492
|
+
subject=_certificate_name(certificate.subject),
|
|
493
|
+
issuer=_certificate_name(certificate.issuer),
|
|
494
|
+
sans=[],
|
|
495
|
+
not_before=not_before,
|
|
496
|
+
not_after=not_after,
|
|
497
|
+
self_signed=_is_self_signed(certificate),
|
|
498
|
+
trust_type="ca",
|
|
242
499
|
)
|
|
243
500
|
|
|
244
501
|
|
|
245
502
|
def save_managed_certificate(certificate: ServerCertificate) -> Path:
|
|
246
503
|
"""Persist a server certificate and metadata in the managed trust directory."""
|
|
247
|
-
trust_directory = _get_trust_directory()
|
|
248
|
-
|
|
504
|
+
trust_directory = _get_trust_directory(create=True)
|
|
505
|
+
origin = get_ssl_server_origin(certificate.origin)
|
|
506
|
+
stem = _get_trust_stem(origin)
|
|
249
507
|
pem_path = trust_directory / f"{stem}.pem"
|
|
250
508
|
metadata_path = pem_path.with_suffix(".json")
|
|
251
|
-
metadata
|
|
252
|
-
|
|
253
|
-
"fingerprint": certificate.fingerprint,
|
|
254
|
-
"subject": certificate.subject,
|
|
255
|
-
"issuer": certificate.issuer,
|
|
256
|
-
"sans": certificate.sans,
|
|
257
|
-
"not-before": certificate.not_before,
|
|
258
|
-
"not-after": certificate.not_after,
|
|
259
|
-
"self-signed": certificate.self_signed,
|
|
260
|
-
}
|
|
509
|
+
metadata = certificate.to_dict()
|
|
510
|
+
metadata["origin"] = origin
|
|
261
511
|
|
|
262
512
|
temporary_pem = pem_path.with_suffix(".pem.tmp")
|
|
263
513
|
temporary_metadata = metadata_path.with_suffix(".json.tmp")
|
|
@@ -275,29 +525,36 @@ def save_managed_certificate(certificate: ServerCertificate) -> Path:
|
|
|
275
525
|
except OSError:
|
|
276
526
|
pass
|
|
277
527
|
raise
|
|
528
|
+
_remove_managed_trust_pairs(origin, keep_metadata_path=metadata_path)
|
|
278
529
|
return pem_path
|
|
279
530
|
|
|
280
531
|
|
|
281
532
|
def get_managed_trust_records() -> List[Dict[str, Any]]:
|
|
282
|
-
"""Return metadata for all managed server certificates."""
|
|
533
|
+
"""Return metadata with canonical origins for all managed server certificates."""
|
|
283
534
|
records: List[Dict[str, Any]] = []
|
|
284
535
|
for metadata_path in sorted(_get_trust_directory().glob("*.json")):
|
|
285
536
|
try:
|
|
286
537
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
287
|
-
|
|
538
|
+
origin = metadata.get("origin") if isinstance(metadata, dict) else None
|
|
539
|
+
if not isinstance(origin, str):
|
|
540
|
+
continue
|
|
541
|
+
metadata["origin"] = get_ssl_server_origin(origin)
|
|
542
|
+
except (OSError, ValueError):
|
|
288
543
|
continue
|
|
289
|
-
|
|
290
|
-
|
|
544
|
+
metadata.setdefault("trust-type", "leaf")
|
|
545
|
+
records.append(metadata)
|
|
291
546
|
return records
|
|
292
547
|
|
|
293
548
|
|
|
294
549
|
def remove_managed_trust(api_url: str) -> bool:
|
|
295
550
|
"""Remove the managed certificate trust entry for a server URL."""
|
|
296
551
|
origin = get_ssl_server_origin(api_url)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
552
|
+
trust_directory = _get_trust_directory()
|
|
553
|
+
removed = _remove_managed_trust_pairs(origin)
|
|
554
|
+
for path in (
|
|
555
|
+
trust_directory / f"{_get_trust_stem(origin)}.pem",
|
|
556
|
+
trust_directory / f"{_get_trust_stem(origin)}.json",
|
|
557
|
+
):
|
|
301
558
|
try:
|
|
302
559
|
path.unlink()
|
|
303
560
|
removed = True
|
slcli/webapp_click.py
CHANGED
|
@@ -22,6 +22,7 @@ import requests
|
|
|
22
22
|
from .cli_utils import is_interactive_environment, validate_output_format
|
|
23
23
|
from .platform import PLATFORM_SLS, get_platform
|
|
24
24
|
from .skill_click import install_skills_to_directory
|
|
25
|
+
from .ssl_trust import use_standard_ssl_context
|
|
25
26
|
from .universal_handlers import UniversalResponseHandler
|
|
26
27
|
from .utils import (
|
|
27
28
|
ExitCodes,
|
|
@@ -109,11 +110,10 @@ def _build_published_webapp_url(
|
|
|
109
110
|
|
|
110
111
|
if webapp_id and (not resolved_name or not resolved_workspace_id):
|
|
111
112
|
try:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
113
|
+
url = f"{_get_webapp_base_url()}/webapps/{webapp_id}"
|
|
114
|
+
ssl_verify = get_ssl_verify(url)
|
|
115
|
+
with use_standard_ssl_context(ssl_verify):
|
|
116
|
+
resp = requests.get(url, headers=get_headers("application/json"), verify=ssl_verify)
|
|
117
117
|
resp.raise_for_status()
|
|
118
118
|
data = resp.json()
|
|
119
119
|
resolved_name = resolved_name or str(data.get("name", ""))
|
|
@@ -295,12 +295,10 @@ def _query_webapps_http(filter_str: str, max_items: int = 1000) -> List[Dict[str
|
|
|
295
295
|
payload["continuationToken"] = continuation_token
|
|
296
296
|
|
|
297
297
|
# Request the server to include a total count when available
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
json=payload,
|
|
302
|
-
verify=get_ssl_verify(),
|
|
303
|
-
)
|
|
298
|
+
url = f"{base}/webapps/query?includeTotalCount=true"
|
|
299
|
+
ssl_verify = get_ssl_verify(url)
|
|
300
|
+
with use_standard_ssl_context(ssl_verify):
|
|
301
|
+
resp = requests.post(url, headers=headers, json=payload, verify=ssl_verify)
|
|
304
302
|
resp.raise_for_status()
|
|
305
303
|
data = resp.json()
|
|
306
304
|
page_items: List[Dict[str, Any]] = data.get("webapps", []) if isinstance(data, dict) else []
|
|
@@ -336,12 +334,10 @@ def _fetch_webapps_page(
|
|
|
336
334
|
payload["continuationToken"] = continuation_token
|
|
337
335
|
|
|
338
336
|
# Request the server to include a total count when available
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
json=payload,
|
|
343
|
-
verify=get_ssl_verify(),
|
|
344
|
-
)
|
|
337
|
+
url = f"{base}/webapps/query?includeTotalCount=true"
|
|
338
|
+
ssl_verify = get_ssl_verify(url)
|
|
339
|
+
with use_standard_ssl_context(ssl_verify):
|
|
340
|
+
resp = requests.post(url, headers=headers, json=payload, verify=ssl_verify)
|
|
345
341
|
resp.raise_for_status()
|
|
346
342
|
data = resp.json()
|
|
347
343
|
items = data.get("webapps", []) if isinstance(data, dict) else []
|
|
@@ -1491,11 +1487,10 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1491
1487
|
"""Show webapp metadata."""
|
|
1492
1488
|
try:
|
|
1493
1489
|
base = _get_webapp_base_url()
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
)
|
|
1490
|
+
url = f"{base}/webapps/{webapp_id}"
|
|
1491
|
+
ssl_verify = get_ssl_verify(url)
|
|
1492
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1493
|
+
resp = requests.get(url, headers=get_headers("application/json"), verify=ssl_verify)
|
|
1499
1494
|
resp.raise_for_status()
|
|
1500
1495
|
data = resp.json()
|
|
1501
1496
|
if data.get("type", "") != "WebVI":
|
|
@@ -1516,9 +1511,10 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1516
1511
|
|
|
1517
1512
|
try:
|
|
1518
1513
|
base = _get_webapp_base_url()
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
)
|
|
1514
|
+
url = f"{base}/webapps/{webapp_id}"
|
|
1515
|
+
ssl_verify = get_ssl_verify(url)
|
|
1516
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1517
|
+
resp = requests.delete(url, headers=get_headers(), verify=ssl_verify)
|
|
1522
1518
|
# Validate response and type if possible
|
|
1523
1519
|
try:
|
|
1524
1520
|
data = resp.json()
|
|
@@ -1542,11 +1538,10 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1542
1538
|
|
|
1543
1539
|
try:
|
|
1544
1540
|
base = _get_webapp_base_url()
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
)
|
|
1541
|
+
url = f"{base}/webapps/{webapp_id}"
|
|
1542
|
+
ssl_verify = get_ssl_verify(url)
|
|
1543
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1544
|
+
resp = requests.get(url, headers=get_headers("application/json"), verify=ssl_verify)
|
|
1550
1545
|
resp.raise_for_status()
|
|
1551
1546
|
data = resp.json()
|
|
1552
1547
|
app_url = _build_published_webapp_url(
|
|
@@ -1630,12 +1625,15 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1630
1625
|
"policyIds": [],
|
|
1631
1626
|
"properties": {},
|
|
1632
1627
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1628
|
+
url = f"{base}/webapps"
|
|
1629
|
+
ssl_verify = get_ssl_verify(url)
|
|
1630
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1631
|
+
resp_create = requests.post(
|
|
1632
|
+
url,
|
|
1633
|
+
headers=get_headers("application/json"),
|
|
1634
|
+
json=payload,
|
|
1635
|
+
verify=ssl_verify,
|
|
1636
|
+
)
|
|
1639
1637
|
_handle_webapp_create_conflict(
|
|
1640
1638
|
resp_create,
|
|
1641
1639
|
workspace_id=created_workspace_id,
|
|
@@ -1656,9 +1654,11 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1656
1654
|
|
|
1657
1655
|
upload_headers = get_headers("application/octet-stream")
|
|
1658
1656
|
url = f"{base}/webapps/{webapp_id}/content"
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1657
|
+
ssl_verify = get_ssl_verify(url)
|
|
1658
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1659
|
+
resp = requests.put(
|
|
1660
|
+
url, headers=upload_headers, data=data, verify=ssl_verify
|
|
1661
|
+
)
|
|
1662
1662
|
if resp.status_code in (200, 201, 204):
|
|
1663
1663
|
workspace_name_hint = (
|
|
1664
1664
|
get_effective_workspace(workspace) or workspace
|
|
@@ -1708,12 +1708,15 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1708
1708
|
"policyIds": [],
|
|
1709
1709
|
"properties": {},
|
|
1710
1710
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1711
|
+
url = f"{base}/webapps"
|
|
1712
|
+
ssl_verify = get_ssl_verify(url)
|
|
1713
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1714
|
+
resp_create = requests.post(
|
|
1715
|
+
url,
|
|
1716
|
+
headers=get_headers("application/json"),
|
|
1717
|
+
json=payload,
|
|
1718
|
+
verify=ssl_verify,
|
|
1719
|
+
)
|
|
1717
1720
|
_handle_webapp_create_conflict(
|
|
1718
1721
|
resp_create,
|
|
1719
1722
|
workspace_id=created_workspace_id,
|
|
@@ -1734,7 +1737,9 @@ def register_webapp_commands(cli: Any) -> None:
|
|
|
1734
1737
|
|
|
1735
1738
|
upload_headers = get_headers("application/octet-stream")
|
|
1736
1739
|
url = f"{base}/webapps/{webapp_id}/content"
|
|
1737
|
-
|
|
1740
|
+
ssl_verify = get_ssl_verify(url)
|
|
1741
|
+
with use_standard_ssl_context(ssl_verify):
|
|
1742
|
+
resp = requests.put(url, headers=upload_headers, data=data, verify=ssl_verify)
|
|
1738
1743
|
if resp.status_code in (200, 201, 204):
|
|
1739
1744
|
workspace_name_hint = (
|
|
1740
1745
|
(get_effective_workspace(workspace) or workspace)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: systemlink-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Author: Fred Visser
|
|
@@ -18,6 +18,7 @@ Requires-Dist: cryptography (>=49.0.0)
|
|
|
18
18
|
Requires-Dist: filelock (>=3.20,<4)
|
|
19
19
|
Requires-Dist: httpcore2 (>=2.10.0,<3) ; extra == "mcp"
|
|
20
20
|
Requires-Dist: httpx2 (>=2.12.0,<3) ; extra == "mcp"
|
|
21
|
+
Requires-Dist: idna (>=3.10,<4)
|
|
21
22
|
Requires-Dist: keyring (>=25.6.0,<26.0.0)
|
|
22
23
|
Requires-Dist: mcp (>=2,<3) ; extra == "mcp"
|
|
23
24
|
Requires-Dist: msgpack (>=1.1,<2) ; extra == "managed-client"
|
|
@@ -2,7 +2,7 @@ dff-editor/editor.js,sha256=VQJNqtkFSeXec_hidYdbsvhcKo8-dcdMoU5qvGVn3YQ,97488
|
|
|
2
2
|
dff-editor/index.html,sha256=Bbicid_Fx66W7ypHgSGJrUtkAEFcLP3HkRoxiGKZjCs,11657
|
|
3
3
|
slcli/__init__.py,sha256=ZRF050VsFgb4zeyyQzQINQ4eHFwEBBoUq-CPyziIB6g,66
|
|
4
4
|
slcli/__main__.py,sha256=wUlBlvYS3Tn4ipw9_7zMr2d1houRpNnALG0TxHg6xZg,829
|
|
5
|
-
slcli/_version.py,sha256=
|
|
5
|
+
slcli/_version.py,sha256=LPSvaTsKdbuxqhCtWPW3xyvCHxMTxr8qhwwJO9a3YAA,113
|
|
6
6
|
slcli/alarm_click.py,sha256=0z0SOO_LJQR9dYqLdOTfZgA4Ogl8d1fsJ7TrJHukDsU,32053
|
|
7
7
|
slcli/asset_click.py,sha256=rBA52bBjn5Kmv0kHv4ft1vBMXFvMGmR292AbX3s_zvY,44544
|
|
8
8
|
slcli/cli_formatters.py,sha256=ShrU2RbnQUEKhJ6ahWuv8c3zw7C-yg0o20i1opq9gZk,7275
|
|
@@ -10,7 +10,7 @@ slcli/cli_utils.py,sha256=tXagGGAeEVvxlKpyHSzbNiosjhrxMmwgDUMJ4glAK1I,13087
|
|
|
10
10
|
slcli/comment_click.py,sha256=klbOQfFYVQ-HDi1hoHWuZE3X8S_5iI2HWfPaAYyICOQ,21048
|
|
11
11
|
slcli/completion_click.py,sha256=KYnGP-1LT-iQVg32BRQOmX5LQspVjZ7D--tpH0qh_MA,13952
|
|
12
12
|
slcli/config.py,sha256=tdVP-1cvy6Qs2ThGXv4nPNa5AoqNu1SG6oOwgMJ6Yx0,2221
|
|
13
|
-
slcli/config_click.py,sha256=
|
|
13
|
+
slcli/config_click.py,sha256=06grrTamRCs725v_XLKNTUwevW17GjKkEM9p96Wpr-A,44393
|
|
14
14
|
slcli/credentials.py,sha256=vKTS7hNAt3t7HB4ggWOZKMLNISefbT2hpCXtyEAyMCg,19996
|
|
15
15
|
slcli/dataframe_click.py,sha256=2xUA9I6Rh8Fx0YfPDUMMT844SLWB2mRC3b1ccmBiEcw,44042
|
|
16
16
|
slcli/dff_click.py,sha256=eqpclQ59C7yoVHTfpZ-UF6I85wNes1JvKO9_fx0yN7E,39100
|
|
@@ -44,7 +44,7 @@ slcli/feed_click.py,sha256=IzE_VwQ8IXmEgqe04afd-wE91JcV7pT3mZ5eYIT4MF4,30437
|
|
|
44
44
|
slcli/file_click.py,sha256=5dpUgUc9ZuxG4cWO3pFjnoTyN8wB8RkndE7c5t5_x50,45841
|
|
45
45
|
slcli/function_click.py,sha256=L8FqhkLc2BZVnRK97mRiLBlLImYmCu4FSOKJOVHvt1U,54278
|
|
46
46
|
slcli/function_templates.py,sha256=GfmsBF0KrsIp2rBiO9ha5UAHv0O9g7yr7xcA4l8vbi8,3371
|
|
47
|
-
slcli/main.py,sha256=
|
|
47
|
+
slcli/main.py,sha256=HXCrJD7OwC3f4mg1VkFL70ZJREHB1gnbl3x9MhmMHW4,29235
|
|
48
48
|
slcli/managed_client/__init__.py,sha256=OBOqV5QisENmueusHloKye9CWldM1QvLBOTDSDiETVQ,1564
|
|
49
49
|
slcli/managed_client/crypto.py,sha256=WLtyXIciEPglCVvvAnWXKvbbOWnv2_9q79505p1PQkQ,16767
|
|
50
50
|
slcli/managed_client/handlers.py,sha256=JvD2d8I0Wnm_9XwgJzgnXBM_g_3F_5YkJJ-3lqmGvP0,17770
|
|
@@ -112,7 +112,7 @@ slcli/skills/slcli/scripts/prepare_eval_workspace.py,sha256=pBQz6-wCD9kzZx_GE3Qr
|
|
|
112
112
|
slcli/skills/slcli/scripts/render_eval_review.py,sha256=6qd7VutQT_KdpPCEW5TtidS_HRA1Bp_wXi58NCm7Kco,2171
|
|
113
113
|
slcli/skills/slcli/scripts/spec_import_helper.py,sha256=p6npcjrFTK0OnAwPQRu4O211WLMJ4Fte9NtqU-73cog,11429
|
|
114
114
|
slcli/spec_click.py,sha256=xDN7qZ90b4v73O94bzyeplUjJBdLpvrsj3D6Y9zMJs4,77305
|
|
115
|
-
slcli/ssl_trust.py,sha256=
|
|
115
|
+
slcli/ssl_trust.py,sha256=gSU004tHLkN8k9T4apYIzuX9hdmdXkRn0-Pmivcr6C4,24857
|
|
116
116
|
slcli/state_click.py,sha256=dceJN-L4OMqouj83vOs-BeIzm_HscZzchTa2ZQ5S1_I,38617
|
|
117
117
|
slcli/system_click.py,sha256=HoRTM-Sv5PqIB9YMm4PTUxUv7vMc9JH_byZ9ty-sTow,109572
|
|
118
118
|
slcli/system_query_utils.py,sha256=x4Fs5XXI4_3X4mhDQUsm_DLIsSX7efM1sRTF1Tcq8Lc,5156
|
|
@@ -127,7 +127,7 @@ slcli/utils.py,sha256=EAFaCt_FyZyb8HkMiYKwrS3J5fV9gvOlZEFxcjAaPuU,37356
|
|
|
127
127
|
slcli/version_click.py,sha256=qo2VHOlvsEUiw2-Sw1L50pCo-tLBPCJvfTte02oXajY,6998
|
|
128
128
|
slcli/web_editor.py,sha256=UR7IEEwxKrwvotA4T5DI6zHnmO0A-0cG7SOZHSfVYE4,17290
|
|
129
129
|
slcli/webapp_bootstrap.py,sha256=pBcfsJ7G4cRMNGcK9H4rkfGhqgRqCzhVpW4WDZlsd1w,53331
|
|
130
|
-
slcli/webapp_click.py,sha256=
|
|
130
|
+
slcli/webapp_click.py,sha256=CfalwkU1Z3rg18gu3kj2Azsse0H3vkvTw81Bigl-rNE,70836
|
|
131
131
|
slcli/webapp_templates/angular/blank/.gitignore,sha256=B4bZYaFkEMoZooOeUyaF3er0BKFS4rdFiCzo2Z6syv8,40
|
|
132
132
|
slcli/webapp_templates/angular/blank/README.md,sha256=TD0MfAJVIM8sKpPEcXig_HgksOLc983Q4wKsjV7gANg,3943
|
|
133
133
|
slcli/webapp_templates/angular/blank/angular.json,sha256=2YnTokjGHnHu0VEszCI_DhpFnOABkwVtmDgIZCTnCck,2677
|
|
@@ -184,8 +184,8 @@ slcli/workflows_click.py,sha256=Ymb69WCzxhAQZ5bv0jTcXWlXy1yskXhIuv7FbgRBSWk,4065
|
|
|
184
184
|
slcli/workitem_click.py,sha256=hmWijNzAvzvNBN0Y1wXoZZbUStRyrVIqg7J2xfJBFxw,88444
|
|
185
185
|
slcli/workspace_click.py,sha256=BBiaOmAe_EzkpCG6TlR8aCrwOI81xrMOzMObAfuM_5w,20932
|
|
186
186
|
slcli/workspace_utils.py,sha256=7QOtotmv3ghJs6a0fDQVojKxrBKPeq_GtE4F1SmZKnM,6872
|
|
187
|
-
systemlink_cli-2.
|
|
188
|
-
systemlink_cli-2.
|
|
189
|
-
systemlink_cli-2.
|
|
190
|
-
systemlink_cli-2.
|
|
191
|
-
systemlink_cli-2.
|
|
187
|
+
systemlink_cli-2.3.0.dist-info/METADATA,sha256=BGSZgIQi6_aYfwrjLkhM0PPJuO3w4DTNNyz-JG0IjaI,1352
|
|
188
|
+
systemlink_cli-2.3.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
189
|
+
systemlink_cli-2.3.0.dist-info/entry_points.txt,sha256=V8IyRF-NYesTOTA_yB4-lVK1EsKZsSY5zOjzGbh5zdA,288
|
|
190
|
+
systemlink_cli-2.3.0.dist-info/licenses/LICENSE,sha256=ioLAEBMHywwQkOr14pBIc7nenmWrc-bgcMrqsB_Uvq8,1077
|
|
191
|
+
systemlink_cli-2.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|