eraflow 0.1.0a1__tar.gz
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.
- eraflow-0.1.0a1/LICENSE +21 -0
- eraflow-0.1.0a1/MANIFEST.in +8 -0
- eraflow-0.1.0a1/NOTICE +4 -0
- eraflow-0.1.0a1/PKG-INFO +34 -0
- eraflow-0.1.0a1/README.md +15 -0
- eraflow-0.1.0a1/pyproject.toml +53 -0
- eraflow-0.1.0a1/setup.cfg +4 -0
- eraflow-0.1.0a1/src/eraflow/__init__.py +39 -0
- eraflow-0.1.0a1/src/eraflow/_version.py +4 -0
- eraflow-0.1.0a1/src/eraflow/flask.py +357 -0
- eraflow-0.1.0a1/src/eraflow/materials.py +7110 -0
- eraflow-0.1.0a1/src/eraflow/provider.py +2153 -0
- eraflow-0.1.0a1/src/eraflow/py.typed +0 -0
- eraflow-0.1.0a1/src/eraflow/runtime.py +992 -0
- eraflow-0.1.0a1/src/eraflow.egg-info/PKG-INFO +34 -0
- eraflow-0.1.0a1/src/eraflow.egg-info/SOURCES.txt +17 -0
- eraflow-0.1.0a1/src/eraflow.egg-info/dependency_links.txt +1 -0
- eraflow-0.1.0a1/src/eraflow.egg-info/requires.txt +30 -0
- eraflow-0.1.0a1/src/eraflow.egg-info/top_level.txt +1 -0
eraflow-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 deepaint-ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
eraflow-0.1.0a1/NOTICE
ADDED
eraflow-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: eraflow
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: ERAF 协议的 Python 接入 SDK,统一提供身份与凭据自动安装、轮换恢复、Edge 请求验签、实时鉴权及运行状态诊断,简化服务的 External API 接入。
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/deepaint-ai/eraflow
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: flask
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
License-File: NOTICE
|
|
19
|
+
|
|
20
|
+
# eraflow
|
|
21
|
+
|
|
22
|
+
ERAF 协议的 Python 接入 SDK,统一提供身份与凭据自动安装、轮换恢复、Edge 请求验签、实时鉴权及运行状态诊断,简化服务的 External API 接入。
|
|
23
|
+
|
|
24
|
+
当前发行候选为 0.1.0a1(Alpha 预发布),供隔离验证与下游接入评估;不代表生产接线已完成。最低 Python 3.7,完整文件安装仅面向 Windows/Linux;macOS 未支持。纯 Python wheel 标签不代表所有平台安装语义可用。
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from eraflow import Provider, ProviderOperation, prepare_external_target
|
|
28
|
+
from eraflow.flask import install_provider
|
|
29
|
+
# service_root、静态身份、UAC 配置与安全回调由宿主明确提供。
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
核心不隐式导入 Flask,不在导入时启动任务或联网。管理员保护、真实 socket/原始请求事实、共享防重放、领域授权及写操作最终复核均须由宿主接线。
|
|
33
|
+
|
|
34
|
+
源码来自固定 CommonLib 提交,保留材料协议和 data/.cache 布局。参见源码仓 docs/README.md 的接入、迁移、验证和发布说明。采用 MIT License,见 LICENSE;来源及依赖声明见 NOTICE。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# eraflow
|
|
2
|
+
|
|
3
|
+
ERAF 协议的 Python 接入 SDK,统一提供身份与凭据自动安装、轮换恢复、Edge 请求验签、实时鉴权及运行状态诊断,简化服务的 External API 接入。
|
|
4
|
+
|
|
5
|
+
当前发行候选为 0.1.0a1(Alpha 预发布),供隔离验证与下游接入评估;不代表生产接线已完成。最低 Python 3.7,完整文件安装仅面向 Windows/Linux;macOS 未支持。纯 Python wheel 标签不代表所有平台安装语义可用。
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from eraflow import Provider, ProviderOperation, prepare_external_target
|
|
9
|
+
from eraflow.flask import install_provider
|
|
10
|
+
# service_root、静态身份、UAC 配置与安全回调由宿主明确提供。
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
核心不隐式导入 Flask,不在导入时启动任务或联网。管理员保护、真实 socket/原始请求事实、共享防重放、领域授权及写操作最终复核均须由宿主接线。
|
|
14
|
+
|
|
15
|
+
源码来自固定 CommonLib 提交,保留材料协议和 data/.cache 布局。参见源码仓 docs/README.md 的接入、迁移、验证和发布说明。采用 MIT License,见 LICENSE;来源及依赖声明见 NOTICE。
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=67.8", "wheel>=0.42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eraflow"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "ERAF 协议的 Python 接入 SDK,统一提供身份与凭据自动安装、轮换恢复、Edge 请求验签、实时鉴权及运行状态诊断,简化服务的 External API 接入。"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.7"
|
|
12
|
+
dependencies = ["cryptography>=43.0.3,<47", "gevent>=22.10.2,<27", "requests>=2.20,<3"]
|
|
13
|
+
classifiers = ["License :: OSI Approved :: MIT License", "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/deepaint-ai/eraflow"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
flask = ["Flask>=2.2.5,<4", "Werkzeug>=2.2.3,<4"]
|
|
20
|
+
dev = ["build==0.10.0; python_version < '3.8'", "build>=1,<2; python_version >= '3.8'", "mypy==1.4.1", "pytest==7.4.4; python_version < '3.8'", "pytest>=8,<10; python_version >= '3.8'", "ruff==0.8.6", "types-requests==2.31.0.6", "jsonschema>=4.17,<5", "setuptools>=67.8", "wheel>=0.42", "SQLAlchemy>=1.4,<2", "PyJWT>=2,<3", "arrow<1", "redis==4.5.5", "pywin32>=300; sys_platform == 'win32'"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.dynamic]
|
|
23
|
+
version = {attr = "eraflow._version.__version__"}
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
eraflow = ["py.typed"]
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
license-files = ["LICENSE", "NOTICE"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["test"]
|
|
33
|
+
addopts = "-ra -p no:cacheprovider --basetemp=tmp/pytest"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
target-version = "py37"
|
|
37
|
+
line-length = 100
|
|
38
|
+
cache-dir = "tmp/ruff-cache"
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E4", "E7", "E9", "F", "I"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.7"
|
|
44
|
+
files = ["src/eraflow"]
|
|
45
|
+
check_untyped_defs = true
|
|
46
|
+
disallow_untyped_defs = true
|
|
47
|
+
strict_optional = false
|
|
48
|
+
no_implicit_optional = false
|
|
49
|
+
ignore_missing_imports = true
|
|
50
|
+
cache_dir = "tmp/mypy-cache"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"test/*.py" = ["E402"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# coding=utf-8
|
|
2
|
+
"""ERAF 协议的 Python 接入 SDK;导入不启动任务或访问网络。"""
|
|
3
|
+
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .materials import (
|
|
6
|
+
ConsumerTarget,
|
|
7
|
+
CredentialBytes,
|
|
8
|
+
ERAFIdentityMaterialError,
|
|
9
|
+
NoPackageError,
|
|
10
|
+
TrustedArtifact,
|
|
11
|
+
)
|
|
12
|
+
from .provider import (
|
|
13
|
+
Admission,
|
|
14
|
+
HTTPRequestBytes,
|
|
15
|
+
Provider,
|
|
16
|
+
ProviderAccessError,
|
|
17
|
+
ProviderOperation,
|
|
18
|
+
UnavailableProvider,
|
|
19
|
+
)
|
|
20
|
+
from .runtime import ExternalRuntime, ExternalStartupError, MaterialView, prepare_external_target
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"ConsumerTarget",
|
|
25
|
+
"CredentialBytes",
|
|
26
|
+
"ERAFIdentityMaterialError",
|
|
27
|
+
"NoPackageError",
|
|
28
|
+
"TrustedArtifact",
|
|
29
|
+
"Admission",
|
|
30
|
+
"HTTPRequestBytes",
|
|
31
|
+
"Provider",
|
|
32
|
+
"ProviderAccessError",
|
|
33
|
+
"ProviderOperation",
|
|
34
|
+
"UnavailableProvider",
|
|
35
|
+
"ExternalRuntime",
|
|
36
|
+
"MaterialView",
|
|
37
|
+
"ExternalStartupError",
|
|
38
|
+
"prepare_external_target",
|
|
39
|
+
]
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# coding=utf-8
|
|
3
|
+
# CommonLib
|
|
4
|
+
|
|
5
|
+
"""External 公共 Flask 接线:安装入口、请求保护装饰器与 /external/status 路由。
|
|
6
|
+
|
|
7
|
+
来源实现:v1.1.0;发行版本统一由 eraflow._version 提供。
|
|
8
|
+
Updated: 2026-10-23
|
|
9
|
+
变更记录:
|
|
10
|
+
v1.1.0 | 2026-10-23 | HTTP 接纳使用服务器原始事实和原请求预算,有限缓冲正文并将同一字节交给业务;拒绝编码及流式入口。
|
|
11
|
+
v1.0.4 | 2026-10-20 | 公共文件简化为 provider_api/provider_lib,更新唯一引用;业务行为、协议与鉴权不变。
|
|
12
|
+
v1.0.3 | 2026-10-20 | 公共文件迁入 External 子目录,固定新引用和服务根定位;协议、鉴权与生命周期不变。
|
|
13
|
+
v1.0.2 | 2026-10-20 | 唯一安装入口承接无可信制品的公共失败 Status,保留管理员预算和保护拒绝链
|
|
14
|
+
v1.0.1 | 2026-10-19 | 仅 External 强制禁止缓存,统一脱敏 HTTPException,保留内部历史响应并拒绝异实例重复安装
|
|
15
|
+
v1.0.0 | 2026-10-19 | 首版:install_provider 向 app.extensions['commonlib.external']
|
|
16
|
+
注册生命周期(serv_process 监听前 start/退出 stop);
|
|
17
|
+
ExternalHTTP.protect(operation_id) 静态操作保护装饰器——
|
|
18
|
+
请求入口建统一单调 deadline、消费 commonlib.socket_facts、
|
|
19
|
+
解析唯一 Authorization(Bearer/DPoP)与 Edge 证明头、调用
|
|
20
|
+
Provider.authorize 一次内省并保存请求级 Admission;
|
|
21
|
+
read 操作在有限响应发出前自动复核,write 操作由业务在最后
|
|
22
|
+
可拒绝点显式 Admission.recheck();/external/status 复用既有
|
|
23
|
+
管理员 Bearer guard(libs.token_auth_lib),只读、no-store、
|
|
24
|
+
拒绝 query/body/Transfer-Encoding,HEAD 同鉴权、OPTIONS 不给
|
|
25
|
+
详情、清除 CORS;错误响应稳定脱敏,不回显 Token/异常正文
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from functools import wraps
|
|
35
|
+
from typing import Any, Callable, Optional
|
|
36
|
+
|
|
37
|
+
from flask import Blueprint, Response, g, jsonify, request
|
|
38
|
+
from werkzeug.exceptions import HTTPException
|
|
39
|
+
|
|
40
|
+
from .provider import (
|
|
41
|
+
MAX_HTTP_BODY_BYTES,
|
|
42
|
+
STATUS_TOTAL_TIMEOUT,
|
|
43
|
+
Admission,
|
|
44
|
+
HTTPRequestBytes,
|
|
45
|
+
Provider,
|
|
46
|
+
ProviderAccessError,
|
|
47
|
+
UnavailableProvider,
|
|
48
|
+
_http_request_fields,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_AUTHORIZATION_PATTERN = re.compile(r"(Bearer|DPoP) ([\x21-\x2b\x2d-\x7e]+)")
|
|
52
|
+
_ERROR_STATUS = {"authentication": 401, "authorization": 403, "dependency": 503}
|
|
53
|
+
_ERROR_MESSAGE = {
|
|
54
|
+
"authentication": "外部请求认证未通过",
|
|
55
|
+
"authorization": "外部请求无操作权限",
|
|
56
|
+
"dependency": "外部依赖暂不可用",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _request_id() -> Any:
|
|
61
|
+
return uuid.uuid4().hex
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _error_response(status: Any, code: Any, correlation_id: Any) -> Any:
|
|
65
|
+
response = jsonify({"code": code, "message": "请求未能完成", "request_id": correlation_id})
|
|
66
|
+
response.status_code = status
|
|
67
|
+
response.headers["X-Request-Id"] = correlation_id
|
|
68
|
+
response.headers["Cache-Control"] = "no-store"
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _access_error_response(error: Any) -> Any:
|
|
73
|
+
status = _ERROR_STATUS.get(error.category, 503)
|
|
74
|
+
response = jsonify(
|
|
75
|
+
{
|
|
76
|
+
"code": "external_" + error.category,
|
|
77
|
+
"message": _ERROR_MESSAGE.get(error.category, "请求未能完成"),
|
|
78
|
+
"request_id": _request_id(),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
response.status_code = status
|
|
82
|
+
if status == 401:
|
|
83
|
+
response.headers["WWW-Authenticate"] = "Bearer"
|
|
84
|
+
response.headers["Cache-Control"] = "no-store"
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _strip_external_cors(response: Any) -> Any:
|
|
89
|
+
"""External 响应不保留全局历史 CORS 许可。"""
|
|
90
|
+
if request.path == "/external" or request.path.startswith("/external/"):
|
|
91
|
+
for name in (
|
|
92
|
+
"Access-Control-Allow-Origin",
|
|
93
|
+
"Access-Control-Allow-Headers",
|
|
94
|
+
"Access-Control-Allow-Methods",
|
|
95
|
+
"Access-Control-Allow-Credentials",
|
|
96
|
+
"Access-Control-Expose-Headers",
|
|
97
|
+
"Access-Control-Max-Age",
|
|
98
|
+
):
|
|
99
|
+
response.headers.pop(name, None)
|
|
100
|
+
response.headers["Cache-Control"] = "no-store"
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ExternalHTTP(object):
|
|
105
|
+
"""安装入口返回的请求保护面;只在受保护请求内暴露 Admission。"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, app: Any, provider: Any) -> None:
|
|
108
|
+
self.app = app
|
|
109
|
+
self.provider = provider
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def admission(self) -> Admission:
|
|
113
|
+
"""本请求本操作的不可变接纳上下文;未受保护请求/跨请求读取均失败。"""
|
|
114
|
+
admission = getattr(g, "external_admission", None)
|
|
115
|
+
if not isinstance(admission, Admission):
|
|
116
|
+
raise ProviderAccessError("dependency", "admission_missing")
|
|
117
|
+
return admission
|
|
118
|
+
|
|
119
|
+
def protect(self, operation_id: str) -> Callable[..., Any]:
|
|
120
|
+
operation = self.provider.operations.get(operation_id)
|
|
121
|
+
if operation is None:
|
|
122
|
+
raise ValueError("unknown external operation: %s" % operation_id)
|
|
123
|
+
|
|
124
|
+
def decorate(handler: Any) -> Any:
|
|
125
|
+
endpoint = handler.__name__
|
|
126
|
+
|
|
127
|
+
@wraps(handler)
|
|
128
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
129
|
+
if request.endpoint is None or request.endpoint.rsplit(".", 1)[-1] != (
|
|
130
|
+
operation.endpoint if operation.endpoint else endpoint
|
|
131
|
+
):
|
|
132
|
+
return _error_response(405, "method_not_allowed", _request_id())
|
|
133
|
+
if request.method not in operation.methods:
|
|
134
|
+
return _error_response(405, "method_not_allowed", _request_id())
|
|
135
|
+
started = time.monotonic()
|
|
136
|
+
inbound = self.provider.inbound_seconds
|
|
137
|
+
deadline = started + inbound if inbound is not None else None
|
|
138
|
+
values = request.headers.getlist("Authorization")
|
|
139
|
+
match = _AUTHORIZATION_PATTERN.fullmatch(values[0]) if len(values) == 1 else None
|
|
140
|
+
if match is None or request.headers.getlist("DPoP"):
|
|
141
|
+
return _access_error_response(
|
|
142
|
+
ProviderAccessError("authentication", "malformed_token")
|
|
143
|
+
)
|
|
144
|
+
scheme, token = match.groups()
|
|
145
|
+
attestation_values = request.headers.getlist("X-SMAI-Transport-Attestation")
|
|
146
|
+
attestation = attestation_values[0] if len(attestation_values) == 1 else None
|
|
147
|
+
if len(attestation_values) > 1:
|
|
148
|
+
return _access_error_response(
|
|
149
|
+
ProviderAccessError("authentication", "attestation_malformed")
|
|
150
|
+
)
|
|
151
|
+
facts = request.environ.get("commonlib.socket_facts")
|
|
152
|
+
try:
|
|
153
|
+
captured = None
|
|
154
|
+
if type(facts) is dict and facts.get("tls") is False:
|
|
155
|
+
raw = request.environ.get("commonlib.http_request_bytes")
|
|
156
|
+
if (
|
|
157
|
+
type(raw) is not dict
|
|
158
|
+
or raw.get("input") is not request.environ["wsgi.input"]
|
|
159
|
+
or raw.get("method") != request.method
|
|
160
|
+
or raw.get("target") != facts.get("raw_target")
|
|
161
|
+
or type(raw.get("started")) not in (int, float)
|
|
162
|
+
or raw["started"] > started
|
|
163
|
+
or deadline is None
|
|
164
|
+
or "_cached_data" in request.__dict__
|
|
165
|
+
or getattr(raw["input"], "position", 0) != 0
|
|
166
|
+
):
|
|
167
|
+
raise ProviderAccessError("dependency", "socket_facts")
|
|
168
|
+
deadline = min(deadline, raw["started"] + inbound)
|
|
169
|
+
headers = raw["headers"]
|
|
170
|
+
|
|
171
|
+
def values_for(name: Any) -> Any:
|
|
172
|
+
return [v for k, v in headers if k.lower() == name]
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
values_for("authorization") != values
|
|
176
|
+
or values_for("x-smai-transport-attestation") != attestation_values
|
|
177
|
+
):
|
|
178
|
+
raise ProviderAccessError("authentication", "attestation_malformed")
|
|
179
|
+
# 先检查 framing,再在原预算内读取;不会等待未声明长度的流。
|
|
180
|
+
lengths = values_for("content-length")
|
|
181
|
+
if (
|
|
182
|
+
len(lengths) > 1
|
|
183
|
+
or lengths
|
|
184
|
+
and not re.fullmatch("0|[1-9][0-9]*", lengths[0])
|
|
185
|
+
or any(
|
|
186
|
+
values_for(n)
|
|
187
|
+
for n in (
|
|
188
|
+
"content-encoding",
|
|
189
|
+
"transfer-encoding",
|
|
190
|
+
"trailer",
|
|
191
|
+
"expect",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
):
|
|
195
|
+
raise ProviderAccessError("authentication", "attestation_claims")
|
|
196
|
+
length = int(lengths[0]) if lengths else 0
|
|
197
|
+
if length > MAX_HTTP_BODY_BYTES:
|
|
198
|
+
raise ProviderAccessError("authentication", "attestation_claims")
|
|
199
|
+
body = self.provider._bounded(lambda: raw["input"].read(length), deadline)
|
|
200
|
+
if len(body) != length:
|
|
201
|
+
raise ProviderAccessError("authentication", "attestation_claims")
|
|
202
|
+
captured = HTTPRequestBytes(request.method, raw["target"], headers, body)
|
|
203
|
+
_http_request_fields(captured)
|
|
204
|
+
request._cached_data = body
|
|
205
|
+
request.environ["wsgi.input"] = io.BytesIO(body)
|
|
206
|
+
request.__dict__["stream"] = request.environ["wsgi.input"]
|
|
207
|
+
admission = self.provider.authorize(
|
|
208
|
+
facts,
|
|
209
|
+
operation_id,
|
|
210
|
+
scheme,
|
|
211
|
+
token,
|
|
212
|
+
request.method,
|
|
213
|
+
attestation=attestation,
|
|
214
|
+
deadline=deadline,
|
|
215
|
+
**({"http_request": captured} if captured is not None else {}),
|
|
216
|
+
)
|
|
217
|
+
g.external_admission = admission
|
|
218
|
+
result = handler(*args, **kwargs)
|
|
219
|
+
if isinstance(result, Response):
|
|
220
|
+
payload = result
|
|
221
|
+
else:
|
|
222
|
+
payload = jsonify(result)
|
|
223
|
+
# 有限读响应发出前复核同次依据;写操作由业务最后可拒绝点显式复核。
|
|
224
|
+
if operation.accept == "read":
|
|
225
|
+
admission.recheck()
|
|
226
|
+
return payload
|
|
227
|
+
except ProviderAccessError as error:
|
|
228
|
+
return _access_error_response(error)
|
|
229
|
+
except HTTPException as error:
|
|
230
|
+
return _error_response(
|
|
231
|
+
error.code or 503, "external_request_failed", _request_id()
|
|
232
|
+
)
|
|
233
|
+
except Exception:
|
|
234
|
+
# 不记录异常链或请求正文;诊断按 request_id 关联。
|
|
235
|
+
return _error_response(503, "external_dependency_unavailable", _request_id())
|
|
236
|
+
|
|
237
|
+
return wrapped
|
|
238
|
+
|
|
239
|
+
return decorate
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _status_exception_response(error: Any) -> Any:
|
|
243
|
+
status = error.code if isinstance(error, HTTPException) else 503
|
|
244
|
+
description = (
|
|
245
|
+
error.description
|
|
246
|
+
if isinstance(error, HTTPException) and type(error.description) is dict
|
|
247
|
+
else {}
|
|
248
|
+
)
|
|
249
|
+
code = description.get("code")
|
|
250
|
+
if code not in ("00010", "00011", "00021"):
|
|
251
|
+
code = "00011" if status in (401, 403) else "00021"
|
|
252
|
+
message = description.get("msg") if status in (401, 403) else None
|
|
253
|
+
if message not in ("授权超时", "授权无效", "权限不足"):
|
|
254
|
+
message = "授权无效" if status in (401, 403) else "诊断请求失败"
|
|
255
|
+
return jsonify({"code": code, "msg": message, "data": {}}), status
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _bounded_status(call: Any, deadline: Any) -> Any:
|
|
259
|
+
from gevent import Timeout
|
|
260
|
+
|
|
261
|
+
class _Expired(Exception):
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
remaining = deadline - time.monotonic()
|
|
265
|
+
if remaining <= 0:
|
|
266
|
+
return jsonify({"code": "00021", "msg": "诊断请求失败", "data": {}}), 503
|
|
267
|
+
try:
|
|
268
|
+
with Timeout(remaining, _Expired()):
|
|
269
|
+
return call()
|
|
270
|
+
except _Expired:
|
|
271
|
+
return jsonify({"code": "00021", "msg": "诊断请求失败", "data": {}}), 503
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def install_provider(
|
|
275
|
+
app: Any,
|
|
276
|
+
provider: Any,
|
|
277
|
+
service_display: Optional[str] = None,
|
|
278
|
+
storage_check: Optional[Callable[..., Any]] = None,
|
|
279
|
+
admin_guard: Optional[Callable[..., Any]] = None,
|
|
280
|
+
) -> ExternalHTTP:
|
|
281
|
+
"""唯一安装入口:注册生命周期、Status 路由与保护边界;import 时不启动。
|
|
282
|
+
|
|
283
|
+
provider 为可信制品构造的 Provider 或准备失败的 UnavailableProvider;后者
|
|
284
|
+
仅保留诊断和拒绝接纳,不创建运行时。storage_check 为服务自有
|
|
285
|
+
必要持久层只读检查(返回 STORAGE_NOT_APPLICABLE 表示真实无持久层),
|
|
286
|
+
不从 ZIP/请求新增探测目标。admin_guard 为既有管理员 Bearer guard 装饰器
|
|
287
|
+
(必须由宿主显式传入;缺失拒绝安装)。安装幂等;构造失败由创建方清理。
|
|
288
|
+
"""
|
|
289
|
+
if not callable(admin_guard):
|
|
290
|
+
raise ValueError("admin_guard must be an explicit administrator decorator")
|
|
291
|
+
if not isinstance(provider, (Provider, UnavailableProvider)):
|
|
292
|
+
raise ValueError("provider must be Provider or UnavailableProvider")
|
|
293
|
+
if service_display is not None and (
|
|
294
|
+
type(service_display) is not str
|
|
295
|
+
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,119}", service_display) is None
|
|
296
|
+
):
|
|
297
|
+
raise ValueError("invalid external service display")
|
|
298
|
+
http = app.extensions.get("commonlib.external_http")
|
|
299
|
+
if http is not None:
|
|
300
|
+
if http.provider is not provider:
|
|
301
|
+
raise ValueError("external provider already installed")
|
|
302
|
+
return http
|
|
303
|
+
http = ExternalHTTP(app, provider)
|
|
304
|
+
app.extensions["commonlib.external_http"] = http
|
|
305
|
+
# serv_process 在监听前 start、退出 finally stop;此处不重复注册 atexit。
|
|
306
|
+
app.extensions["commonlib.external"] = provider
|
|
307
|
+
|
|
308
|
+
status_api = Blueprint("commonlib_external_status", __name__)
|
|
309
|
+
|
|
310
|
+
@status_api.route("/external/status", methods=["GET"], provide_automatic_options=False)
|
|
311
|
+
def external_status() -> Any:
|
|
312
|
+
deadline = time.monotonic() + STATUS_TOTAL_TIMEOUT
|
|
313
|
+
|
|
314
|
+
def invoke() -> Any:
|
|
315
|
+
if (
|
|
316
|
+
request.args
|
|
317
|
+
or request.content_length not in (None, 0)
|
|
318
|
+
or request.environ.get("HTTP_TRANSFER_ENCODING")
|
|
319
|
+
):
|
|
320
|
+
from flask import abort
|
|
321
|
+
|
|
322
|
+
abort(400, {"code": "00021"})
|
|
323
|
+
data = provider.collect_status(
|
|
324
|
+
request.environ.get("commonlib.socket_facts"),
|
|
325
|
+
storage_check,
|
|
326
|
+
deadline=deadline,
|
|
327
|
+
service_display=service_display,
|
|
328
|
+
)
|
|
329
|
+
response = jsonify({"code": "00000", "msg": "成功", "data": data})
|
|
330
|
+
response.headers["Cache-Control"] = "no-store"
|
|
331
|
+
return response
|
|
332
|
+
|
|
333
|
+
guard = admin_guard
|
|
334
|
+
try:
|
|
335
|
+
return _bounded_status(guard(invoke), deadline)
|
|
336
|
+
except HTTPException as error:
|
|
337
|
+
return _status_exception_response(error)
|
|
338
|
+
except Exception:
|
|
339
|
+
return _status_exception_response(Exception())
|
|
340
|
+
|
|
341
|
+
app.register_blueprint(status_api)
|
|
342
|
+
|
|
343
|
+
# 路由匹配失败、未加保护的 External 路由也不进入会回显正文的历史处理器。
|
|
344
|
+
handle_http_exception = app.handle_http_exception
|
|
345
|
+
|
|
346
|
+
def external_http_exception(error: Any) -> Any:
|
|
347
|
+
if request.path == "/external" or request.path.startswith("/external/"):
|
|
348
|
+
return _error_response(error.code or 503, "external_request_failed", _request_id())
|
|
349
|
+
return handle_http_exception(error)
|
|
350
|
+
|
|
351
|
+
app.handle_http_exception = external_http_exception
|
|
352
|
+
|
|
353
|
+
@app.after_request
|
|
354
|
+
def _commonlib_external_boundary(response: Any) -> Any:
|
|
355
|
+
return _strip_external_cors(response)
|
|
356
|
+
|
|
357
|
+
return http
|