cartethyia 2.5.3 → 2.5.6
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/.env.example +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
- package/dist/dashboard/donation/qris-risun-marke.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +57 -57
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +88 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/update.ts +40 -0
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +11 -7
- package/src/boot/dependencies.ts +50 -19
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +52 -7
- package/src/config/telemetry.ts +26 -0
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +100 -0
- package/src/console/data-management/operations.ts +234 -0
- package/src/console/data-management/routes.ts +103 -0
- package/src/console/domain-registration.ts +45 -1
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +1138 -1018
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +24 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +1553 -1532
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +95 -2
- package/src/console/settings/store.ts +81 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +317 -0
- package/src/observability/event-day-index.ts +440 -0
- package/src/observability/event-log.ts +155 -6
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +158 -33
- package/src/observability/payload-store.ts +82 -61
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +75 -29
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +33 -11
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +139 -6
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +18 -7
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +4 -4
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +27 -20
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
- package/src/providers/integrations/cursor/cursor-request.ts +616 -471
- package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
- package/src/providers/integrations/cursor/cursor.ts +1333 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok-quota.ts +279 -177
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +19 -20
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +75 -27
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +10 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +27 -17
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +24 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +19 -6
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +9 -8
- package/src/transport/request/state.ts +21 -8
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +65 -10
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/command/update.ts +0 -23
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -1,478 +1,478 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pool egress agents: the HTTP CONNECT pair, the SOCKS5 pair, and the
|
|
3
|
-
* per-connect SSRF revalidation they share. `PoolAgentResolver` (resolver.ts)
|
|
4
|
-
* caches instances of these; the selector owns admission.
|
|
5
|
-
*/
|
|
6
|
-
import { isIP } from "node:net";
|
|
7
|
-
import { accountSocketBytes, accountSocketWrites, recordPoolBytes } from "./byte-accounting";
|
|
8
|
-
import {
|
|
9
|
-
Agent as HttpAgent,
|
|
10
|
-
request as httpRequest,
|
|
11
|
-
type ClientRequestArgs,
|
|
12
|
-
type IncomingMessage,
|
|
13
|
-
type RequestOptions,
|
|
14
|
-
} from "node:http";
|
|
15
|
-
import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
16
|
-
import * as tls from "node:tls";
|
|
17
|
-
import type { Duplex } from "node:stream";
|
|
18
|
-
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
19
|
-
import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
|
|
20
|
-
import type { SsrfPolicy } from "../../config/network";
|
|
21
|
-
import { resolveProxyMaxFreeSockets, resolveProxyMaxSockets } from "../../config/network";
|
|
22
|
-
import type { AgentConfig } from "../types";
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Idle keep-alive socket timeout for every pool agent. Matches the 60s DB pool
|
|
26
|
-
* idle window so long-idle resources in one process reap together.
|
|
27
|
-
*/
|
|
28
|
-
const PROXY_KEEP_ALIVE_TIMEOUT_MS = 60_000;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The transport a network pool dials outbound traffic through. Every kind is
|
|
32
|
-
* dialled directly by the resolver; no pool spawns a child process.
|
|
33
|
-
*
|
|
34
|
-
* `http` and `https` differ only in which flavor answers a `https:` target, so a
|
|
35
|
-
* hosted relay front door is recorded as the protocol it actually speaks. A
|
|
36
|
-
* serverless host that cannot answer CONNECT at all (see `isRelayHost`) is
|
|
37
|
-
* dialled through the `x-relay-target` contract instead, which
|
|
38
|
-
* `createHttpProxyAgent` selects from the hostname.
|
|
39
|
-
*/
|
|
40
|
-
export const TRANSPORT_KINDS = ["http", "https", "socks5"] as const;
|
|
41
|
-
|
|
42
|
-
/** One outbound transport kind. */
|
|
43
|
-
export type TransportKind = (typeof TRANSPORT_KINDS)[number];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** Config keys reserved for endpoint/label; every other key is opaque non-secret transport config. */
|
|
47
|
-
export const RESERVED_ENDPOINT_CONFIG_KEYS: Record<string, true> = {
|
|
48
|
-
endpoint: true,
|
|
49
|
-
label: true,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export function splitEndpointConfig(config: Record<string, unknown>): {
|
|
53
|
-
endpoint?: string;
|
|
54
|
-
label?: string;
|
|
55
|
-
rest: Record<string, unknown>;
|
|
56
|
-
} {
|
|
57
|
-
const rest: Record<string, unknown> = {};
|
|
58
|
-
for (const [key, value] of Object.entries(config)) {
|
|
59
|
-
if (!RESERVED_ENDPOINT_CONFIG_KEYS[key]) rest[key] = value;
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}),
|
|
63
|
-
...(typeof config.label === "string" ? { label: config.label } : {}),
|
|
64
|
-
rest,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Derives the application-level `https` distinction the DB enum collapses into `http`. */
|
|
69
|
-
export function deriveKind(dbKind: string, endpoint: string): TransportKind {
|
|
70
|
-
if (dbKind === "http") return endpoint.startsWith("https:") ? "https" : "http";
|
|
71
|
-
return dbKind as TransportKind;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Pool agents — single egress
|
|
75
|
-
export type PoolAgent = HttpAgent | HttpsAgent | ProxyAgentPair;
|
|
76
|
-
|
|
77
|
-
class InvalidPoolAgentConfigError extends Error {}
|
|
78
|
-
|
|
79
|
-
function withCredential(url: URL, credential: string | undefined): URL {
|
|
80
|
-
if (!credential) return url;
|
|
81
|
-
const separator = credential.indexOf(":");
|
|
82
|
-
const username = separator === -1 ? credential : credential.slice(0, separator);
|
|
83
|
-
const password = separator === -1 ? "" : credential.slice(separator + 1);
|
|
84
|
-
url.username = encodeURIComponent(username);
|
|
85
|
-
url.password = encodeURIComponent(password);
|
|
86
|
-
return url;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Hosted relay front doors expose an application HTTP endpoint rather than
|
|
91
|
-
* an RFC CONNECT proxy. Keep this classification beside the pool factory so
|
|
92
|
-
* the dispatch and health-check paths cannot accidentally tunnel them with
|
|
93
|
-
* CONNECT. This mirrors the supported relay-host policy in Cartethyia 21.
|
|
94
|
-
*/
|
|
95
|
-
function isRelayHost(hostname: string): boolean {
|
|
96
|
-
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
97
|
-
return (
|
|
98
|
-
normalized.endsWith(".vercel.app") ||
|
|
99
|
-
normalized.endsWith(".workers.dev") ||
|
|
100
|
-
normalized.endsWith(".netlify.app") ||
|
|
101
|
-
// Deno Deploy project URLs. `*.deno.dev` is the project hostname Deno
|
|
102
|
-
// Deploy assigns, so a deployed relay answers there exactly like a
|
|
103
|
-
// Vercel/Workers front door.
|
|
104
|
-
normalized.endsWith(".deno.dev")
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Per-connect DNS lookup that validates every resolved address of the dialed
|
|
110
|
-
* host against the SSRF policy, pinning the validated address for the socket.
|
|
111
|
-
* Shared by the HTTP CONNECT handshake and the SOCKS5 proxy socket: both dial
|
|
112
|
-
* a *proxy* host that was validated at create-time, so a DNS-rebind of that
|
|
113
|
-
* record must not be able to swap it to a private/link-local address later.
|
|
114
|
-
*/
|
|
115
|
-
function createSsrfLookup(policy: SsrfPolicy): NonNullable<RequestOptions["lookup"]> {
|
|
116
|
-
return ((
|
|
117
|
-
hostname: string,
|
|
118
|
-
lookupOptions: { all?: boolean; family?: number } | number,
|
|
119
|
-
cb: (
|
|
120
|
-
error: Error | null,
|
|
121
|
-
address?: string | Array<{ address: string; family: number }>,
|
|
122
|
-
family?: number,
|
|
123
|
-
) => void,
|
|
124
|
-
): void => {
|
|
125
|
-
const abort = new AbortController();
|
|
126
|
-
const timer = setTimeout(() => abort.abort(), 5_000);
|
|
127
|
-
void resolveAllAddresses(hostname, abort.signal)
|
|
128
|
-
.then((addrs) => {
|
|
129
|
-
try {
|
|
130
|
-
validateResolvedAddresses(addrs, policy);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
cb(error as Error);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const first = addrs[0];
|
|
136
|
-
if (!first) {
|
|
137
|
-
cb(new Error(`no addresses resolved for ${hostname}`));
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
const family = first.includes(":") ? 6 : 4;
|
|
141
|
-
const opts = typeof lookupOptions === "number" ? { family: lookupOptions } : lookupOptions;
|
|
142
|
-
if (opts?.all) {
|
|
143
|
-
cb(null, addrs.map((address) => ({ address, family: address.includes(":") ? 6 : 4 })));
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
cb(null, first, family);
|
|
147
|
-
})
|
|
148
|
-
.catch((error: unknown) => cb(error as Error))
|
|
149
|
-
.finally(() => clearTimeout(timer));
|
|
150
|
-
}) as unknown as NonNullable<RequestOptions["lookup"]>;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* A proxy answered the CONNECT handshake with a non-200 status instead of
|
|
155
|
-
* establishing the tunnel. Typed so the caller can read the status without
|
|
156
|
-
* re-parsing text: the health machine treats 402/407 as "reachable but
|
|
157
|
-
* unusable", and a relay pool treats the whole class as "this front door
|
|
158
|
-
* does not do CONNECT, use the relay form".
|
|
159
|
-
*/
|
|
160
|
-
export class ProxyConnectRefusalError extends Error {
|
|
161
|
-
constructor(
|
|
162
|
-
readonly statusCode: number,
|
|
163
|
-
statusMessage?: string,
|
|
164
|
-
) {
|
|
165
|
-
super(`Proxy CONNECT failed: ${statusCode}${statusMessage ? ` ${statusMessage}` : ""}`);
|
|
166
|
-
this.name = "ProxyConnectRefusalError";
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* A CONNECT handshake that never reached "established" for a reason other than
|
|
172
|
-
* an explicit status refusal — the socket was reset, the connection refused, or
|
|
173
|
-
* the peer closed before answering.
|
|
174
|
-
*
|
|
175
|
-
* Tagged at the handshake on purpose. The distinction that matters to a caller
|
|
176
|
-
* is *when* the failure happened: this class means "the front door could not be
|
|
177
|
-
* reached as a tunnel", which is the signal to fall back to the relay form. A
|
|
178
|
-
* failure that happens *inside* an established tunnel — the target's own TLS
|
|
179
|
-
* handshake failing, say — is a different error from a different code path and
|
|
180
|
-
* must not be retried elsewhere, so it is deliberately not this class.
|
|
181
|
-
*/
|
|
182
|
-
export class ProxyConnectHandshakeError extends Error {
|
|
183
|
-
constructor(cause: Error) {
|
|
184
|
-
super(`Proxy CONNECT handshake failed: ${cause.message}`);
|
|
185
|
-
this.name = "ProxyConnectHandshakeError";
|
|
186
|
-
this.cause = cause;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* True when a CONNECT attempt failed at the handshake, so a relay pool
|
|
192
|
-
* should retry through the relay form. Only the two handshake classes qualify:
|
|
193
|
-
* a `ProxyConnectRefusalError` (the front door answered "no CONNECT") and a
|
|
194
|
-
* `ProxyConnectHandshakeError` (the front door reset or refused the socket). A
|
|
195
|
-
* caller cancellation and any error raised after the tunnel was established are
|
|
196
|
-
* never retried.
|
|
197
|
-
*/
|
|
198
|
-
/**
|
|
199
|
-
* Shared HTTP CONNECT tunnel establishment (proxy handshake + SSRF-pinned
|
|
200
|
-
* proxy DNS). Used by both scheme flavors below; the returned socket is the
|
|
201
|
-
* raw CONNECT tunnel — node's http/https layer performs the *target* TLS
|
|
202
|
-
* handshake itself over it.
|
|
203
|
-
*/
|
|
204
|
-
function createProxyConnection(
|
|
205
|
-
proxy: URL,
|
|
206
|
-
policy: SsrfPolicy,
|
|
207
|
-
options: ClientRequestArgs,
|
|
208
|
-
callback?: (error: Error | null, stream: Duplex) => void,
|
|
209
|
-
poolId?: string,
|
|
210
|
-
): Duplex | null | undefined {
|
|
211
|
-
const targetHost = options.hostname ?? options.host ?? "";
|
|
212
|
-
const targetPort = options.port ?? 443;
|
|
213
|
-
const isHttpsProxy = proxy.protocol === "https:";
|
|
214
|
-
const fail = (error: Error): void => {
|
|
215
|
-
callback?.(error, undefined as unknown as Duplex);
|
|
216
|
-
};
|
|
217
|
-
const lookup = createSsrfLookup(policy);
|
|
218
|
-
const proxyOptions: ClientRequestArgs = {
|
|
219
|
-
hostname: proxy.hostname,
|
|
220
|
-
port: proxy.port ? Number(proxy.port) : isHttpsProxy ? 443 : 80,
|
|
221
|
-
method: "CONNECT",
|
|
222
|
-
path: `${targetHost}:${targetPort}`,
|
|
223
|
-
headers: { host: `${targetHost}:${targetPort}` },
|
|
224
|
-
agent: false,
|
|
225
|
-
lookup,
|
|
226
|
-
// SNI is mandatory for name-based TLS front doors (Vercel serves a
|
|
227
|
-
// fallback cert without it → altnames mismatch). Skipped for literal
|
|
228
|
-
// IPs, which TLS rejects as a servername.
|
|
229
|
-
...(isHttpsProxy && !isIP(proxy.hostname) ? { servername: proxy.hostname } : {}),
|
|
230
|
-
};
|
|
231
|
-
if (proxy.username || proxy.password) {
|
|
232
|
-
const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
|
|
233
|
-
proxyOptions.headers = {
|
|
234
|
-
...proxyOptions.headers,
|
|
235
|
-
"proxy-authorization": `Basic ${Buffer.from(auth).toString("base64")}`,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
const proxyRequest = (isHttpsProxy ? httpsRequest : httpRequest)(proxyOptions);
|
|
239
|
-
// The CONNECT exchange itself costs bytes on this pool's egress. It is tens
|
|
240
|
-
// of bytes per tunnel, so it barely moves a quota on its own — but a pool
|
|
241
|
-
// that churns tunnels without ever establishing one spends real traffic and
|
|
242
|
-
//
|
|
243
|
-
if (poolId) {
|
|
244
|
-
const requestLine = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n`;
|
|
245
|
-
const headerText = Object.entries(proxyOptions.headers ?? {})
|
|
246
|
-
.map(([name, value]) => `${name}: ${String(value)}\r\n`)
|
|
247
|
-
.join("");
|
|
248
|
-
recordPoolBytes(poolId, "sent", Buffer.byteLength(requestLine + headerText + "\r\n", "utf8"));
|
|
249
|
-
}
|
|
250
|
-
// Every failure before the tunnel is established — the socket reset, the
|
|
251
|
-
// connection refused, the peer closing early, the CONNECT request itself
|
|
252
|
-
// erroring — is a handshake failure and is tagged as such, so a relay
|
|
253
|
-
// pool can tell "this front door does not tunnel" from an error raised
|
|
254
|
-
// later, inside a tunnel that did open.
|
|
255
|
-
let established = false;
|
|
256
|
-
const failHandshake = (error: Error): void => {
|
|
257
|
-
if (established) {
|
|
258
|
-
fail(error);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
fail(new ProxyConnectHandshakeError(error));
|
|
262
|
-
};
|
|
263
|
-
proxyRequest.on(
|
|
264
|
-
"connect",
|
|
265
|
-
(response: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
266
|
-
if (response.statusCode !== 200) {
|
|
267
|
-
fail(new ProxyConnectRefusalError(response.statusCode ?? 502, response.statusMessage));
|
|
268
|
-
socket.destroy();
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
established = true;
|
|
272
|
-
// `head` is whatever the peer had already sent by the time CONNECT
|
|
273
|
-
// completed. Unshifting it replays those bytes into the stream, but a
|
|
274
|
-
// `data` listener attached afterwards never sees them — so they were
|
|
275
|
-
// silently missing from the pool's metered totals.
|
|
276
|
-
if (head.length > 0) {
|
|
277
|
-
if (poolId) recordPoolBytes(poolId, "received", head.length);
|
|
278
|
-
socket.unshift(head);
|
|
279
|
-
}
|
|
280
|
-
// Tally the tunnel from here on: `write` on this socket is the TLS
|
|
281
|
-
// record stream, so what is counted is what actually went on the wire.
|
|
282
|
-
if (poolId) {
|
|
283
|
-
accountSocketBytes(socket, poolId);
|
|
284
|
-
accountSocketWrites(socket, poolId);
|
|
285
|
-
}
|
|
286
|
-
callback?.(null, socket);
|
|
287
|
-
},
|
|
288
|
-
);
|
|
289
|
-
proxyRequest.on("error", failHandshake);
|
|
290
|
-
proxyRequest.end();
|
|
291
|
-
return undefined;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* One pool's dual-scheme egress. Node validates `agent.protocol` per
|
|
296
|
-
* request (`https.request` rejects an `http:` agent with `Protocol "https:"
|
|
297
|
-
* not supported`), so a single Agent class cannot serve both target
|
|
298
|
-
* schemes — the pair carries one flavor per scheme around one shared
|
|
299
|
-
* CONNECT implementation.
|
|
300
|
-
*/
|
|
301
|
-
export interface ProxyAgentPair {
|
|
302
|
-
/** Flavor for `http:` targets (protocol `http:`). */
|
|
303
|
-
readonly http: HttpAgent;
|
|
304
|
-
/** Flavor for `https:` targets (protocol `https:`). */
|
|
305
|
-
readonly https: HttpsAgent;
|
|
306
|
-
/**
|
|
307
|
-
* Relay endpoint for hosted HTTP front doors (Vercel/Workers/Netlify).
|
|
308
|
-
* These endpoints are application relays, not RFC CONNECT proxies; the
|
|
309
|
-
* validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
|
|
310
|
-
*/
|
|
311
|
-
readonly relayEndpoint?: URL;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
|
|
315
|
-
// Shape-checked (not instanceof): the socks pair holds two
|
|
316
|
-
// SocksProxyAgent flavors, which are never HttpsAgent subclasses — what
|
|
317
|
-
// matters is one agent per target scheme behind a shared CONNECT path.
|
|
318
|
-
if (!value || typeof value !== "object") return false;
|
|
319
|
-
const pair = value as Record<string, unknown>;
|
|
320
|
-
const http = pair.http as { createConnection?: unknown } | undefined;
|
|
321
|
-
const https = pair.https as { createConnection?: unknown } | undefined;
|
|
322
|
-
return (
|
|
323
|
-
typeof http?.createConnection === "function" &&
|
|
324
|
-
typeof https?.createConnection === "function"
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
class HttpProxyAgent extends HttpAgent {
|
|
329
|
-
constructor(
|
|
330
|
-
readonly proxy: URL,
|
|
331
|
-
/** SSRF policy applied to the *proxy* hostname on every CONNECT so a
|
|
332
|
-
* DNS-rebind of the proxy record cannot swap it to a private/link-local
|
|
333
|
-
* address after the pool was validated at create-time. */
|
|
334
|
-
private readonly policy: SsrfPolicy = {},
|
|
335
|
-
config: AgentConfig = {},
|
|
336
|
-
private readonly poolId?: string,
|
|
337
|
-
) {
|
|
338
|
-
super({
|
|
339
|
-
keepAlive: true,
|
|
340
|
-
maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
|
|
341
|
-
maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
|
|
342
|
-
timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
override createConnection(
|
|
347
|
-
options: ClientRequestArgs,
|
|
348
|
-
callback?: (error: Error | null, stream: Duplex) => void,
|
|
349
|
-
): Duplex | null | undefined {
|
|
350
|
-
return createProxyConnection(this.proxy, this.policy, options, callback, this.poolId);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
class HttpsProxyAgent extends HttpsAgent {
|
|
355
|
-
constructor(
|
|
356
|
-
readonly proxy: URL,
|
|
357
|
-
private readonly policy: SsrfPolicy = {},
|
|
358
|
-
config: AgentConfig = {},
|
|
359
|
-
private readonly poolId?: string,
|
|
360
|
-
) {
|
|
361
|
-
super({
|
|
362
|
-
keepAlive: true,
|
|
363
|
-
maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
|
|
364
|
-
maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
|
|
365
|
-
timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
override createConnection(
|
|
369
|
-
options: ClientRequestArgs,
|
|
370
|
-
callback?: (error: Error | null, stream: Duplex) => void,
|
|
371
|
-
): Duplex | null | undefined {
|
|
372
|
-
return createProxyConnection(this.proxy, this.policy, options, (err, rawSocket) => {
|
|
373
|
-
if (err || !rawSocket) {
|
|
374
|
-
callback?.(err, rawSocket);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
const targetHost = options.hostname ?? options.host ?? "";
|
|
378
|
-
const tlsSocket = tls.connect({
|
|
379
|
-
socket: rawSocket,
|
|
380
|
-
servername: isIP(targetHost) ? undefined : targetHost,
|
|
381
|
-
rejectUnauthorized: this.options.rejectUnauthorized !== false,
|
|
382
|
-
});
|
|
383
|
-
tlsSocket.once("secureConnect", () => {
|
|
384
|
-
callback?.(null, tlsSocket);
|
|
385
|
-
});
|
|
386
|
-
tlsSocket.once("error", (tlsErr) => {
|
|
387
|
-
rawSocket.destroy();
|
|
388
|
-
callback?.(tlsErr, undefined as unknown as Duplex);
|
|
389
|
-
});
|
|
390
|
-
}, this.poolId);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
export function createHttpProxyAgent(
|
|
394
|
-
endpoint: string,
|
|
395
|
-
credential?: string,
|
|
396
|
-
policy: SsrfPolicy = {},
|
|
397
|
-
config: AgentConfig = {},
|
|
398
|
-
poolId?: string,
|
|
399
|
-
): ProxyAgentPair {
|
|
400
|
-
let url: URL;
|
|
401
|
-
try {
|
|
402
|
-
url = new URL(endpoint);
|
|
403
|
-
} catch {
|
|
404
|
-
throw new InvalidPoolAgentConfigError(`invalid HTTP proxy endpoint: ${endpoint}`);
|
|
405
|
-
}
|
|
406
|
-
const proxy = new URL(withCredential(url, credential).toString());
|
|
407
|
-
return {
|
|
408
|
-
http: new HttpProxyAgent(proxy, policy, config, poolId),
|
|
409
|
-
https: new HttpsProxyAgent(proxy, policy, config, poolId),
|
|
410
|
-
...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Tally a SOCKS5 agent's tunnel.
|
|
416
|
-
*
|
|
417
|
-
* The socks client owns socket creation, so this wraps `createConnection`
|
|
418
|
-
* instead of the CONNECT callback the HTTP path uses. The socket handed back is
|
|
419
|
-
* the raw tunnel (Node applies TLS afterwards for `https:` targets), which is
|
|
420
|
-
* exactly the layer whose bytes the proxy bills. The SOCKS negotiation itself
|
|
421
|
-
* is a handful of bytes exchanged before this point and is not counted.
|
|
422
|
-
*/
|
|
423
|
-
function accountSocksAgent(agent: SocksProxyAgent, poolId?: string): void {
|
|
424
|
-
if (!poolId) return;
|
|
425
|
-
const original = agent.createConnection.bind(agent) as (
|
|
426
|
-
options: ClientRequestArgs,
|
|
427
|
-
callback?: (error: Error | null, stream: Duplex) => void,
|
|
428
|
-
) => Duplex | null | undefined;
|
|
429
|
-
agent.createConnection = ((options: ClientRequestArgs, callback?: (error: Error | null, stream: Duplex) => void) =>
|
|
430
|
-
original(options, (error, socket) => {
|
|
431
|
-
if (socket) {
|
|
432
|
-
accountSocketBytes(socket, poolId);
|
|
433
|
-
accountSocketWrites(socket, poolId);
|
|
434
|
-
}
|
|
435
|
-
callback?.(error, socket);
|
|
436
|
-
})) as unknown as typeof agent.createConnection;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
export function createSocks5Agent(
|
|
440
|
-
endpoint: string,
|
|
441
|
-
credential?: string,
|
|
442
|
-
policy: SsrfPolicy = {},
|
|
443
|
-
config: AgentConfig = {},
|
|
444
|
-
poolId?: string,
|
|
445
|
-
): ProxyAgentPair {
|
|
446
|
-
const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
|
|
447
|
-
let url: URL;
|
|
448
|
-
try {
|
|
449
|
-
url = new URL(authority);
|
|
450
|
-
} catch {
|
|
451
|
-
throw new InvalidPoolAgentConfigError(`invalid SOCKS5 endpoint: ${endpoint}`);
|
|
452
|
-
}
|
|
453
|
-
// The proxy hostname is resolved by the socks client itself, so the policy
|
|
454
|
-
// is applied through the socket's `lookup`: every connect re-resolves and
|
|
455
|
-
// re-validates the proxy address instead of trusting the create-time check.
|
|
456
|
-
const options = {
|
|
457
|
-
socketOptions: { lookup: createSsrfLookup(policy) },
|
|
458
|
-
keepAlive: true,
|
|
459
|
-
maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
|
|
460
|
-
maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
|
|
461
|
-
timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
|
|
462
|
-
};
|
|
463
|
-
const http = new SocksProxyAgent(withCredential(url, credential), options);
|
|
464
|
-
accountSocksAgent(http, poolId);
|
|
465
|
-
// Same latent Node check as HTTP pools: an `http:`-protocol agent is
|
|
466
|
-
// rejected for `https:` targets, so the https flavor carries an
|
|
467
|
-
// overridden protocol (per-instance shadow — the shared SocksProxyAgent
|
|
468
|
-
// prototype is untouched). Behavior is identical; only the check reads it.
|
|
469
|
-
const https = new SocksProxyAgent(withCredential(url, credential), options);
|
|
470
|
-
https.protocol = "https:";
|
|
471
|
-
accountSocksAgent(https, poolId);
|
|
472
|
-
return {
|
|
473
|
-
http: http as unknown as HttpAgent,
|
|
474
|
-
https: https as unknown as HttpsAgent,
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
export class PoolBindingError extends Error {}
|
|
1
|
+
/**
|
|
2
|
+
* Pool egress agents: the HTTP CONNECT pair, the SOCKS5 pair, and the
|
|
3
|
+
* per-connect SSRF revalidation they share. `PoolAgentResolver` (resolver.ts)
|
|
4
|
+
* caches instances of these; the selector owns admission.
|
|
5
|
+
*/
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
|
+
import { accountSocketBytes, accountSocketWrites, recordPoolBytes } from "./byte-accounting";
|
|
8
|
+
import {
|
|
9
|
+
Agent as HttpAgent,
|
|
10
|
+
request as httpRequest,
|
|
11
|
+
type ClientRequestArgs,
|
|
12
|
+
type IncomingMessage,
|
|
13
|
+
type RequestOptions,
|
|
14
|
+
} from "node:http";
|
|
15
|
+
import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
16
|
+
import * as tls from "node:tls";
|
|
17
|
+
import type { Duplex } from "node:stream";
|
|
18
|
+
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
19
|
+
import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
|
|
20
|
+
import type { SsrfPolicy } from "../../config/network";
|
|
21
|
+
import { resolveProxyMaxFreeSockets, resolveProxyMaxSockets } from "../../config/network";
|
|
22
|
+
import type { AgentConfig } from "../types";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Idle keep-alive socket timeout for every pool agent. Matches the 60s DB pool
|
|
26
|
+
* idle window so long-idle resources in one process reap together.
|
|
27
|
+
*/
|
|
28
|
+
const PROXY_KEEP_ALIVE_TIMEOUT_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The transport a network pool dials outbound traffic through. Every kind is
|
|
32
|
+
* dialled directly by the resolver; no pool spawns a child process.
|
|
33
|
+
*
|
|
34
|
+
* `http` and `https` differ only in which flavor answers a `https:` target, so a
|
|
35
|
+
* hosted relay front door is recorded as the protocol it actually speaks. A
|
|
36
|
+
* serverless host that cannot answer CONNECT at all (see `isRelayHost`) is
|
|
37
|
+
* dialled through the `x-relay-target` contract instead, which
|
|
38
|
+
* `createHttpProxyAgent` selects from the hostname.
|
|
39
|
+
*/
|
|
40
|
+
export const TRANSPORT_KINDS = ["http", "https", "socks5"] as const;
|
|
41
|
+
|
|
42
|
+
/** One outbound transport kind. */
|
|
43
|
+
export type TransportKind = (typeof TRANSPORT_KINDS)[number];
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/** Config keys reserved for endpoint/label; every other key is opaque non-secret transport config. */
|
|
47
|
+
export const RESERVED_ENDPOINT_CONFIG_KEYS: Record<string, true> = {
|
|
48
|
+
endpoint: true,
|
|
49
|
+
label: true,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function splitEndpointConfig(config: Record<string, unknown>): {
|
|
53
|
+
endpoint?: string;
|
|
54
|
+
label?: string;
|
|
55
|
+
rest: Record<string, unknown>;
|
|
56
|
+
} {
|
|
57
|
+
const rest: Record<string, unknown> = {};
|
|
58
|
+
for (const [key, value] of Object.entries(config)) {
|
|
59
|
+
if (!RESERVED_ENDPOINT_CONFIG_KEYS[key]) rest[key] = value;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}),
|
|
63
|
+
...(typeof config.label === "string" ? { label: config.label } : {}),
|
|
64
|
+
rest,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Derives the application-level `https` distinction the DB enum collapses into `http`. */
|
|
69
|
+
export function deriveKind(dbKind: string, endpoint: string): TransportKind {
|
|
70
|
+
if (dbKind === "http") return endpoint.startsWith("https:") ? "https" : "http";
|
|
71
|
+
return dbKind as TransportKind;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pool agents — single egress
|
|
75
|
+
export type PoolAgent = HttpAgent | HttpsAgent | ProxyAgentPair;
|
|
76
|
+
|
|
77
|
+
class InvalidPoolAgentConfigError extends Error {}
|
|
78
|
+
|
|
79
|
+
function withCredential(url: URL, credential: string | undefined): URL {
|
|
80
|
+
if (!credential) return url;
|
|
81
|
+
const separator = credential.indexOf(":");
|
|
82
|
+
const username = separator === -1 ? credential : credential.slice(0, separator);
|
|
83
|
+
const password = separator === -1 ? "" : credential.slice(separator + 1);
|
|
84
|
+
url.username = encodeURIComponent(username);
|
|
85
|
+
url.password = encodeURIComponent(password);
|
|
86
|
+
return url;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hosted relay front doors expose an application HTTP endpoint rather than
|
|
91
|
+
* an RFC CONNECT proxy. Keep this classification beside the pool factory so
|
|
92
|
+
* the dispatch and health-check paths cannot accidentally tunnel them with
|
|
93
|
+
* CONNECT. This mirrors the supported relay-host policy in Cartethyia 21.
|
|
94
|
+
*/
|
|
95
|
+
function isRelayHost(hostname: string): boolean {
|
|
96
|
+
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
97
|
+
return (
|
|
98
|
+
normalized.endsWith(".vercel.app") ||
|
|
99
|
+
normalized.endsWith(".workers.dev") ||
|
|
100
|
+
normalized.endsWith(".netlify.app") ||
|
|
101
|
+
// Deno Deploy project URLs. `*.deno.dev` is the project hostname Deno
|
|
102
|
+
// Deploy assigns, so a deployed relay answers there exactly like a
|
|
103
|
+
// Vercel/Workers front door.
|
|
104
|
+
normalized.endsWith(".deno.dev")
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Per-connect DNS lookup that validates every resolved address of the dialed
|
|
110
|
+
* host against the SSRF policy, pinning the validated address for the socket.
|
|
111
|
+
* Shared by the HTTP CONNECT handshake and the SOCKS5 proxy socket: both dial
|
|
112
|
+
* a *proxy* host that was validated at create-time, so a DNS-rebind of that
|
|
113
|
+
* record must not be able to swap it to a private/link-local address later.
|
|
114
|
+
*/
|
|
115
|
+
function createSsrfLookup(policy: SsrfPolicy): NonNullable<RequestOptions["lookup"]> {
|
|
116
|
+
return ((
|
|
117
|
+
hostname: string,
|
|
118
|
+
lookupOptions: { all?: boolean; family?: number } | number,
|
|
119
|
+
cb: (
|
|
120
|
+
error: Error | null,
|
|
121
|
+
address?: string | Array<{ address: string; family: number }>,
|
|
122
|
+
family?: number,
|
|
123
|
+
) => void,
|
|
124
|
+
): void => {
|
|
125
|
+
const abort = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => abort.abort(), 5_000);
|
|
127
|
+
void resolveAllAddresses(hostname, abort.signal)
|
|
128
|
+
.then((addrs) => {
|
|
129
|
+
try {
|
|
130
|
+
validateResolvedAddresses(addrs, policy);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
cb(error as Error);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const first = addrs[0];
|
|
136
|
+
if (!first) {
|
|
137
|
+
cb(new Error(`no addresses resolved for ${hostname}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const family = first.includes(":") ? 6 : 4;
|
|
141
|
+
const opts = typeof lookupOptions === "number" ? { family: lookupOptions } : lookupOptions;
|
|
142
|
+
if (opts?.all) {
|
|
143
|
+
cb(null, addrs.map((address) => ({ address, family: address.includes(":") ? 6 : 4 })));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
cb(null, first, family);
|
|
147
|
+
})
|
|
148
|
+
.catch((error: unknown) => cb(error as Error))
|
|
149
|
+
.finally(() => clearTimeout(timer));
|
|
150
|
+
}) as unknown as NonNullable<RequestOptions["lookup"]>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A proxy answered the CONNECT handshake with a non-200 status instead of
|
|
155
|
+
* establishing the tunnel. Typed so the caller can read the status without
|
|
156
|
+
* re-parsing text: the health machine treats 402/407 as "reachable but
|
|
157
|
+
* unusable", and a relay pool treats the whole class as "this front door
|
|
158
|
+
* does not do CONNECT, use the relay form".
|
|
159
|
+
*/
|
|
160
|
+
export class ProxyConnectRefusalError extends Error {
|
|
161
|
+
constructor(
|
|
162
|
+
readonly statusCode: number,
|
|
163
|
+
statusMessage?: string,
|
|
164
|
+
) {
|
|
165
|
+
super(`Proxy CONNECT failed: ${statusCode}${statusMessage ? ` ${statusMessage}` : ""}`);
|
|
166
|
+
this.name = "ProxyConnectRefusalError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A CONNECT handshake that never reached "established" for a reason other than
|
|
172
|
+
* an explicit status refusal — the socket was reset, the connection refused, or
|
|
173
|
+
* the peer closed before answering.
|
|
174
|
+
*
|
|
175
|
+
* Tagged at the handshake on purpose. The distinction that matters to a caller
|
|
176
|
+
* is *when* the failure happened: this class means "the front door could not be
|
|
177
|
+
* reached as a tunnel", which is the signal to fall back to the relay form. A
|
|
178
|
+
* failure that happens *inside* an established tunnel — the target's own TLS
|
|
179
|
+
* handshake failing, say — is a different error from a different code path and
|
|
180
|
+
* must not be retried elsewhere, so it is deliberately not this class.
|
|
181
|
+
*/
|
|
182
|
+
export class ProxyConnectHandshakeError extends Error {
|
|
183
|
+
constructor(cause: Error) {
|
|
184
|
+
super(`Proxy CONNECT handshake failed: ${cause.message}`);
|
|
185
|
+
this.name = "ProxyConnectHandshakeError";
|
|
186
|
+
this.cause = cause;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* True when a CONNECT attempt failed at the handshake, so a relay pool
|
|
192
|
+
* should retry through the relay form. Only the two handshake classes qualify:
|
|
193
|
+
* a `ProxyConnectRefusalError` (the front door answered "no CONNECT") and a
|
|
194
|
+
* `ProxyConnectHandshakeError` (the front door reset or refused the socket). A
|
|
195
|
+
* caller cancellation and any error raised after the tunnel was established are
|
|
196
|
+
* never retried.
|
|
197
|
+
*/
|
|
198
|
+
/**
|
|
199
|
+
* Shared HTTP CONNECT tunnel establishment (proxy handshake + SSRF-pinned
|
|
200
|
+
* proxy DNS). Used by both scheme flavors below; the returned socket is the
|
|
201
|
+
* raw CONNECT tunnel — node's http/https layer performs the *target* TLS
|
|
202
|
+
* handshake itself over it.
|
|
203
|
+
*/
|
|
204
|
+
function createProxyConnection(
|
|
205
|
+
proxy: URL,
|
|
206
|
+
policy: SsrfPolicy,
|
|
207
|
+
options: ClientRequestArgs,
|
|
208
|
+
callback?: (error: Error | null, stream: Duplex) => void,
|
|
209
|
+
poolId?: string,
|
|
210
|
+
): Duplex | null | undefined {
|
|
211
|
+
const targetHost = options.hostname ?? options.host ?? "";
|
|
212
|
+
const targetPort = options.port ?? 443;
|
|
213
|
+
const isHttpsProxy = proxy.protocol === "https:";
|
|
214
|
+
const fail = (error: Error): void => {
|
|
215
|
+
callback?.(error, undefined as unknown as Duplex);
|
|
216
|
+
};
|
|
217
|
+
const lookup = createSsrfLookup(policy);
|
|
218
|
+
const proxyOptions: ClientRequestArgs = {
|
|
219
|
+
hostname: proxy.hostname,
|
|
220
|
+
port: proxy.port ? Number(proxy.port) : isHttpsProxy ? 443 : 80,
|
|
221
|
+
method: "CONNECT",
|
|
222
|
+
path: `${targetHost}:${targetPort}`,
|
|
223
|
+
headers: { host: `${targetHost}:${targetPort}` },
|
|
224
|
+
agent: false,
|
|
225
|
+
lookup,
|
|
226
|
+
// SNI is mandatory for name-based TLS front doors (Vercel serves a
|
|
227
|
+
// fallback cert without it → altnames mismatch). Skipped for literal
|
|
228
|
+
// IPs, which TLS rejects as a servername.
|
|
229
|
+
...(isHttpsProxy && !isIP(proxy.hostname) ? { servername: proxy.hostname } : {}),
|
|
230
|
+
};
|
|
231
|
+
if (proxy.username || proxy.password) {
|
|
232
|
+
const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
|
|
233
|
+
proxyOptions.headers = {
|
|
234
|
+
...proxyOptions.headers,
|
|
235
|
+
"proxy-authorization": `Basic ${Buffer.from(auth).toString("base64")}`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const proxyRequest = (isHttpsProxy ? httpsRequest : httpRequest)(proxyOptions);
|
|
239
|
+
// The CONNECT exchange itself costs bytes on this pool's egress. It is tens
|
|
240
|
+
// of bytes per tunnel, so it barely moves a quota on its own — but a pool
|
|
241
|
+
// that churns tunnels without ever establishing one spends real traffic and
|
|
242
|
+
// would otherwise meter as zero.
|
|
243
|
+
if (poolId) {
|
|
244
|
+
const requestLine = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n`;
|
|
245
|
+
const headerText = Object.entries(proxyOptions.headers ?? {})
|
|
246
|
+
.map(([name, value]) => `${name}: ${String(value)}\r\n`)
|
|
247
|
+
.join("");
|
|
248
|
+
recordPoolBytes(poolId, "sent", Buffer.byteLength(requestLine + headerText + "\r\n", "utf8"));
|
|
249
|
+
}
|
|
250
|
+
// Every failure before the tunnel is established — the socket reset, the
|
|
251
|
+
// connection refused, the peer closing early, the CONNECT request itself
|
|
252
|
+
// erroring — is a handshake failure and is tagged as such, so a relay
|
|
253
|
+
// pool can tell "this front door does not tunnel" from an error raised
|
|
254
|
+
// later, inside a tunnel that did open.
|
|
255
|
+
let established = false;
|
|
256
|
+
const failHandshake = (error: Error): void => {
|
|
257
|
+
if (established) {
|
|
258
|
+
fail(error);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
fail(new ProxyConnectHandshakeError(error));
|
|
262
|
+
};
|
|
263
|
+
proxyRequest.on(
|
|
264
|
+
"connect",
|
|
265
|
+
(response: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
266
|
+
if (response.statusCode !== 200) {
|
|
267
|
+
fail(new ProxyConnectRefusalError(response.statusCode ?? 502, response.statusMessage));
|
|
268
|
+
socket.destroy();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
established = true;
|
|
272
|
+
// `head` is whatever the peer had already sent by the time CONNECT
|
|
273
|
+
// completed. Unshifting it replays those bytes into the stream, but a
|
|
274
|
+
// `data` listener attached afterwards never sees them — so they were
|
|
275
|
+
// silently missing from the pool's metered totals.
|
|
276
|
+
if (head.length > 0) {
|
|
277
|
+
if (poolId) recordPoolBytes(poolId, "received", head.length);
|
|
278
|
+
socket.unshift(head);
|
|
279
|
+
}
|
|
280
|
+
// Tally the tunnel from here on: `write` on this socket is the TLS
|
|
281
|
+
// record stream, so what is counted is what actually went on the wire.
|
|
282
|
+
if (poolId) {
|
|
283
|
+
accountSocketBytes(socket, poolId);
|
|
284
|
+
accountSocketWrites(socket, poolId);
|
|
285
|
+
}
|
|
286
|
+
callback?.(null, socket);
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
proxyRequest.on("error", failHandshake);
|
|
290
|
+
proxyRequest.end();
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* One pool's dual-scheme egress. Node validates `agent.protocol` per
|
|
296
|
+
* request (`https.request` rejects an `http:` agent with `Protocol "https:"
|
|
297
|
+
* not supported`), so a single Agent class cannot serve both target
|
|
298
|
+
* schemes — the pair carries one flavor per scheme around one shared
|
|
299
|
+
* CONNECT implementation.
|
|
300
|
+
*/
|
|
301
|
+
export interface ProxyAgentPair {
|
|
302
|
+
/** Flavor for `http:` targets (protocol `http:`). */
|
|
303
|
+
readonly http: HttpAgent;
|
|
304
|
+
/** Flavor for `https:` targets (protocol `https:`). */
|
|
305
|
+
readonly https: HttpsAgent;
|
|
306
|
+
/**
|
|
307
|
+
* Relay endpoint for hosted HTTP front doors (Vercel/Workers/Netlify).
|
|
308
|
+
* These endpoints are application relays, not RFC CONNECT proxies; the
|
|
309
|
+
* validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
|
|
310
|
+
*/
|
|
311
|
+
readonly relayEndpoint?: URL;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
|
|
315
|
+
// Shape-checked (not instanceof): the socks pair holds two
|
|
316
|
+
// SocksProxyAgent flavors, which are never HttpsAgent subclasses — what
|
|
317
|
+
// matters is one agent per target scheme behind a shared CONNECT path.
|
|
318
|
+
if (!value || typeof value !== "object") return false;
|
|
319
|
+
const pair = value as Record<string, unknown>;
|
|
320
|
+
const http = pair.http as { createConnection?: unknown } | undefined;
|
|
321
|
+
const https = pair.https as { createConnection?: unknown } | undefined;
|
|
322
|
+
return (
|
|
323
|
+
typeof http?.createConnection === "function" &&
|
|
324
|
+
typeof https?.createConnection === "function"
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
class HttpProxyAgent extends HttpAgent {
|
|
329
|
+
constructor(
|
|
330
|
+
readonly proxy: URL,
|
|
331
|
+
/** SSRF policy applied to the *proxy* hostname on every CONNECT so a
|
|
332
|
+
* DNS-rebind of the proxy record cannot swap it to a private/link-local
|
|
333
|
+
* address after the pool was validated at create-time. */
|
|
334
|
+
private readonly policy: SsrfPolicy = {},
|
|
335
|
+
config: AgentConfig = {},
|
|
336
|
+
private readonly poolId?: string,
|
|
337
|
+
) {
|
|
338
|
+
super({
|
|
339
|
+
keepAlive: true,
|
|
340
|
+
maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
|
|
341
|
+
maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
|
|
342
|
+
timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
override createConnection(
|
|
347
|
+
options: ClientRequestArgs,
|
|
348
|
+
callback?: (error: Error | null, stream: Duplex) => void,
|
|
349
|
+
): Duplex | null | undefined {
|
|
350
|
+
return createProxyConnection(this.proxy, this.policy, options, callback, this.poolId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
class HttpsProxyAgent extends HttpsAgent {
|
|
355
|
+
constructor(
|
|
356
|
+
readonly proxy: URL,
|
|
357
|
+
private readonly policy: SsrfPolicy = {},
|
|
358
|
+
config: AgentConfig = {},
|
|
359
|
+
private readonly poolId?: string,
|
|
360
|
+
) {
|
|
361
|
+
super({
|
|
362
|
+
keepAlive: true,
|
|
363
|
+
maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
|
|
364
|
+
maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
|
|
365
|
+
timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
override createConnection(
|
|
369
|
+
options: ClientRequestArgs,
|
|
370
|
+
callback?: (error: Error | null, stream: Duplex) => void,
|
|
371
|
+
): Duplex | null | undefined {
|
|
372
|
+
return createProxyConnection(this.proxy, this.policy, options, (err, rawSocket) => {
|
|
373
|
+
if (err || !rawSocket) {
|
|
374
|
+
callback?.(err, rawSocket);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const targetHost = options.hostname ?? options.host ?? "";
|
|
378
|
+
const tlsSocket = tls.connect({
|
|
379
|
+
socket: rawSocket,
|
|
380
|
+
servername: isIP(targetHost) ? undefined : targetHost,
|
|
381
|
+
rejectUnauthorized: this.options.rejectUnauthorized !== false,
|
|
382
|
+
});
|
|
383
|
+
tlsSocket.once("secureConnect", () => {
|
|
384
|
+
callback?.(null, tlsSocket);
|
|
385
|
+
});
|
|
386
|
+
tlsSocket.once("error", (tlsErr) => {
|
|
387
|
+
rawSocket.destroy();
|
|
388
|
+
callback?.(tlsErr, undefined as unknown as Duplex);
|
|
389
|
+
});
|
|
390
|
+
}, this.poolId);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export function createHttpProxyAgent(
|
|
394
|
+
endpoint: string,
|
|
395
|
+
credential?: string,
|
|
396
|
+
policy: SsrfPolicy = {},
|
|
397
|
+
config: AgentConfig = {},
|
|
398
|
+
poolId?: string,
|
|
399
|
+
): ProxyAgentPair {
|
|
400
|
+
let url: URL;
|
|
401
|
+
try {
|
|
402
|
+
url = new URL(endpoint);
|
|
403
|
+
} catch {
|
|
404
|
+
throw new InvalidPoolAgentConfigError(`invalid HTTP proxy endpoint: ${endpoint}`);
|
|
405
|
+
}
|
|
406
|
+
const proxy = new URL(withCredential(url, credential).toString());
|
|
407
|
+
return {
|
|
408
|
+
http: new HttpProxyAgent(proxy, policy, config, poolId),
|
|
409
|
+
https: new HttpsProxyAgent(proxy, policy, config, poolId),
|
|
410
|
+
...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Tally a SOCKS5 agent's tunnel.
|
|
416
|
+
*
|
|
417
|
+
* The socks client owns socket creation, so this wraps `createConnection`
|
|
418
|
+
* instead of the CONNECT callback the HTTP path uses. The socket handed back is
|
|
419
|
+
* the raw tunnel (Node applies TLS afterwards for `https:` targets), which is
|
|
420
|
+
* exactly the layer whose bytes the proxy bills. The SOCKS negotiation itself
|
|
421
|
+
* is a handful of bytes exchanged before this point and is not counted.
|
|
422
|
+
*/
|
|
423
|
+
function accountSocksAgent(agent: SocksProxyAgent, poolId?: string): void {
|
|
424
|
+
if (!poolId) return;
|
|
425
|
+
const original = agent.createConnection.bind(agent) as (
|
|
426
|
+
options: ClientRequestArgs,
|
|
427
|
+
callback?: (error: Error | null, stream: Duplex) => void,
|
|
428
|
+
) => Duplex | null | undefined;
|
|
429
|
+
agent.createConnection = ((options: ClientRequestArgs, callback?: (error: Error | null, stream: Duplex) => void) =>
|
|
430
|
+
original(options, (error, socket) => {
|
|
431
|
+
if (socket) {
|
|
432
|
+
accountSocketBytes(socket, poolId);
|
|
433
|
+
accountSocketWrites(socket, poolId);
|
|
434
|
+
}
|
|
435
|
+
callback?.(error, socket);
|
|
436
|
+
})) as unknown as typeof agent.createConnection;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function createSocks5Agent(
|
|
440
|
+
endpoint: string,
|
|
441
|
+
credential?: string,
|
|
442
|
+
policy: SsrfPolicy = {},
|
|
443
|
+
config: AgentConfig = {},
|
|
444
|
+
poolId?: string,
|
|
445
|
+
): ProxyAgentPair {
|
|
446
|
+
const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
|
|
447
|
+
let url: URL;
|
|
448
|
+
try {
|
|
449
|
+
url = new URL(authority);
|
|
450
|
+
} catch {
|
|
451
|
+
throw new InvalidPoolAgentConfigError(`invalid SOCKS5 endpoint: ${endpoint}`);
|
|
452
|
+
}
|
|
453
|
+
// The proxy hostname is resolved by the socks client itself, so the policy
|
|
454
|
+
// is applied through the socket's `lookup`: every connect re-resolves and
|
|
455
|
+
// re-validates the proxy address instead of trusting the create-time check.
|
|
456
|
+
const options = {
|
|
457
|
+
socketOptions: { lookup: createSsrfLookup(policy) },
|
|
458
|
+
keepAlive: true,
|
|
459
|
+
maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
|
|
460
|
+
maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
|
|
461
|
+
timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
|
|
462
|
+
};
|
|
463
|
+
const http = new SocksProxyAgent(withCredential(url, credential), options);
|
|
464
|
+
accountSocksAgent(http, poolId);
|
|
465
|
+
// Same latent Node check as HTTP pools: an `http:`-protocol agent is
|
|
466
|
+
// rejected for `https:` targets, so the https flavor carries an
|
|
467
|
+
// overridden protocol (per-instance shadow — the shared SocksProxyAgent
|
|
468
|
+
// prototype is untouched). Behavior is identical; only the check reads it.
|
|
469
|
+
const https = new SocksProxyAgent(withCredential(url, credential), options);
|
|
470
|
+
https.protocol = "https:";
|
|
471
|
+
accountSocksAgent(https, poolId);
|
|
472
|
+
return {
|
|
473
|
+
http: http as unknown as HttpAgent,
|
|
474
|
+
https: https as unknown as HttpsAgent,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export class PoolBindingError extends Error {}
|