@mokuyoaxis/dsh-iris 0.3.0-alpha.1 → 0.3.0-beta.1
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.
- package/CHANGELOG.md +63 -1
- package/README.en.md +20 -13
- package/README.md +20 -13
- package/bin/dsh-iris.js +143 -51
- package/docs/AGENT_CORE_QUICKSTART.md +2 -2
- package/docs/AGENT_QUICKSTART.md +1 -1
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +4 -0
- package/docs/AUDIO_FLOWS.md +20 -4
- package/docs/BETA_SCOPE.md +42 -0
- package/docs/CONTACT_SHEET.md +1 -1
- package/docs/CORE_CONNECTIONS.md +3 -1
- package/docs/CORE_QUICKSTART.md +7 -5
- package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
- package/docs/CORE_SERVICE.md +9 -5
- package/docs/CORE_SKILLS.md +2 -2
- package/docs/DSH_COMPATIBILITY.md +11 -1
- package/docs/DSH_CORE_MIGRATION.md +6 -0
- package/docs/DSH_RUNTIME.md +2 -2
- package/docs/HEADLESS_CLI.md +29 -4
- package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
- package/docs/IMAGE_FLOW.md +2 -2
- package/docs/IMAGE_GENERATION.md +1 -1
- package/docs/LOCAL_API.md +2 -2
- package/docs/LOCAL_API_CONTRACT.md +17 -15
- package/docs/LOCAL_CREATION.md +2 -2
- package/docs/LOCAL_OPERATIONS.md +2 -2
- package/docs/LOCAL_SELECTION.md +1 -1
- package/docs/LOCAL_WEB.md +11 -5
- package/docs/MCP.md +30 -9
- package/docs/MEDIA_SCHEDULING.md +72 -0
- package/docs/NETWORK_RESOURCES.md +106 -0
- package/docs/NETWORK_ROUTING.md +103 -0
- package/docs/NETWORK_WORKBENCH.md +65 -0
- package/docs/PANEL_EXTENSIONS.md +1 -1
- package/docs/PLUGINS.md +9 -5
- package/docs/PLUGIN_DEVELOPMENT.md +39 -0
- package/docs/PLUGIN_STARTUP.md +15 -2
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
- package/docs/PROVIDER_CREDENTIALS.md +67 -0
- package/docs/PROVIDER_EXTENSIONS.md +8 -4
- package/docs/PROVIDER_HEALTH.md +4 -2
- package/docs/PROVIDER_NETWORK.md +45 -0
- package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
- package/docs/PROVIDER_SETTINGS.md +13 -7
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
- package/docs/ROADMAP.md +6 -6
- package/docs/SECURITY.md +1 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
- package/docs/VIDEO_FLOWS.md +89 -0
- package/docs/VISION_MODEL.md +2 -0
- package/docs/WEB_PROTOTYPE.md +3 -3
- package/docs/WEB_RECOVERY.md +2 -2
- package/docs/WORKBENCH_ARTIFACTS.md +6 -0
- package/docs/WORKBENCH_LOGIN.md +2 -2
- package/docs/releases/0.3.0-beta.1.md +34 -0
- package/docs/screenshots.md +2 -2
- package/lib/actions.js +5 -4
- package/lib/adapters.js +58 -41
- package/lib/api.js +23 -13
- package/lib/application-runtime.js +44 -15
- package/lib/application-writer.js +193 -0
- package/lib/client.d.ts +3 -3
- package/lib/client.js +62 -25
- package/lib/comfyui-adapter.js +3 -2
- package/lib/comfyui-client.js +10 -1
- package/lib/command-service.js +18 -5
- package/lib/config.js +13 -9
- package/lib/configured-image-runtime.js +28 -9
- package/lib/connection-skill.js +9 -0
- package/lib/core-artifact-store.js +68 -26
- package/lib/core-bridge-tools.js +5 -3
- package/lib/core-capability-catalog.js +21 -11
- package/lib/core-delivery-cache.js +8 -3
- package/lib/core-service-client.js +6 -2
- package/lib/core-tasks.js +86 -11
- package/lib/creation-request-store.js +8 -5
- package/lib/doctor.js +2 -2
- package/lib/dsh-core-adapter.js +61 -9
- package/lib/dsh-provider-config.js +1 -1
- package/lib/dsh-service-bridge.js +1 -0
- package/lib/flow-store.js +64 -17
- package/lib/image-flow.js +39 -17
- package/lib/index.js +10 -5
- package/lib/local-api-projection.js +6 -2
- package/lib/local-api.js +137 -49
- package/lib/local-creation.js +12 -5
- package/lib/local-operations.js +2 -2
- package/lib/local-request-root.js +2 -2
- package/lib/local-web-assets.js +2 -0
- package/lib/mcp-http.js +82 -9
- package/lib/mcp-server.js +23 -1
- package/lib/media-scheduler.js +194 -0
- package/lib/model-directory-service.js +25 -8
- package/lib/music-transport.js +4 -2
- package/lib/network-config-service.js +123 -0
- package/lib/network-config.js +127 -0
- package/lib/network-doctor.js +94 -0
- package/lib/network-management.js +51 -0
- package/lib/network-policy.js +19 -0
- package/lib/network-route-evidence.js +38 -0
- package/lib/network-routing.js +283 -0
- package/lib/operation-runner.js +83 -38
- package/lib/operation-store.js +6 -6
- package/lib/plugins.d.ts +25 -6
- package/lib/plugins.js +2 -1
- package/lib/provider-adapter.js +2 -2
- package/lib/provider-adapters.js +37 -5
- package/lib/provider-catalog.js +33 -16
- package/lib/provider-config-format.js +51 -0
- package/lib/provider-config-service.js +52 -22
- package/lib/provider-context.d.ts +90 -0
- package/lib/provider-context.js +42 -0
- package/lib/provider-credentials.js +97 -0
- package/lib/provider-diagnostics.js +5 -5
- package/lib/provider-health.js +10 -5
- package/lib/provider-monitor.js +5 -3
- package/lib/provider-network.js +52 -0
- package/lib/provider-protocol.js +3 -2
- package/lib/provider-registry.js +15 -5
- package/lib/provider-response.js +10 -0
- package/lib/provider-settings.js +14 -12
- package/lib/provider-task-runner.js +113 -37
- package/lib/provider-transport.js +16 -0
- package/lib/render.js +11 -1
- package/lib/scheduling-policy.js +66 -0
- package/lib/tasks.js +3 -2
- package/lib/video-flow-input.js +16 -0
- package/lib/video-input.js +12 -5
- package/lib/vision-model-routing.js +2 -0
- package/lib/web/app.js +107 -46
- package/lib/web/connections.js +151 -12
- package/lib/web/creation.js +29 -25
- package/lib/web/flow-view.js +4 -3
- package/lib/web/gallery.js +17 -16
- package/lib/web/index.html +58 -5
- package/lib/web/media-client.js +18 -4
- package/lib/web/network.js +232 -0
- package/lib/web/plugin-panel.js +4 -2
- package/lib/web/plugin-status.js +140 -0
- package/lib/web/providers.js +34 -13
- package/lib/web/recovery.js +2 -2
- package/lib/web/styles.css +54 -1
- package/lib/web/transport.js +6 -1
- package/lib/workbench-artifacts.js +3 -2
- package/package.json +5 -2
- package/skills/iris-core/references/comfyui.md +1 -1
- package/templates/plugin-starter/LICENSE +21 -0
- package/templates/plugin-starter/README.md +74 -0
- package/templates/plugin-starter/assets/peach.png +0 -0
- package/templates/plugin-starter/assets/violet.png +0 -0
- package/templates/plugin-starter/index.mjs +27 -0
- package/templates/plugin-starter/package.json +18 -0
- package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
- package/templates/plugin-starter/scripts/environment.mjs +65 -0
- package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
- package/templates/plugin-starter/scripts/setup.mjs +42 -0
- package/templates/plugin-starter/scripts/smoke.mjs +76 -0
- package/templates/plugin-starter/web/board.css +27 -0
- package/templates/plugin-starter/web/board.mjs +160 -0
- package/templates/plugin-starter/web/png.mjs +23 -0
- package/templates/provider-starter/LICENSE +21 -0
- package/templates/provider-starter/README.md +50 -0
- package/templates/provider-starter/index.d.mts +5 -0
- package/templates/provider-starter/index.mjs +165 -0
- package/templates/provider-starter/package.json +13 -0
- package/templates/provider-starter/scripts/core-cli.mjs +22 -0
- package/templates/provider-starter/scripts/environment.mjs +122 -0
- package/templates/provider-starter/scripts/fixture.mjs +54 -0
- package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
- package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
- package/templates/provider-starter/scripts/setup.mjs +44 -0
- package/templates/provider-starter/scripts/smoke.mjs +145 -0
- package/user_guide.md +3 -3
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Private, explicit network resources. This schema does not activate routing.
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { redactProviderMessage } from './provider-contract.js';
|
|
4
|
+
|
|
5
|
+
export const NETWORK_CONFIG_VERSION = 1;
|
|
6
|
+
export const NETWORK_ROUTING_CONFIG_VERSION = 2;
|
|
7
|
+
export const NETWORK_CONFIG_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
|
+
const ENV = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
|
|
12
|
+
export function networkConfigFail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
13
|
+
const invalid = message => networkConfigFail('IRIS_NETWORK_CONFIG_INVALID', message);
|
|
14
|
+
export function networkFields(value, allowed, required = []) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
16
|
+
|| Object.keys(value).some(key => !allowed.includes(key)) || required.some(key => !Object.hasOwn(value, key))) {
|
|
17
|
+
invalid('网络配置字段不符合约定,请检查对应对象的字段名称与必填项');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function identity(value) {
|
|
22
|
+
if (typeof value !== 'string' || !ID.test(value)) invalid('资源 ID 须为1–64字符的字母、数字、点、短横线或下划线,且以字母或数字开头');
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function label(value) {
|
|
26
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 120 || /[\x00-\x1f\x7f]/.test(value)) invalid('资源名称须为非空且最多120字符的单行文本');
|
|
27
|
+
return value.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeNetworkNode(value) {
|
|
31
|
+
networkFields(value, ['id', 'label', 'enabled', 'protocol', 'host', 'port', 'auth'], ['id', 'protocol', 'host', 'port']);
|
|
32
|
+
const id = identity(value.id);
|
|
33
|
+
if (!['http', 'https'].includes(value.protocol)) invalid('代理协议当前只接受 http 或 https;协议声明不代表已完成连通测试');
|
|
34
|
+
if (typeof value.host !== 'string' || !value.host || value.host.length > 253 || /[\s/\\@?#%]/.test(value.host)
|
|
35
|
+
|| !Number.isInteger(value.port) || value.port < 1 || value.port > 65535) invalid('代理 host 和 port 无效;请分开填写主机与1–65535端口,不填写认证 URL');
|
|
36
|
+
let host = value.host;
|
|
37
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
38
|
+
if (host.includes(':') && isIP(host) !== 6) invalid('代理 IPv6 地址无效');
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(`${value.protocol}://${isIP(host) === 6 ? '[' + host + ']' : host}:${value.port}`);
|
|
41
|
+
if (!url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) invalid('代理 host 无效');
|
|
42
|
+
host = url.hostname.replace(/^\[|\]$/g, '');
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === 'IRIS_NETWORK_CONFIG_INVALID') throw error;
|
|
45
|
+
invalid('代理 host 无效');
|
|
46
|
+
}
|
|
47
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') invalid('enabled 必须是布尔值');
|
|
48
|
+
let auth;
|
|
49
|
+
if (value.auth !== undefined && value.auth !== null) {
|
|
50
|
+
networkFields(value.auth, ['usernameEnv', 'passwordEnv'], ['usernameEnv', 'passwordEnv']);
|
|
51
|
+
if (![value.auth.usernameEnv, value.auth.passwordEnv].every(name => typeof name === 'string' && ENV.test(name))) invalid('代理认证只接受成对的用户名/密码环境变量名');
|
|
52
|
+
auth = { usernameEnv: value.auth.usernameEnv, passwordEnv: value.auth.passwordEnv };
|
|
53
|
+
}
|
|
54
|
+
return { id, label: label(value.label === undefined ? id : value.label), enabled: value.enabled ?? true, protocol: value.protocol, host, port: value.port, ...(auth ? { auth } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeNetworkPool(value) {
|
|
58
|
+
networkFields(value, ['id', 'label', 'enabled', 'mode', 'candidates'], ['id', 'mode', 'candidates']);
|
|
59
|
+
const id = identity(value.id);
|
|
60
|
+
if (!['fixed', 'ordered'].includes(value.mode) || !Array.isArray(value.candidates)
|
|
61
|
+
|| value.candidates.length > (value.mode === 'fixed' ? 1 : 16)) invalid('池需要 fixed(最多1候选)或 ordered(最多16候选),空候选仅为草稿');
|
|
62
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') invalid('enabled 必须是布尔值');
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const candidates = value.candidates.map(candidate => {
|
|
65
|
+
networkFields(candidate, ['kind', 'nodeId'], ['kind']);
|
|
66
|
+
let next, key;
|
|
67
|
+
if (candidate.kind === 'node') { next = { kind: 'node', nodeId: identity(candidate.nodeId) }; key = 'node:' + next.nodeId; }
|
|
68
|
+
else if (candidate.kind === 'direct' && !Object.hasOwn(candidate, 'nodeId')) { next = { kind: 'direct' }; key = 'direct'; }
|
|
69
|
+
else invalid('池候选必须是明确的 node 引用或 direct');
|
|
70
|
+
if (seen.has(key)) invalid('池中不能重复引用同一节点或直连候选');
|
|
71
|
+
seen.add(key); return next;
|
|
72
|
+
});
|
|
73
|
+
return { id, label: label(value.label === undefined ? id : value.label), enabled: value.enabled ?? true, mode: value.mode, candidates };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateNetworkConfig(value) {
|
|
77
|
+
if (![NETWORK_CONFIG_VERSION, NETWORK_ROUTING_CONFIG_VERSION].includes(value?.schemaVersion)) networkConfigFail('IRIS_NETWORK_CONFIG_VERSION', '不支持此网络配置版本;请使用兼容版本,原文件不会自动迁移或覆盖');
|
|
78
|
+
const routing = value.schemaVersion === NETWORK_ROUTING_CONFIG_VERSION;
|
|
79
|
+
const fields = ['schemaVersion', 'configId', 'generation', 'nodes', 'pools', ...(routing ? ['bindings'] : [])];
|
|
80
|
+
networkFields(value, fields, fields);
|
|
81
|
+
if (typeof value.configId !== 'string' || !UUID.test(value.configId) || !Number.isSafeInteger(value.generation) || value.generation < 1
|
|
82
|
+
|| !Array.isArray(value.nodes) || !Array.isArray(value.pools) || value.nodes.length > 256 || value.pools.length > 256) invalid('网络配置身份、修订或资源列表无效(节点/池各最多256项)');
|
|
83
|
+
const nodes = value.nodes.map(normalizeNetworkNode), pools = value.pools.map(normalizeNetworkPool);
|
|
84
|
+
const ids = new Set(nodes.map(node => node.id));
|
|
85
|
+
if (ids.size !== nodes.length || new Set(pools.map(pool => pool.id)).size !== pools.length) invalid('同类资源 ID 不能重复');
|
|
86
|
+
for (const pool of pools) for (const candidate of pool.candidates) {
|
|
87
|
+
if (candidate.kind === 'node' && !ids.has(candidate.nodeId)) invalid('池引用了不存在的节点;请先添加节点或显式编辑候选');
|
|
88
|
+
}
|
|
89
|
+
let bindings;
|
|
90
|
+
if (routing) {
|
|
91
|
+
if (!Array.isArray(value.bindings) || value.bindings.length > 1024) invalid('网络绑定必须是最多1024项的列表');
|
|
92
|
+
const seen = new Set(), poolIds = new Set(pools.map(pool => pool.id));
|
|
93
|
+
bindings = value.bindings.map(binding => {
|
|
94
|
+
networkFields(binding, ['catalogId', 'providerId', 'poolId'], ['catalogId', 'providerId', 'poolId']);
|
|
95
|
+
if (typeof binding.catalogId !== 'string' || !UUID.test(binding.catalogId)
|
|
96
|
+
|| typeof binding.providerId !== 'string' || !binding.providerId.trim() || binding.providerId.length > 256
|
|
97
|
+
|| /[\x00-\x1f\x7f]/.test(binding.providerId)) invalid('绑定需要有效的目录身份与供应商 ID');
|
|
98
|
+
const poolId = identity(binding.poolId), key = JSON.stringify([binding.catalogId, binding.providerId]);
|
|
99
|
+
if (seen.has(key) || !poolIds.has(poolId)) invalid('绑定重复或引用了不存在的池');
|
|
100
|
+
seen.add(key);
|
|
101
|
+
return { catalogId: binding.catalogId, providerId: binding.providerId, poolId };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return { schemaVersion: value.schemaVersion, configId: value.configId, generation: value.generation, nodes, pools, ...(routing ? { bindings } : {}) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function networkNodeCredentialStatus(node, env = process.env) {
|
|
108
|
+
if (!node.auth) return 'none';
|
|
109
|
+
const values = [env[node.auth.usernameEnv], env[node.auth.passwordEnv]];
|
|
110
|
+
if (values.some(value => typeof value !== 'string' || !value.length)) return 'missing';
|
|
111
|
+
return values.some(value => value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)) || values[0].includes(':') ? 'invalid' : 'ready';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function projectNetworkConfig(config, revision, env = process.env) {
|
|
115
|
+
const secrets = [...new Set(config.nodes.flatMap(node => node.auth ? [env[node.auth.usernameEnv], env[node.auth.passwordEnv]] : [])
|
|
116
|
+
.filter(value => typeof value === 'string' && value.length))].sort((a, b) => b.length - a.length);
|
|
117
|
+
const display = value => redactProviderMessage(secrets.reduce((text, secret) => text.split(secret).join('[redacted]'), value));
|
|
118
|
+
const nodes = config.nodes.map(node => ({ ...node, label: display(node.label), host: display(node.host), credentialStatus: networkNodeCredentialStatus(node, env),
|
|
119
|
+
referencedBy: config.pools.filter(pool => pool.candidates.some(candidate => candidate.kind === 'node' && candidate.nodeId === node.id)).map(pool => pool.id) }));
|
|
120
|
+
const pools = config.pools.map(pool => ({ ...pool, label: display(pool.label),
|
|
121
|
+
...(config.bindings ? { referencedBy: config.bindings.filter(binding => binding.poolId === pool.id).map(({ catalogId, providerId }) => ({ catalogId, providerId })) } : {}),
|
|
122
|
+
status: !pool.enabled ? 'disabled' : !pool.candidates.length ? 'empty'
|
|
123
|
+
: pool.candidates.some(candidate => candidate.kind === 'direct' || nodes.some(node => node.id === candidate.nodeId && node.enabled && ['none', 'ready'].includes(node.credentialStatus))) ? 'configured' : 'unavailable' }));
|
|
124
|
+
return { schemaVersion: config.schemaVersion, configId: config.configId, generation: config.generation, revision,
|
|
125
|
+
...(config.bindings ? { routingConfigured: config.bindings.length > 0, runtimeStatus: 'not_observed' } : { routingActive: false }), nodes, pools,
|
|
126
|
+
...(config.bindings ? { bindings: config.bindings.map(binding => ({ ...binding })) } : {}) };
|
|
127
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Explicit GET diagnostics in the caller's Core process, never a media probe.
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
import { loadProviderCatalog } from './provider-catalog.js';
|
|
5
|
+
import { providerMediaBaseUrl } from './provider-protocol.js';
|
|
6
|
+
import { networkManagementSettings } from './network-management.js';
|
|
7
|
+
import { networkFields } from './network-config.js';
|
|
8
|
+
import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
|
|
9
|
+
import { inheritedProviderFetch } from './provider-network.js';
|
|
10
|
+
import { readProviderText } from './provider-response.js';
|
|
11
|
+
import { settingsRevision } from './provider-config-service.js';
|
|
12
|
+
|
|
13
|
+
export const NETWORK_PROBE_TIMEOUT_MS = 8000, NETWORK_PROBE_MAX_BYTES = 16 * 1024;
|
|
14
|
+
const proxyNames = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'];
|
|
15
|
+
export function networkEnvironmentFacts({ env = process.env, execArgv = process.execArgv } = {}) {
|
|
16
|
+
const nodeFlagRequested = execArgv.includes('--use-env-proxy') || /(?:^|\s)--use-env-proxy(?:\s|$)/.test(env.NODE_OPTIONS || '') || env.NODE_USE_ENV_PROXY === '1';
|
|
17
|
+
return { variables: Object.fromEntries(proxyNames.map(name => [name, Boolean(env[name])])), nodeFlagRequested,
|
|
18
|
+
inheritedPolicy: 'unknown', systemTunnel: 'unknown',
|
|
19
|
+
note: '变量存在或Node开关被请求不等于所有请求已代理;宿主fetch/dispatcher和系统TUN的实际路径未由静态检查证明。' };
|
|
20
|
+
}
|
|
21
|
+
export function networkDoctor(options) {
|
|
22
|
+
const report = { contractVersion: 0, observedAt: new Date().toISOString(), source: 'core-process',
|
|
23
|
+
process: { node: process.version, platform: process.platform, arch: process.arch },
|
|
24
|
+
environment: networkEnvironmentFacts(), networkTested: false };
|
|
25
|
+
try { return { ...report, settings: networkManagementSettings(options), configuration: { state: 'readable' } }; }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!/^IRIS_(?:NETWORK|PROVIDER)_/.test(error.code || '')) throw error;
|
|
28
|
+
return { ...report, settings: null, configuration: { state: 'invalid', issue: error.code,
|
|
29
|
+
message: '指定的网络或供应商配置无法读取;请核对文件版本、格式、权限与错误码。未自动修复或联网。' } };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function probeNetworkRoute(options, input, { signal } = {}) {
|
|
34
|
+
const fields = ['expectedRevision', 'expectedProviderRevision', 'providerId', 'target', ...(input?.target === 'provider' ? [] : ['url'])];
|
|
35
|
+
networkFields(input, fields, fields);
|
|
36
|
+
if (!['provider', 'custom', 'ip'].includes(input.target)) throw networkPolicyError('IRIS_NETWORK_PROBE_INVALID', '请选择供应商地址、自定义GET或IP观察');
|
|
37
|
+
if (input.target !== 'provider' && (typeof input.url !== 'string' || !input.url || input.url.length > 2048)) throw networkPolicyError('IRIS_NETWORK_PROBE_INVALID', '测试URL须为最多2048字符的文本');
|
|
38
|
+
const settings = networkManagementSettings(options);
|
|
39
|
+
if (input.expectedRevision !== settings.network?.revision || input.expectedProviderRevision !== settings.catalog.revision) {
|
|
40
|
+
throw networkPolicyError('IRIS_NETWORK_CONFIG_CHANGED', '网络或供应商目录已变化,请重新读取后测试');
|
|
41
|
+
}
|
|
42
|
+
if (['mismatch', 'missing_network'].includes(settings.catalog.state)) throw networkPolicyError('IRIS_NETWORK_CONFIG_MISMATCH', '供应商与网络配置不匹配,未进行测试');
|
|
43
|
+
if (!settings.providers.some(provider => provider.id === input.providerId)) throw networkPolicyError('IRIS_NETWORK_PROVIDER_NOT_FOUND', '测试需要当前目录中的明确供应商');
|
|
44
|
+
const catalog = loadProviderCatalog(options.providerConfigPath), provider = catalog.providers.find(item => item.id === input.providerId);
|
|
45
|
+
if (settingsRevision(catalog) !== input.expectedProviderRevision) throw networkPolicyError('IRIS_NETWORK_PROVIDER_CHANGED', '供应商目录已变化,未进行测试');
|
|
46
|
+
if (!provider) throw networkPolicyError('IRIS_NETWORK_PROVIDER_NOT_FOUND', '测试需要当前目录中的明确供应商');
|
|
47
|
+
let target;
|
|
48
|
+
try { target = new URL(input.target === 'provider' ? providerMediaBaseUrl(provider) : input.url); }
|
|
49
|
+
catch (_) { throw networkPolicyError('IRIS_NETWORK_PROBE_INVALID', '测试地址需要有效的HTTP(S) URL'); }
|
|
50
|
+
if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || target.hash) {
|
|
51
|
+
throw networkPolicyError('IRIS_NETWORK_PROBE_INVALID', '测试地址只接受不含认证信息或片段的HTTP(S) URL');
|
|
52
|
+
}
|
|
53
|
+
const combined = AbortSignal.any([AbortSignal.timeout(NETWORK_PROBE_TIMEOUT_MS), ...(signal ? [signal] : [])]);
|
|
54
|
+
const started = performance.now(); let route = null, dispatchStarted = false, status = null, responseReceived = false;
|
|
55
|
+
const report = () => ({ contractVersion: 0, source: 'core-process', observedAt: new Date().toISOString(),
|
|
56
|
+
providerId: provider.id, target: { kind: input.target, origin: target.origin }, elapsedMs: Math.round(performance.now() - started),
|
|
57
|
+
timeoutMs: NETWORK_PROBE_TIMEOUT_MS, maxBytes: NETWORK_PROBE_MAX_BYTES, requestLimit: 1, requestLimitKind: 'application_get',
|
|
58
|
+
dispatchStarted, responseReceived, httpStatus: status, route,
|
|
59
|
+
note: '只表示本次目标与路线;不证明其他服务、所有任务或系统TUN行为。不携带供应商Key,不生成媒体。' });
|
|
60
|
+
try {
|
|
61
|
+
const requirement = providerNetworkRequirement(provider);
|
|
62
|
+
const network = requirement ? options.resolveProviderNetwork?.({ providerId: provider.id, networkRequirement: requirement }) : undefined;
|
|
63
|
+
if (requirement && (!network || network.providerId !== provider.id || !sameNetworkRequirement(requirement, network.networkRequirement))) throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '测试需要匹配的Core网络上下文,不会静默直连');
|
|
64
|
+
const action = async fetch => {
|
|
65
|
+
combined.throwIfAborted();
|
|
66
|
+
if (!network) { dispatchStarted = true; route = { route: 'inherit', providerId: provider.id, legacy: true }; }
|
|
67
|
+
const response = await fetch(target.href, { method: 'GET', redirect: 'error', credentials: 'omit', signal: combined,
|
|
68
|
+
headers: { Accept: input.target === 'ip' ? 'application/json' : '*/*' } });
|
|
69
|
+
status = response.status; responseReceived = true;
|
|
70
|
+
const text = await readProviderText(response, { maxBytes: NETWORK_PROBE_MAX_BYTES, stage: 'discover', acceptance: 'not_accepted' });
|
|
71
|
+
let observedIp;
|
|
72
|
+
if (input.target === 'ip' && response.ok) {
|
|
73
|
+
let value; try { value = JSON.parse(text); } catch (_) { /* Report a limited observation, never the response body. */ }
|
|
74
|
+
if (value && typeof value.ip === 'string' && net.isIP(value.ip)) observedIp = value.ip;
|
|
75
|
+
else return { ...report(), ok: false, reachable: true, issue: 'IRIS_NETWORK_IP_NOT_OBSERVED' };
|
|
76
|
+
}
|
|
77
|
+
return { ...report(), ok: response.ok, reachable: true, ...(observedIp ? { observedIp } : {}),
|
|
78
|
+
...(!response.ok ? { issue: 'IRIS_NETWORK_TARGET_HTTP_ERROR' } : {}) };
|
|
79
|
+
};
|
|
80
|
+
return network ? await network.run(action, { operation: 'discover', recordNetworkRoute: fact => {
|
|
81
|
+
dispatchStarted = true;
|
|
82
|
+
route = { route: fact.route, providerId: fact.providerId, ...(fact.poolId ? { poolId: fact.poolId } : {}),
|
|
83
|
+
...(fact.nodeId ? { nodeId: fact.nodeId } : {}), configGeneration: fact.configGeneration, configRevision: fact.configRevision };
|
|
84
|
+
} }) : await action(inheritedProviderFetch);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
const code = error.providerCode || error.code;
|
|
88
|
+
if (!(error instanceof TypeError && !error.code) && !['AbortError', 'TimeoutError'].includes(error.name)
|
|
89
|
+
&& !/^IRIS_NETWORK_/.test(code || '') && code !== 'IRIS_PROVIDER_RESPONSE_TOO_LARGE') throw error;
|
|
90
|
+
return { ...report(), ok: false, reachable: responseReceived,
|
|
91
|
+
issue: combined.aborted ? 'IRIS_NETWORK_PROBE_TIMEOUT' : code === 'IRIS_PROVIDER_RESPONSE_TOO_LARGE' ? 'IRIS_NETWORK_PROBE_TOO_LARGE'
|
|
92
|
+
: /^IRIS_NETWORK_/.test(code || '') ? code : 'IRIS_NETWORK_PROBE_FAILED' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Owner-only composition over the existing resource store. No implicit enrollment.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { readNetworkConfig, executeNetworkConfigCommand } from './network-config-service.js';
|
|
4
|
+
import { networkFields, projectNetworkConfig } from './network-config.js';
|
|
5
|
+
import { loadProviderCatalog } from './provider-catalog.js';
|
|
6
|
+
import { projectProviderSettings } from './provider-settings.js';
|
|
7
|
+
import { settingsRevision } from './provider-config-service.js';
|
|
8
|
+
import { providerNetworkRequirement, networkPolicyError } from './network-policy.js';
|
|
9
|
+
|
|
10
|
+
export const NETWORK_MANAGEMENT_ACTIONS = Object.freeze(['init', 'nodes/add', 'nodes/set', 'nodes/remove',
|
|
11
|
+
'pools/add', 'pools/set', 'pools/remove', 'bindings/set', 'bindings/remove']);
|
|
12
|
+
|
|
13
|
+
export function networkManagementSettings({ networkConfigPath, providerConfigPath, registry }) {
|
|
14
|
+
let saved;
|
|
15
|
+
try { fs.lstatSync(networkConfigPath); saved = readNetworkConfig(networkConfigPath); }
|
|
16
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
17
|
+
const catalog = providerConfigPath ? loadProviderCatalog(providerConfigPath) : undefined;
|
|
18
|
+
const requirement = providerNetworkRequirement(catalog);
|
|
19
|
+
const revision = catalog ? settingsRevision(catalog) : null;
|
|
20
|
+
const state = !catalog ? 'absent' : !requirement ? 'legacy' : !saved ? 'missing_network'
|
|
21
|
+
: saved.config.schemaVersion !== 2 || saved.config.configId !== requirement.configId ? 'mismatch' : 'ready';
|
|
22
|
+
const network = saved ? projectNetworkConfig(saved.config, saved.revision) : null;
|
|
23
|
+
const settings = catalog ? projectProviderSettings(catalog, revision, registry) : undefined;
|
|
24
|
+
const providers = (settings?.providers || []).map(provider => {
|
|
25
|
+
const binding = state === 'ready' && saved.config.bindings.find(item => item.catalogId === requirement.catalogId && item.providerId === provider.id);
|
|
26
|
+
const pool = binding && network.pools.find(item => item.id === binding.poolId);
|
|
27
|
+
return { id: provider.id, name: provider.name, enabled: provider.enabled,
|
|
28
|
+
route: { state: ['mismatch', 'missing_network'].includes(state) ? 'blocked' : !binding ? 'inherit' : pool.status === 'configured' ? 'pool' : 'blocked',
|
|
29
|
+
...(pool ? { poolId: pool.id, poolLabel: pool.label, poolStatus: pool.status, mode: pool.mode } : {}),
|
|
30
|
+
...(state === 'legacy' ? { legacy: true } : {}) } };
|
|
31
|
+
});
|
|
32
|
+
return { contractVersion: 0, storage: saved ? 'ready' : 'missing', network,
|
|
33
|
+
catalog: { state, revision, ...(requirement ? { configId: requirement.configId, catalogId: requirement.catalogId } : {}) }, providers };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function manageNetworkResources(options, action, input, { signal } = {}) {
|
|
37
|
+
if (!NETWORK_MANAGEMENT_ACTIONS.includes(action)) throw new TypeError('未知网络管理操作');
|
|
38
|
+
let command = action === 'init' ? 'routing.init' : action.replace('/', '.');
|
|
39
|
+
if (action === 'bindings/set') {
|
|
40
|
+
networkFields(input, ['expectedRevision', 'expectedProviderRevision', 'providerId', 'poolId'],
|
|
41
|
+
['expectedRevision', 'expectedProviderRevision', 'providerId', 'poolId']);
|
|
42
|
+
const settings = networkManagementSettings(options);
|
|
43
|
+
if (settings.catalog.state !== 'ready') throw networkPolicyError('IRIS_NETWORK_CATALOG_REQUIRED', '绑定需要匹配的v2网络文件和供应商目录;请先按指引另存启用,不自动迁移');
|
|
44
|
+
if (input.expectedProviderRevision !== settings.catalog.revision) throw networkPolicyError('IRIS_NETWORK_PROVIDER_CHANGED', '供应商目录已变化,请重新读取后核对绑定');
|
|
45
|
+
if (!settings.providers.some(provider => provider.id === input.providerId)) throw networkPolicyError('IRIS_NETWORK_PROVIDER_NOT_FOUND', '所选供应商不在当前目录');
|
|
46
|
+
input = { expectedRevision: input.expectedRevision, catalogId: settings.catalog.catalogId, providerId: input.providerId, poolId: input.poolId };
|
|
47
|
+
}
|
|
48
|
+
const changed = executeNetworkConfigCommand(options.networkConfigPath, command, input, { signal });
|
|
49
|
+
return { contractVersion: 0, changed: changed.created === true || changed.changed === true,
|
|
50
|
+
settings: networkManagementSettings(options) };
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Nonsecret identity shared by persisted records and owned Provider assembly.
|
|
2
|
+
export const CATALOG_NETWORK = Symbol('iris.catalog-network');
|
|
3
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
4
|
+
export function checkedNetworkRequirement(value) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
6
|
+
|| Object.keys(value).length !== 3 || Object.keys(value).some(key => !['schemaVersion', 'configId', 'catalogId'].includes(key))
|
|
7
|
+
|| value.schemaVersion !== 1 || !UUID.test(value.configId) || !UUID.test(value.catalogId)) {
|
|
8
|
+
throw Object.assign(new Error('网络要求身份或版本无效'), { code: 'IRIS_NETWORK_REQUIREMENT_INVALID' });
|
|
9
|
+
}
|
|
10
|
+
return { schemaVersion: 1, configId: value.configId, catalogId: value.catalogId };
|
|
11
|
+
}
|
|
12
|
+
export function sameNetworkRequirement(a, b) {
|
|
13
|
+
return a === undefined || b === undefined ? a === b : a.schemaVersion === b.schemaVersion && a.configId === b.configId && a.catalogId === b.catalogId;
|
|
14
|
+
}
|
|
15
|
+
export function providerNetworkRequirement(provider) { return provider?.[CATALOG_NETWORK]; }
|
|
16
|
+
export function createNetworkAttemptBudget() { return { remaining: 16, probes: new Map() }; }
|
|
17
|
+
export function networkPolicyError(code, message, stage = 'validate') {
|
|
18
|
+
return Object.assign(new Error(message), { code, stage, category: 'invalid_request', acceptance: 'not_accepted' });
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Nonsecret dispatch intentions. These records do not prove receipt or public IP.
|
|
2
|
+
import { checkedNetworkRequirement } from './network-policy.js';
|
|
3
|
+
|
|
4
|
+
export const MAX_NETWORK_ROUTE_FACTS = 128;
|
|
5
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
6
|
+
const ATTEMPT = /^attempt_[a-f0-9]{24}$/;
|
|
7
|
+
const SHA = /^[a-f0-9]{64}$/;
|
|
8
|
+
const OPERATIONS = ['discover', 'prepareInput', 'submit', 'poll', 'download', 'cancel', 'vision'];
|
|
9
|
+
const FIELDS = ['schemaVersion', 'configId', 'catalogId', 'configGeneration', 'configRevision', 'providerId', 'operation', 'route', 'poolId', 'nodeId', 'attemptId'];
|
|
10
|
+
const invalid = () => { throw Object.assign(new Error('网络路线事实包含无效身份、阶段或未允许字段'), { code: 'IRIS_NETWORK_ROUTE_INVALID' }); };
|
|
11
|
+
|
|
12
|
+
export function checkedNetworkRouteFact(value) {
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !FIELDS.includes(key))
|
|
14
|
+
|| value.schemaVersion !== 1 || !Number.isSafeInteger(value.configGeneration) || value.configGeneration < 1 || !SHA.test(value.configRevision)
|
|
15
|
+
|| typeof value.providerId !== 'string' || !value.providerId || value.providerId.length > 256 || /[\x00-\x1f\x7f]/.test(value.providerId)
|
|
16
|
+
|| !OPERATIONS.includes(value.operation) || !['inherit', 'direct', 'proxy'].includes(value.route)
|
|
17
|
+
|| value.poolId !== undefined && !ID.test(value.poolId) || value.nodeId !== undefined && !ID.test(value.nodeId)
|
|
18
|
+
|| value.attemptId !== undefined && !ATTEMPT.test(value.attemptId)
|
|
19
|
+
|| value.route === 'proxy' && (!value.poolId || !value.nodeId)
|
|
20
|
+
|| value.route !== 'proxy' && value.nodeId !== undefined || value.route === 'inherit' && value.poolId !== undefined) invalid();
|
|
21
|
+
try { checkedNetworkRequirement({ schemaVersion: 1, configId: value.configId, catalogId: value.catalogId }); }
|
|
22
|
+
catch (_) { invalid(); }
|
|
23
|
+
return { schemaVersion: 1, configId: value.configId, catalogId: value.catalogId,
|
|
24
|
+
configGeneration: value.configGeneration, configRevision: value.configRevision, providerId: value.providerId,
|
|
25
|
+
operation: value.operation, route: value.route,
|
|
26
|
+
...(value.poolId !== undefined ? { poolId: value.poolId } : {}), ...(value.nodeId !== undefined ? { nodeId: value.nodeId } : {}),
|
|
27
|
+
...(value.attemptId !== undefined ? { attemptId: value.attemptId } : {}) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const networkRouteFactKey = value => JSON.stringify(checkedNetworkRouteFact(value));
|
|
31
|
+
export function checkedNetworkRouteRecord(value) {
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
|
33
|
+
const { firstAt, lastAt, requestCount, phase, ...fact } = value;
|
|
34
|
+
const checked = checkedNetworkRouteFact(fact);
|
|
35
|
+
const iso = time => typeof time === 'string' && Number.isFinite(Date.parse(time)) && new Date(time).toISOString() === time;
|
|
36
|
+
if (phase !== 'dispatch_started' || !iso(firstAt) || !iso(lastAt) || lastAt < firstAt || !Number.isSafeInteger(requestCount) || requestCount < 1) invalid();
|
|
37
|
+
return { ...checked, phase, firstAt, lastAt, requestCount };
|
|
38
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Instance-owned routing. CONNECT preflight never sends a business request.
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { readNetworkConfig } from './network-config-service.js';
|
|
6
|
+
import { networkNodeCredentialStatus, NETWORK_ROUTING_CONFIG_VERSION } from './network-config.js';
|
|
7
|
+
import { checkedNetworkRequirement, networkPolicyError, createNetworkAttemptBudget } from './network-policy.js';
|
|
8
|
+
import { createProviderNetworkClient, inheritedProviderFetch } from './provider-network.js';
|
|
9
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
10
|
+
|
|
11
|
+
const hash = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
12
|
+
const authority = url => `${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : 80)}`;
|
|
13
|
+
const nodeUri = node => `${node.protocol}://${net.isIP(node.host) === 6 ? '[' + node.host + ']' : node.host}:${node.port}`;
|
|
14
|
+
const PREFLIGHT_ERRORS = new Set([
|
|
15
|
+
'ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'EAI_AGAIN', 'ENOTFOUND',
|
|
16
|
+
'ETIMEDOUT', 'EPIPE', 'EADDRNOTAVAIL', 'EACCES', 'EPERM', 'IRIS_NETWORK_CONNECT_REJECTED',
|
|
17
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_HEADERS_OVERFLOW', 'UND_ERR_SOCKET', 'UND_ERR_ABORTED', 'UND_ERR_PRX_TLS',
|
|
18
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
19
|
+
'UNABLE_TO_GET_ISSUER_CERT', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'CERT_REVOKED',
|
|
20
|
+
'ERR_TLS_CERT_ALTNAME_INVALID', 'ERR_TLS_HANDSHAKE_TIMEOUT'
|
|
21
|
+
]);
|
|
22
|
+
const preflightUnavailable = error => PREFLIGHT_ERRORS.has(error.code) || /^(?:ERR_SSL_|HPE_)[A-Z0-9_]+$/.test(error.code || '')
|
|
23
|
+
|| ['AbortError', 'TimeoutError'].includes(error.name);
|
|
24
|
+
function failure(context, code, message, category = 'network') {
|
|
25
|
+
const operation = context.operation || 'submit';
|
|
26
|
+
const notAccepted = ['submit', 'prepareInput', 'discover', 'vision'].includes(operation);
|
|
27
|
+
return Object.assign(new ProviderContractError(message, { providerCode: code, category, stage: notAccepted ? 'prepare' : operation,
|
|
28
|
+
acceptance: notAccepted ? 'not_accepted' : 'accepted' }), { code });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function directPreflight(url, signal, timeoutMs) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
const combined = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const socket = net.connect({ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)) });
|
|
36
|
+
const aborted = () => socket.destroy(combined.reason);
|
|
37
|
+
combined.addEventListener('abort', aborted, { once: true });
|
|
38
|
+
const cleanup = () => combined.removeEventListener('abort', aborted);
|
|
39
|
+
socket.once('error', error => { cleanup(); reject(error); });
|
|
40
|
+
socket.once('connect', () => { cleanup(); socket.destroy(); resolve(); });
|
|
41
|
+
if (combined.aborted) aborted();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transportFetchInput(transport, input, options) {
|
|
46
|
+
// Node's built-in fetch and the installed Undici use different Web IDL brands.
|
|
47
|
+
// Convert only at the explicit transport boundary; inherited fetch stays native.
|
|
48
|
+
if (input instanceof Request) {
|
|
49
|
+
const request = new Request(input, options);
|
|
50
|
+
return { input: new transport.Request(request.url, request), options: { signal: request.signal } };
|
|
51
|
+
}
|
|
52
|
+
if (options.body instanceof FormData) {
|
|
53
|
+
const body = new transport.FormData();
|
|
54
|
+
for (const [name, value] of options.body) body.append(name, value);
|
|
55
|
+
return { input, options: { ...options, body } };
|
|
56
|
+
}
|
|
57
|
+
return { input, options };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createProviderNetworkManager({ networkConfigPath, onRoute, tls, proxyTls, scheduler, preflightTimeoutMs = 3000,
|
|
61
|
+
loadTransport = () => {
|
|
62
|
+
// Initialize Node's own fetch classes/default dispatcher without a request.
|
|
63
|
+
// Undici must inherit an existing dispatcher rather than create its own default.
|
|
64
|
+
void globalThis.Request;
|
|
65
|
+
return import('undici');
|
|
66
|
+
} } = {}) {
|
|
67
|
+
if (onRoute !== undefined && typeof onRoute !== 'function') throw new TypeError('onRoute 必须是函数');
|
|
68
|
+
if (typeof loadTransport !== 'function' || !Number.isInteger(preflightTimeoutMs) || preflightTimeoutMs < 1 || preflightTimeoutMs > 30000) throw new TypeError('网络客户端装配参数无效');
|
|
69
|
+
const clients = new Map(), retiring = new Set();
|
|
70
|
+
let closing = false, disposal, transport, revision, cleanupFailed = false;
|
|
71
|
+
function retire(client) {
|
|
72
|
+
const pending = client.dispose();
|
|
73
|
+
retiring.add(pending);
|
|
74
|
+
void pending.then(() => retiring.delete(pending), () => { cleanupFailed = true; retiring.delete(pending); });
|
|
75
|
+
}
|
|
76
|
+
function remember(slot, key, client, routed = false) {
|
|
77
|
+
const previous = clients.get(slot);
|
|
78
|
+
clients.set(slot, { key, client, routed });
|
|
79
|
+
if (previous) retire(previous.client);
|
|
80
|
+
return client;
|
|
81
|
+
}
|
|
82
|
+
const library = () => transport ||= Promise.resolve().then(loadTransport).catch(() => {
|
|
83
|
+
throw failure({ operation: 'discover' }, 'IRIS_NETWORK_CLIENT_UNAVAILABLE', '代理客户端未安装或无法加载;请使用包含所需生产依赖的完整 Iris 安装,不会退回直连', 'invalid_request');
|
|
84
|
+
});
|
|
85
|
+
async function scheduledFetch(fetch, dispatcher, input, options, context) {
|
|
86
|
+
const managed = scheduler.dispatcher(dispatcher, context, options.signal);
|
|
87
|
+
try { return await fetch(input, { ...options, dispatcher: managed }); }
|
|
88
|
+
catch (error) {
|
|
89
|
+
const queuedFailure = [error, error.cause].some(value => typeof value?.code === 'string' && value.code.startsWith('IRIS_SCHEDULER_'));
|
|
90
|
+
if (managed.dispatches === 0 && (queuedFailure || options.signal?.aborted)) {
|
|
91
|
+
const original = typeof error.cause?.code === 'string' && error.cause.code.startsWith('IRIS_SCHEDULER_') ? error.cause : error;
|
|
92
|
+
throw failure(context, typeof original.code === 'string' && original.code.startsWith('IRIS_SCHEDULER_') ? original.code : 'IRIS_SCHEDULER_ABORTED',
|
|
93
|
+
'请求在本地调度期间停止,业务请求尚未派发', options.signal?.aborted ? 'aborted' : 'rate_limit');
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveProviderNetwork({ providerId, modelRef, networkRequirement } = {}) {
|
|
100
|
+
if (closing) throw networkPolicyError('IRIS_PROVIDER_NETWORK_CLOSED', '网络管理实例已关闭');
|
|
101
|
+
if (networkRequirement === undefined) {
|
|
102
|
+
if (!scheduler) return undefined;
|
|
103
|
+
const key = JSON.stringify(['scheduled-inherit', providerId, modelRef]);
|
|
104
|
+
if (clients.has(key)) return clients.get(key).client;
|
|
105
|
+
const scope = new AsyncLocalStorage();
|
|
106
|
+
const base = createProviderNetworkClient({ async fetchPort(input, options = {}) {
|
|
107
|
+
const context = scope.getStore();
|
|
108
|
+
if (!context) throw networkPolicyError('IRIS_NETWORK_OPERATION_REQUIRED', '调度客户端必须在完整操作内调用');
|
|
109
|
+
const { getGlobalDispatcher } = await library();
|
|
110
|
+
return scheduledFetch(inheritedProviderFetch, getGlobalDispatcher(), input, options, context);
|
|
111
|
+
} });
|
|
112
|
+
const client = Object.freeze({ ...base, run(action, context = {}) {
|
|
113
|
+
return base.run(fetch => scope.run({ providerId, modelRef, ...context }, () => action(fetch)));
|
|
114
|
+
} });
|
|
115
|
+
return remember(key, key, client);
|
|
116
|
+
}
|
|
117
|
+
const requirement = Object.freeze(checkedNetworkRequirement(networkRequirement));
|
|
118
|
+
if (typeof providerId !== 'string' || !providerId.trim() || providerId.length > 256 || /[\x00-\x1f\x7f]/.test(providerId)) throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '网络解析需要明确的供应商身份');
|
|
119
|
+
if (!networkConfigPath) throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '此供应商配置需要明确的 network-config,不会使用默认出口');
|
|
120
|
+
const saved = readNetworkConfig(networkConfigPath), config = saved.config;
|
|
121
|
+
if (config.schemaVersion !== NETWORK_ROUTING_CONFIG_VERSION || config.configId !== requirement.configId) {
|
|
122
|
+
throw networkPolicyError('IRIS_NETWORK_CONFIG_MISMATCH', '网络配置版本或身份与供应商目录不符,不会借用其他配置');
|
|
123
|
+
}
|
|
124
|
+
if (revision !== saved.revision) {
|
|
125
|
+
revision = saved.revision;
|
|
126
|
+
for (const [slot, entry] of clients) if (entry.routed) {
|
|
127
|
+
clients.delete(slot); retire(entry.client);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const binding = config.bindings.find(value => value.catalogId === requirement.catalogId && value.providerId === providerId);
|
|
131
|
+
const pool = binding ? config.pools.find(value => value.id === binding.poolId) : undefined;
|
|
132
|
+
if (binding && (!pool?.enabled || !pool.candidates.length)) throw networkPolicyError('IRIS_NETWORK_POOL_UNAVAILABLE', '显式绑定的出口池为空或已停用,不会隐式继承或直连');
|
|
133
|
+
const secrets = pool ? pool.candidates.flatMap(candidate => {
|
|
134
|
+
const node = candidate.kind === 'node' && config.nodes.find(value => value.id === candidate.nodeId);
|
|
135
|
+
return node?.auth ? [process.env[node.auth.usernameEnv], process.env[node.auth.passwordEnv]] : [];
|
|
136
|
+
}) : [];
|
|
137
|
+
const slot = hash([requirement, providerId, ...(scheduler ? [modelRef] : [])]);
|
|
138
|
+
const key = hash([saved.revision, requirement, providerId, ...(scheduler ? [modelRef] : []), secrets]);
|
|
139
|
+
const cached = clients.get(slot);
|
|
140
|
+
if (cached?.key === key) return cached.client;
|
|
141
|
+
const scope = new AsyncLocalStorage(), dispatchers = new Map();
|
|
142
|
+
// Capture only this client's explicitly referenced values; later resolutions
|
|
143
|
+
// use a new client if credentials rotate, while admitted operations can drain.
|
|
144
|
+
const nodeIds = new Set(pool?.candidates.filter(candidate => candidate.kind === 'node').map(candidate => candidate.nodeId) || []);
|
|
145
|
+
const nodes = new Map(config.nodes.filter(node => nodeIds.has(node.id)).map(node => [node.id, { ...node, credentialStatus: networkNodeCredentialStatus(node),
|
|
146
|
+
...(node.auth ? { token: `Basic ${Buffer.from(`${process.env[node.auth.usernameEnv] || ''}:${process.env[node.auth.passwordEnv] || ''}`).toString('base64')}` } : {}) }]));
|
|
147
|
+
|
|
148
|
+
async function choose(url, options, context) {
|
|
149
|
+
if (!pool) return { route: 'inherit' };
|
|
150
|
+
const budget = context.networkBudget || createNetworkAttemptBudget();
|
|
151
|
+
for (const candidate of pool.candidates) {
|
|
152
|
+
if (options.signal?.aborted) throw failure(context, 'IRIS_NETWORK_ABORTED', '业务请求发送前已取消出口预检', 'aborted');
|
|
153
|
+
const probeKey = JSON.stringify([key, url.origin, candidate]);
|
|
154
|
+
if (budget.probes.has(probeKey)) {
|
|
155
|
+
const previous = await budget.probes.get(probeKey);
|
|
156
|
+
if (previous) return previous;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const node = candidate.kind === 'node' ? nodes.get(candidate.nodeId) : undefined;
|
|
160
|
+
if (candidate.kind === 'node' && (!node?.enabled || !['none', 'ready'].includes(node.credentialStatus))) {
|
|
161
|
+
budget.probes.set(probeKey, Promise.resolve(null)); continue;
|
|
162
|
+
}
|
|
163
|
+
if (!budget.remaining) continue;
|
|
164
|
+
budget.remaining--;
|
|
165
|
+
let complete;
|
|
166
|
+
const result = new Promise(resolve => { complete = resolve; });
|
|
167
|
+
budget.probes.set(probeKey, result);
|
|
168
|
+
try {
|
|
169
|
+
if (candidate.kind === 'direct') {
|
|
170
|
+
await directPreflight(url, options.signal, preflightTimeoutMs);
|
|
171
|
+
const selected = { route: 'direct', poolId: pool.id }; complete(selected); return selected;
|
|
172
|
+
}
|
|
173
|
+
const { Client } = await library();
|
|
174
|
+
const probe = new Client(nodeUri(node), { connect: proxyTls, connectTimeout: preflightTimeoutMs });
|
|
175
|
+
try {
|
|
176
|
+
const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(preflightTimeoutMs)]) : AbortSignal.timeout(preflightTimeoutMs);
|
|
177
|
+
const connected = await probe.connect({ path: authority(url), signal,
|
|
178
|
+
headers: { ...(node.token ? { 'proxy-authorization': node.token } : {}), host: authority(url) } });
|
|
179
|
+
connected.socket.destroy();
|
|
180
|
+
if (connected.statusCode !== 200) throw Object.assign(new Error('CONNECT rejected'), { code: 'IRIS_NETWORK_CONNECT_REJECTED' });
|
|
181
|
+
} finally { await probe.destroy(); }
|
|
182
|
+
const selected = { route: 'proxy', poolId: pool.id, nodeId: node.id }; complete(selected); return selected;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
complete(null);
|
|
185
|
+
if (options.signal?.aborted) throw failure(context, 'IRIS_NETWORK_ABORTED', '业务请求发送前已取消出口预检', 'aborted');
|
|
186
|
+
if (error.code === 'IRIS_NETWORK_CLIENT_UNAVAILABLE') throw error;
|
|
187
|
+
if (!preflightUnavailable(error)) throw error;
|
|
188
|
+
// Only the independent connection preflight may select another route.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw failure(context, 'IRIS_NETWORK_POOL_EXHAUSTED', '出口池没有通过连接预检的候选;未发送业务请求,也未隐式直连');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function dispatcherFor(selected) {
|
|
195
|
+
const id = selected.nodeId || 'direct';
|
|
196
|
+
if (!dispatchers.has(id)) {
|
|
197
|
+
const pending = library().then(({ Agent, ProxyAgent }) => {
|
|
198
|
+
const node = selected.nodeId && nodes.get(selected.nodeId);
|
|
199
|
+
return node ? new ProxyAgent({ uri: nodeUri(node), token: node.token, requestTls: tls, proxyTls,
|
|
200
|
+
connectTimeout: preflightTimeoutMs, proxyTunnel: true }) : new Agent({ connect: tls, connectTimeout: preflightTimeoutMs });
|
|
201
|
+
});
|
|
202
|
+
dispatchers.set(id, pending);
|
|
203
|
+
void pending.catch(() => { if (dispatchers.get(id) === pending) dispatchers.delete(id); });
|
|
204
|
+
}
|
|
205
|
+
return dispatchers.get(id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const base = createProviderNetworkClient({
|
|
209
|
+
async fetchPort(input, options = {}) {
|
|
210
|
+
const state = scope.getStore();
|
|
211
|
+
if (!state) throw networkPolicyError('IRIS_NETWORK_OPERATION_REQUIRED', '显式网络客户端必须在完整操作内调用');
|
|
212
|
+
if (state.context.taskId && typeof state.context.recordNetworkRoute !== 'function') {
|
|
213
|
+
throw failure(state.context, 'IRIS_NETWORK_EVIDENCE_REQUIRED', 'Task 出站需要 Core 提供路线持久化端口,已停止派发', 'local_io');
|
|
214
|
+
}
|
|
215
|
+
if (input instanceof Request && options.signal === undefined) options = { ...options, signal: input.signal };
|
|
216
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
217
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw failure(state.context, 'IRIS_NETWORK_TARGET_INVALID', '出站目标需要不含认证信息的 HTTP(S) 地址', 'invalid_request');
|
|
218
|
+
state.selection ||= choose(url, options, state.context);
|
|
219
|
+
const selected = await state.selection;
|
|
220
|
+
const dispatcher = selected.route === 'inherit' ? undefined : await dispatcherFor(selected);
|
|
221
|
+
const fact = { ...requirement, configGeneration: config.generation, configRevision: saved.revision, providerId,
|
|
222
|
+
operation: state.context.operation || 'discover', ...selected, ...(state.context.attemptId ? { attemptId: state.context.attemptId } : {}) };
|
|
223
|
+
if (state.context.recordNetworkRoute) {
|
|
224
|
+
try { await state.context.recordNetworkRoute(fact); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw failure(state.context, /^IRIS_(?:TASK|NETWORK)_/.test(error.code || '') ? error.code : 'IRIS_NETWORK_ROUTE_WRITE_FAILED',
|
|
227
|
+
error.code === 'IRIS_NETWORK_ROUTE_LIMIT' ? '原 Task 的路线事实已达限额,已停止派发并保留历史'
|
|
228
|
+
: '路线事实未能持久化,已停止出站;请检查存储和原 Task 身份', 'local_io');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (onRoute) {
|
|
232
|
+
try { await onRoute(fact, state.context); }
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!/^IRIS_(?:TASK|NETWORK)_/.test(error.code || '')) throw error;
|
|
235
|
+
throw failure(state.context, error.code, error.code === 'IRIS_NETWORK_ROUTE_LIMIT'
|
|
236
|
+
? '原 Task 的路线事实已达限额,已停止派发并保留历史'
|
|
237
|
+
: '路线事实未能持久化,已停止出站;请检查存储和原 Task 身份', 'local_io');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Once business dispatch starts, errors remain conservatively ambiguous.
|
|
241
|
+
// Neither the next proxy nor a direct connection may replay this request.
|
|
242
|
+
try {
|
|
243
|
+
if (!dispatcher) {
|
|
244
|
+
if (!scheduler) return await inheritedProviderFetch(input, options);
|
|
245
|
+
const { getGlobalDispatcher } = await library();
|
|
246
|
+
return await scheduledFetch(inheritedProviderFetch, getGlobalDispatcher(), input, options, { providerId, modelRef, ...state.context });
|
|
247
|
+
}
|
|
248
|
+
const transport = await library(), request = transportFetchInput(transport, input, options);
|
|
249
|
+
if (scheduler) return await scheduledFetch(transport.fetch, dispatcher, request.input, request.options, { providerId, modelRef, ...state.context });
|
|
250
|
+
return await transport.fetch(request.input, { ...request.options, dispatcher });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (typeof error.code === 'string' && error.code.startsWith('IRIS_SCHEDULER_')) throw error;
|
|
253
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
254
|
+
throw Object.assign(new TypeError('出站请求失败;出口已选定,未自动重放请求'), { code: 'IRIS_NETWORK_TRANSPORT', cause: error });
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
async close() {
|
|
258
|
+
const results = await Promise.allSettled([...dispatchers.values()].map(async value => (await value).close()));
|
|
259
|
+
if (results.some(value => value.status === 'rejected')) throw new Error('部分网络连接未能正常释放');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
const client = Object.freeze({ ...base, networkRequirement: requirement, providerId, ...(pool ? { poolId: pool.id } : {}),
|
|
263
|
+
run(action, context = {}) {
|
|
264
|
+
return base.run(fetch => scope.run({ context, selection: null }, () => action(fetch)));
|
|
265
|
+
} });
|
|
266
|
+
return remember(slot, key, client, true);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function dispose() {
|
|
270
|
+
if (!disposal) {
|
|
271
|
+
closing = true;
|
|
272
|
+
disposal = (async () => {
|
|
273
|
+
const results = await Promise.allSettled([
|
|
274
|
+
...[...clients.values()].map(entry => entry.client.dispose()), ...retiring
|
|
275
|
+
]);
|
|
276
|
+
clients.clear();
|
|
277
|
+
if (cleanupFailed || results.some(value => value.status === 'rejected')) throw new Error('网络管理实例释放失败');
|
|
278
|
+
})();
|
|
279
|
+
}
|
|
280
|
+
return disposal;
|
|
281
|
+
}
|
|
282
|
+
return Object.freeze({ resolveProviderNetwork, dispose, scheduler });
|
|
283
|
+
}
|