@opensoha/contracts 0.1.25 → 0.1.27

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.
Files changed (200) hide show
  1. package/COMPATIBILITY.md +34 -0
  2. package/README.md +1 -1
  3. package/agent/environment/export.go +1 -0
  4. package/agent/environment/protocol_test.go +1 -1
  5. package/agent/guest/protocol_test.go +2 -2
  6. package/auth/catalog_generated.go +2 -2
  7. package/auth/permission-catalog.json +481 -1
  8. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  9. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  10. package/delivery/document.schema.json +127 -9
  11. package/endpointcontrol/decode.go +64 -0
  12. package/endpointcontrol/models.go +138 -0
  13. package/endpointcontrol/models_test.go +51 -0
  14. package/endpointcontrol/protocol.schema.json +397 -0
  15. package/examples/relations_compat_test.go +18 -0
  16. package/fixtures/json-schema/endpoint-controls/invalid/controls.json +16 -0
  17. package/fixtures/json-schema/endpoint-controls/invalid/missing-channel-decision.json +15 -0
  18. package/fixtures/json-schema/endpoint-controls/invalid/receipt-content.json +36 -0
  19. package/fixtures/json-schema/endpoint-controls/valid/controls.json +16 -0
  20. package/fixtures/json-schema/endpoint-controls/valid/receipt.json +35 -0
  21. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-activate-bad-fingerprint.json +14 -0
  22. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-gateway-request.json +14 -0
  23. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-private-key-request.json +15 -0
  24. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-profile-without-eap-credential.json +22 -0
  25. package/fixtures/json-schema/network-runtime-protocol/invalid/endpoint-dns-observation.json +25 -0
  26. package/fixtures/json-schema/network-runtime-protocol/invalid/gateway-presence.json +22 -0
  27. package/fixtures/json-schema/network-runtime-protocol/invalid/mihomo-protection-zero-version.json +45 -0
  28. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-raw-key.json +23 -0
  29. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-zero-sequence.json +22 -0
  30. package/fixtures/json-schema/network-runtime-protocol/invalid/workload-wide-vpn.json +17 -0
  31. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-activate.json +14 -0
  32. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-request.json +14 -0
  33. package/fixtures/json-schema/network-runtime-protocol/valid/eap-profile-enrollment.json +24 -0
  34. package/fixtures/json-schema/network-runtime-protocol/valid/endpoint-presence.json +22 -0
  35. package/fixtures/json-schema/network-runtime-protocol/valid/ipv6-configuration.json +102 -0
  36. package/fixtures/json-schema/network-runtime-protocol/valid/mihomo-protected-policy.json +45 -0
  37. package/fixtures/json-schema/network-runtime-protocol/valid/site-dns-ipv6.json +25 -0
  38. package/fixtures/json-schema/network-runtime-protocol/valid/workload-direct-ztna.json +20 -0
  39. package/fixtures/openapi/DLPShadowDecision/invalid/step-up-type.json +14 -0
  40. package/fixtures/openapi/DLPShadowDecision/valid/archive-v2.json +14 -0
  41. package/fixtures/openapi/DLPShadowDecision/valid/step-up.json +14 -0
  42. package/fixtures/openapi/DLPShadowInput/invalid/self-reported-result.json +7 -0
  43. package/fixtures/openapi/DLPShadowInput/invalid/too-many-samples.json +14 -0
  44. package/fixtures/openapi/DLPShadowInput/valid/bounded.json +7 -0
  45. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
  46. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
  47. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
  48. package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
  49. package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
  50. package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
  51. package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
  52. package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
  53. package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
  54. package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
  55. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
  56. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
  57. package/fixtures/openapi/DockerVolumeDLPAppealInput/invalid/p4-response.json +3 -0
  58. package/fixtures/openapi/DockerVolumeDLPAppealInput/valid/p4-response.json +3 -0
  59. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/invalid/p4-response.json +5 -0
  60. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/valid/p4-response.json +5 -0
  61. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/invalid/p4-response.json +3 -0
  62. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/valid/p4-response.json +3 -0
  63. package/fixtures/openapi/DockerVolumeDLPContent/invalid/archive-unsupported-channel.json +9 -0
  64. package/fixtures/openapi/DockerVolumeDLPContent/invalid/p4.json +6 -0
  65. package/fixtures/openapi/DockerVolumeDLPContent/invalid/step-up-audit.json +9 -0
  66. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unavailable-engine.json +9 -0
  67. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unknown-data-level.json +12 -0
  68. package/fixtures/openapi/DockerVolumeDLPContent/valid/classified-rule.json +16 -0
  69. package/fixtures/openapi/DockerVolumeDLPContent/valid/p4.json +8 -0
  70. package/fixtures/openapi/DockerVolumeDLPContent/valid/step-up.json +9 -0
  71. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/p4.json +9 -0
  72. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/step-up-type.json +12 -0
  73. package/fixtures/openapi/DockerVolumeDLPDecision/valid/archive-v2.json +12 -0
  74. package/fixtures/openapi/DockerVolumeDLPDecision/valid/p4.json +11 -0
  75. package/fixtures/openapi/DockerVolumeDLPDecision/valid/step-up.json +12 -0
  76. package/fixtures/openapi/DockerVolumeDLPSaveInput/invalid/p4.json +11 -0
  77. package/fixtures/openapi/DockerVolumeDLPSaveInput/valid/p4.json +11 -0
  78. package/fixtures/openapi/DockerVolumeFileReadInput/invalid/p4-response.json +5 -0
  79. package/fixtures/openapi/DockerVolumeFileReadInput/valid/p4-response.json +4 -0
  80. package/fixtures/openapi/EndpointDeviceInput/invalid/reported-management.json +5 -0
  81. package/fixtures/openapi/EndpointDeviceInput/valid/management-state.json +5 -0
  82. package/fixtures/openapi/EndpointDeviceRegistrationInput/invalid/self-promote-management.json +5 -0
  83. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
  84. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
  85. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
  86. package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
  87. package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
  88. package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
  89. package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
  90. package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
  91. package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
  92. package/fixtures/openapi/ManagedContentPasteInput/invalid/boundary.json +5 -0
  93. package/fixtures/openapi/ManagedContentPasteInput/valid/boundary.json +6 -0
  94. package/fixtures/openapi/ManagedContentPolicy/invalid/self-reported-allow.json +8 -0
  95. package/fixtures/openapi/ManagedContentPolicy/invalid/step-up-audit.json +8 -0
  96. package/fixtures/openapi/ManagedContentPolicy/invalid/unknown-extractor.json +8 -0
  97. package/fixtures/openapi/ManagedContentPolicy/valid/archive-v2.json +8 -0
  98. package/fixtures/openapi/ManagedContentPolicy/valid/network-and-level.json +10 -0
  99. package/fixtures/openapi/ManagedContentPolicy/valid/step-up.json +8 -0
  100. package/fixtures/openapi/ManagedContentReviewInput/invalid/boundary.json +5 -0
  101. package/fixtures/openapi/ManagedContentReviewInput/valid/boundary.json +5 -0
  102. package/fixtures/openapi/ManagedContentSaveInput/invalid/boundary.json +14 -0
  103. package/fixtures/openapi/ManagedContentSaveInput/valid/boundary.json +16 -0
  104. package/fixtures/openapi/ManagedContentUploadInput/invalid/boundary.json +7 -0
  105. package/fixtures/openapi/ManagedContentUploadInput/valid/boundary.json +6 -0
  106. package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
  107. package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
  108. package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
  109. package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
  110. package/fixtures/openapi/NetworkAccessPolicyInput/invalid/unknown-management-state.json +35 -0
  111. package/fixtures/openapi/NetworkAccessPolicyInput/valid/managed-device.json +35 -0
  112. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/deviceId-characters.json +4 -0
  113. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/nasBindingId-characters.json +4 -0
  114. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/subject-override.json +1 -0
  115. package/fixtures/openapi/NetworkAdmissionPreviewRequest/valid/device-nas.json +1 -0
  116. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-attributeVersion.json +13 -0
  117. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-evaluatedAt.json +13 -0
  118. package/fixtures/openapi/NetworkAdmissionPreviewResult/valid/versioned.json +14 -0
  119. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/missing-runtime-ranges.json +1 -0
  120. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/unknown-source-type.json +9 -0
  121. package/fixtures/openapi/NetworkConflictAnalysisRequest/valid/empty-runtime-ranges.json +3 -0
  122. package/fixtures/openapi/NetworkConflictAnalysisRequest/{invalid → valid}/ipv6-range.json +1 -1
  123. package/fixtures/openapi/NetworkEAPServerTrust/invalid/no-server-validation.json +5 -0
  124. package/fixtures/openapi/NetworkEAPServerTrust/valid/named-server.json +9 -0
  125. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/all-sites-implicit.json +45 -0
  126. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/unbounded-stages.json +54 -0
  127. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/zero-heartbeat.json +47 -0
  128. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/valid/staged-protection.json +47 -0
  129. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/invalid/missing-reason.json +3 -0
  130. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/valid/expected-current.json +4 -0
  131. package/fixtures/openapi/NetworkPolicyPreviewResult/invalid/attribute-version.json +1 -0
  132. package/fixtures/openapi/NetworkPolicyPreviewResult/valid/admission-version.json +1 -0
  133. package/fixtures/openapi/NetworkPolicySubjects/invalid/duplicate-role.json +1 -0
  134. package/fixtures/openapi/NetworkPolicySubjects/invalid/role-characters.json +5 -0
  135. package/fixtures/openapi/NetworkPolicySubjects/valid/role-bound.json +1 -0
  136. package/fixtures/openapi/NetworkProxyConfigurationInput/invalid/missing-content.json +1 -0
  137. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/bad-binding-id.json +9 -0
  138. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/empty-content.json +1 -0
  139. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/binding-only.json +11 -0
  140. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/clear-binding-disabled.json +5 -0
  141. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/enabled.json +1 -0
  142. package/fixtures/openapi/NetworkSiteInput/invalid/unknown-governance.json +6 -0
  143. package/fixtures/openapi/NetworkSiteInput/valid/restricted-unmanaged.json +6 -0
  144. package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
  145. package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
  146. package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
  147. package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
  148. package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
  149. package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
  150. package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
  151. package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
  152. package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
  153. package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
  154. package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
  155. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
  156. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
  157. package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
  158. package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
  159. package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
  160. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
  161. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
  162. package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
  163. package/gen/go/sohaapi/client.go +1 -1
  164. package/gen/go/sohaapi/client_test.go +2 -2
  165. package/gen/go/sohaapi/import_source_compat_test.go +12 -0
  166. package/gen/go/sohaapi/proxy_configuration_test.go +38 -0
  167. package/gen/go/sohaapi/types.go +3760 -345
  168. package/gen/ts/sohaapi/index.ts +5573 -181
  169. package/go.mod +2 -2
  170. package/helmrelease/delivery.go +4 -1
  171. package/helmrelease/initialization.go +10 -0
  172. package/helmrelease/runtime/delivery.go +40 -10
  173. package/helmrelease/runtime/delivery_test.go +4 -0
  174. package/helmrelease/runtime/initialization.go +186 -0
  175. package/helmrelease/runtime/initialization_test.go +203 -0
  176. package/network/README.md +34 -0
  177. package/network/network-radius-accounting.schema.json +4 -1
  178. package/network/network-runtime-protocol.schema.json +2166 -317
  179. package/network/proxy-runtime.schema.json +482 -67
  180. package/openapi/network_control_test.go +64 -0
  181. package/openapi/soha-api.json +53524 -43459
  182. package/openapi/soha-api.yaml +26431 -20491
  183. package/openapi/spec_generated.go +3 -3
  184. package/openapi/spec_test.go +42 -0
  185. package/package.json +6 -3
  186. package/proxytransport/handler.go +271 -0
  187. package/proxytransport/probe.go +30 -0
  188. package/proxytransport/scope.go +89 -0
  189. package/proxytransport/transport_test.go +60 -0
  190. package/resource/models.go +2 -0
  191. package/resource/relations.go +225 -0
  192. package/resource/relations_test.go +47 -0
  193. package/resource/root_cr.go +38 -0
  194. package/resource/runtime/argocd_integration_test.go +1 -1
  195. package/resource/runtime/initialization.go +66 -0
  196. package/resource/runtime/initialization_test.go +80 -0
  197. package/resource/runtime/relations.go +161 -0
  198. package/resource/runtime/relations_test.go +100 -0
  199. package/resource/runtime/root_cr.go +79 -0
  200. package/resource/runtime/root_cr_test.go +118 -0
@@ -5,9 +5,9 @@ package openapi
5
5
  import _ "embed"
6
6
 
7
7
  const (
8
- Version = "0.1.25"
9
- YAMLSHA256 = "5f72e3a41fbf5edf22773a9b85d2841c87c99a0636ad704065b9b1333414bd02"
10
- JSONSHA256 = "60341d8b2940687e94a3ed239363761e8e50cf36824fda11f344ba6363a17c04"
8
+ Version = "0.1.27"
9
+ YAMLSHA256 = "7ec8930963123f5b79dff6dfaddf7200dd0fce51ada6212d47f415d4e7539727"
10
+ JSONSHA256 = "6c1d5c0d4871c5ff6ea82241a02ff8ee4147792c6026a474a9d10a3ef3ca051b"
11
11
  )
12
12
 
13
13
  //go:embed soha-api.yaml
@@ -70,3 +70,45 @@ func TestTopologySuccessResponsePreservesDescription(t *testing.T) {
70
70
  t.Fatalf("topology response description was truncated: %q", description)
71
71
  }
72
72
  }
73
+
74
+ func TestPodWebSocketMetadataPreservesHTTPRouteSurface(t *testing.T) {
75
+ var document struct {
76
+ Paths map[string]json.RawMessage `json:"paths"`
77
+ Streams map[string]struct {
78
+ Get struct {
79
+ Permissions struct {
80
+ Keys []string `json:"keys"`
81
+ } `json:"x-soha-permissions"`
82
+ Parameters []struct {
83
+ Name string `json:"name"`
84
+ Schema struct {
85
+ Type string `json:"type"`
86
+ MinLength int `json:"minLength"`
87
+ } `json:"schema"`
88
+ } `json:"parameters"`
89
+ } `json:"get"`
90
+ } `json:"x-soha-websocket-paths"`
91
+ }
92
+ if err := json.Unmarshal(JSON(), &document); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ for suffix, permission := range map[string]string{"logs/stream": "platform.pods.logs", "terminal": "platform.pods.exec"} {
96
+ path := "/clusters/{clusterID}/workloads/pods/{podName}/" + suffix
97
+ if _, exists := document.Paths[path]; exists {
98
+ t.Fatalf("WebSocket target entered the HTTP client route surface: %s", path)
99
+ }
100
+ stream, exists := document.Streams[path]
101
+ if !exists || len(stream.Get.Permissions.Keys) != 1 || stream.Get.Permissions.Keys[0] != permission {
102
+ t.Fatalf("missing WebSocket target permission: %s", path)
103
+ }
104
+ uidQuery := false
105
+ for _, parameter := range stream.Get.Parameters {
106
+ if parameter.Name == "expectedUid" && parameter.Schema.Type == "string" && parameter.Schema.MinLength == 1 {
107
+ uidQuery = true
108
+ }
109
+ }
110
+ if !uidQuery {
111
+ t.Fatalf("missing non-empty expectedUid query metadata: %s", path)
112
+ }
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensoha/contracts",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "description": "Shared OpenSoha API, agent, MCP, skill, event, and auth contracts.",
6
6
  "license": "Apache-2.0",
@@ -105,7 +105,8 @@
105
105
  "./agent/personal-guest-runtime-result.schema.json": "./agent/personal-guest-runtime-result.schema.json",
106
106
  "./agent/personal-guest-tools.schema.json": "./agent/personal-guest-tools.schema.json",
107
107
  "./agent/personal-browser-network-request.schema.json": "./agent/personal-browser-network-request.schema.json",
108
- "./agent/personal-browser-network-response.schema.json": "./agent/personal-browser-network-response.schema.json"
108
+ "./agent/personal-browser-network-response.schema.json": "./agent/personal-browser-network-response.schema.json",
109
+ "./endpointcontrol/protocol.schema.json": "./endpointcontrol/protocol.schema.json"
109
110
  },
110
111
  "files": [
111
112
  "delivery",
@@ -136,7 +137,9 @@
136
137
  "connectors",
137
138
  "network",
138
139
  "knowledge",
139
- "memory"
140
+ "memory",
141
+ "proxytransport",
142
+ "endpointcontrol"
140
143
  ],
141
144
  "devDependencies": {
142
145
  "ajv": "^8.20.0",
@@ -0,0 +1,271 @@
1
+ // Package proxytransport supplies the HTTP CONNECT transport shared by network
2
+ // consumers. Callers own listener lifecycle, destination policy and SOCKS routing.
3
+ package proxytransport
4
+
5
+ import (
6
+ "context"
7
+ "crypto/sha256"
8
+ "crypto/subtle"
9
+ "errors"
10
+ "io"
11
+ "net"
12
+ "net/http"
13
+ "strconv"
14
+ "strings"
15
+ "sync"
16
+ "time"
17
+ )
18
+
19
+ // Dial receives the authenticated transport name and original destination. A
20
+ // consumer must resolve, authorize and pin an IP before dialing its backend.
21
+ type Dial func(context.Context, string, string) (net.Conn, error)
22
+
23
+ type Handler struct {
24
+ mu sync.Mutex
25
+ credentials map[string][32]byte
26
+ dial Dial
27
+ deadline time.Time
28
+ closed bool
29
+ connections map[net.Conn]struct{}
30
+ }
31
+
32
+ func New(credentials map[string]string, dial Dial, deadline time.Time) (*Handler, error) {
33
+ if len(credentials) == 0 || len(credentials) > 2048 || dial == nil || !deadline.After(time.Now()) || deadline.After(time.Now().Add(5*time.Minute)) {
34
+ return nil, errors.New("invalid proxy transport dependencies or lease")
35
+ }
36
+ h := &Handler{credentials: map[string][32]byte{}, dial: dial, deadline: deadline.UTC(), connections: map[net.Conn]struct{}{}}
37
+ for user, password := range credentials {
38
+ if user == "" || len(user) > 128 || len(password) < 32 {
39
+ return nil, errors.New("invalid proxy transport credential")
40
+ }
41
+ h.credentials[user] = sha256.Sum256([]byte(password))
42
+ }
43
+ return h, nil
44
+ }
45
+
46
+ func (h *Handler) Refresh(deadline time.Time) error {
47
+ h.mu.Lock()
48
+ defer h.mu.Unlock()
49
+ if h.closed || !deadline.After(time.Now()) || deadline.After(time.Now().Add(5*time.Minute)) {
50
+ return errors.New("proxy transport lease is inactive")
51
+ }
52
+ deadline = deadline.UTC()
53
+ h.deadline = deadline
54
+ for conn := range h.connections {
55
+ if err := conn.SetDeadline(deadline); err != nil {
56
+ return err
57
+ }
58
+ }
59
+ return nil
60
+ }
61
+
62
+ func (h *Handler) Close() error {
63
+ h.mu.Lock()
64
+ defer h.mu.Unlock()
65
+ if h.closed {
66
+ return nil
67
+ }
68
+ h.closed = true
69
+ var result error
70
+ for conn := range h.connections {
71
+ if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
72
+ result = errors.Join(result, err)
73
+ }
74
+ }
75
+ return result
76
+ }
77
+
78
+ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79
+ user, ok := h.authenticate(r)
80
+ if !ok {
81
+ w.Header().Set("Proxy-Authenticate", `Basic realm="Soha managed transport"`)
82
+ http.Error(w, "proxy authentication required", http.StatusProxyAuthRequired)
83
+ return
84
+ }
85
+ if r.Method == http.MethodConnect {
86
+ h.connect(w, r, user)
87
+ return
88
+ }
89
+ if r.URL.Scheme != "http" || r.URL.Host == "" || r.Host != r.URL.Host || r.URL.User != nil || r.Header.Get("Upgrade") != "" {
90
+ http.Error(w, "unsupported proxy request", http.StatusBadRequest)
91
+ return
92
+ }
93
+ h.forward(w, r, user)
94
+ }
95
+
96
+ func (h *Handler) authenticate(r *http.Request) (string, bool) {
97
+ values := r.Header.Values("Proxy-Authorization")
98
+ if len(values) != 1 {
99
+ return "", false
100
+ }
101
+ copy := r.Clone(r.Context())
102
+ copy.Header = make(http.Header)
103
+ copy.Header.Set("Authorization", values[0])
104
+ user, password, ok := copy.BasicAuth()
105
+ if !ok {
106
+ return "", false
107
+ }
108
+ expected, exists := h.credentials[user]
109
+ actual := sha256.Sum256([]byte(password))
110
+ return user, exists && subtle.ConstantTimeCompare(expected[:], actual[:]) == 1
111
+ }
112
+
113
+ func (h *Handler) open(ctx context.Context, user, address string) (net.Conn, error) {
114
+ host, rawPort, err := net.SplitHostPort(address)
115
+ if err != nil || host == "" || len(host) > 253 {
116
+ return nil, errors.New("invalid proxy target")
117
+ }
118
+ port, err := strconv.Atoi(rawPort)
119
+ if err != nil || port < 1 || port > 65535 {
120
+ return nil, errors.New("invalid proxy target port")
121
+ }
122
+ h.mu.Lock()
123
+ until, closed := h.deadline, h.closed
124
+ h.mu.Unlock()
125
+ if closed || !until.After(time.Now()) {
126
+ return nil, errors.New("proxy transport lease expired")
127
+ }
128
+ ctx, cancel := context.WithDeadline(ctx, until)
129
+ defer cancel()
130
+ conn, err := h.dial(ctx, user, net.JoinHostPort(host, rawPort))
131
+ if err != nil {
132
+ return nil, err
133
+ }
134
+ if err := conn.SetDeadline(until); err != nil {
135
+ _ = conn.Close()
136
+ return nil, err
137
+ }
138
+ h.mu.Lock()
139
+ defer h.mu.Unlock()
140
+ if h.closed || !h.deadline.After(time.Now()) || len(h.connections) >= 4096 {
141
+ _ = conn.Close()
142
+ return nil, errors.New("proxy transport was closed or reached its limit")
143
+ }
144
+ if err := conn.SetDeadline(h.deadline); err != nil {
145
+ _ = conn.Close()
146
+ return nil, err
147
+ }
148
+ h.connections[conn] = struct{}{}
149
+ return &trackedConn{Conn: conn, handler: h}, nil
150
+ }
151
+
152
+ func (h *Handler) Ready() bool {
153
+ h.mu.Lock()
154
+ defer h.mu.Unlock()
155
+ return !h.closed && h.deadline.After(time.Now())
156
+ }
157
+
158
+ type trackedConn struct {
159
+ net.Conn
160
+ handler *Handler
161
+ }
162
+
163
+ func (c *trackedConn) Close() error {
164
+ c.handler.mu.Lock()
165
+ delete(c.handler.connections, c.Conn)
166
+ c.handler.mu.Unlock()
167
+ return c.Conn.Close()
168
+ }
169
+
170
+ func (h *Handler) connect(w http.ResponseWriter, r *http.Request, user string) {
171
+ if r.URL.Host != "" && r.URL.Host != r.Host || r.URL.User != nil {
172
+ http.Error(w, "ambiguous proxy target", http.StatusBadRequest)
173
+ return
174
+ }
175
+ upstream, err := h.open(r.Context(), user, r.Host)
176
+ if err != nil {
177
+ http.Error(w, "proxy target unavailable", http.StatusBadGateway)
178
+ return
179
+ }
180
+ defer func() { _ = upstream.Close() }()
181
+ hijacker, ok := w.(http.Hijacker)
182
+ if !ok {
183
+ http.Error(w, "CONNECT unavailable", http.StatusInternalServerError)
184
+ return
185
+ }
186
+ client, buffer, err := hijacker.Hijack()
187
+ if err != nil {
188
+ return
189
+ }
190
+ defer func() { _ = client.Close() }()
191
+ h.mu.Lock()
192
+ closed := h.closed || !h.deadline.After(time.Now()) || len(h.connections) >= 4096
193
+ if !closed {
194
+ if err := client.SetDeadline(h.deadline); err != nil {
195
+ closed = true
196
+ } else {
197
+ h.connections[client] = struct{}{}
198
+ }
199
+ }
200
+ h.mu.Unlock()
201
+ defer func() { h.mu.Lock(); delete(h.connections, client); h.mu.Unlock() }()
202
+ if closed {
203
+ return
204
+ }
205
+
206
+ if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
207
+ return
208
+ }
209
+ if buffer.Flush() != nil {
210
+ return
211
+ }
212
+ done := make(chan struct{})
213
+ go func() { defer close(done); _, _ = io.Copy(upstream, buffer); _ = upstream.Close() }()
214
+ _, _ = io.Copy(client, upstream)
215
+ _ = client.Close()
216
+ _ = upstream.Close()
217
+ <-done
218
+ }
219
+
220
+ func (h *Handler) forward(w http.ResponseWriter, r *http.Request, user string) {
221
+ h.mu.Lock()
222
+ until, closed := h.deadline, h.closed
223
+ h.mu.Unlock()
224
+ if closed || !until.After(time.Now()) {
225
+ http.Error(w, "proxy lease expired", http.StatusServiceUnavailable)
226
+ return
227
+ }
228
+ control := http.NewResponseController(w)
229
+ if control.SetReadDeadline(until) != nil || control.SetWriteDeadline(until) != nil {
230
+ http.Error(w, "bounded proxy transport unavailable", http.StatusServiceUnavailable)
231
+ return
232
+ }
233
+ ctx, cancel := context.WithDeadline(r.Context(), until)
234
+ defer cancel()
235
+ request := r.Clone(ctx)
236
+ request.RequestURI = ""
237
+ request.Header = request.Header.Clone()
238
+ removeHopHeaders(request.Header)
239
+ transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
240
+ if network != "tcp" {
241
+ return nil, errors.New("unsupported proxy transport")
242
+ }
243
+ return h.open(ctx, user, address)
244
+ }, DisableKeepAlives: true, ResponseHeaderTimeout: 30 * time.Second}
245
+ defer transport.CloseIdleConnections()
246
+ response, err := transport.RoundTrip(request)
247
+ if err != nil {
248
+ http.Error(w, "proxy target unavailable", http.StatusBadGateway)
249
+ return
250
+ }
251
+ defer func() { _ = response.Body.Close() }()
252
+ removeHopHeaders(response.Header)
253
+ for name, values := range response.Header {
254
+ for _, value := range values {
255
+ w.Header().Add(name, value)
256
+ }
257
+ }
258
+ w.WriteHeader(response.StatusCode)
259
+ _, _ = io.Copy(w, response.Body)
260
+ }
261
+
262
+ func removeHopHeaders(header http.Header) {
263
+ for _, line := range header.Values("Connection") {
264
+ for _, name := range strings.Split(line, ",") {
265
+ header.Del(strings.TrimSpace(name))
266
+ }
267
+ }
268
+ for _, name := range []string{"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "TE", "Trailer", "Transfer-Encoding", "Upgrade"} {
269
+ header.Del(name)
270
+ }
271
+ }
@@ -0,0 +1,30 @@
1
+ package proxytransport
2
+
3
+ import (
4
+ "bufio"
5
+ "io"
6
+ "net"
7
+ "net/http"
8
+ "time"
9
+ )
10
+
11
+ // ProbeConnection returns a bounded, local HTTP responder used only after a
12
+ // consumer has matched its unpredictable, private health-probe hostname.
13
+ // It never opens a network socket or forwards data to a real destination.
14
+ func ProbeConnection() net.Conn {
15
+ client, server := net.Pipe()
16
+ go func() {
17
+ defer func() { _ = server.Close() }()
18
+ _ = server.SetDeadline(time.Now().Add(2 * time.Second))
19
+ request, err := http.ReadRequest(bufio.NewReader(io.LimitReader(server, 16<<10)))
20
+ if err != nil {
21
+ return
22
+ }
23
+ _ = request.Body.Close()
24
+ if request.Method != http.MethodGet {
25
+ return
26
+ }
27
+ _, _ = io.WriteString(server, "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n")
28
+ }()
29
+ return client
30
+ }
@@ -0,0 +1,89 @@
1
+ package proxytransport
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net"
7
+ "net/netip"
8
+ "strings"
9
+ )
10
+
11
+ type Scope struct {
12
+ prefixes []netip.Prefix
13
+ hosts []string
14
+ }
15
+
16
+ func NewScope(cidrs, hosts []string) (*Scope, error) {
17
+ if len(cidrs) > 4096 || len(hosts) > 2048 {
18
+ return nil, errors.New("proxy destination scope exceeds its limit")
19
+ }
20
+ scope := &Scope{hosts: append([]string(nil), hosts...)}
21
+ for _, raw := range cidrs {
22
+ prefix, err := netip.ParsePrefix(raw)
23
+ if err != nil || !prefix.Addr().IsGlobalUnicast() || prefix.Addr().Is4In6() || prefix.Bits() == 0 || prefix != prefix.Masked() || prefix.String() != raw {
24
+ return nil, errors.New("invalid proxy destination prefix")
25
+ }
26
+ scope.prefixes = append(scope.prefixes, prefix)
27
+ }
28
+ for _, host := range hosts {
29
+ if !validHost(host) || strings.ToLower(host) != host {
30
+ return nil, errors.New("invalid protected hostname")
31
+ }
32
+ }
33
+ return scope, nil
34
+ }
35
+
36
+ // Resolve checks every answer before choosing one. Returning a literal IP to a
37
+ // transport prevents a second, remote DNS result from changing the destination.
38
+ func (s *Scope) Resolve(ctx context.Context, host string) (netip.Addr, error) {
39
+ host = strings.ToLower(strings.TrimSuffix(host, "."))
40
+ for _, protected := range s.hosts {
41
+ if host == protected || strings.HasSuffix(host, "."+protected) {
42
+ return netip.Addr{}, errors.New("protected proxy hostname")
43
+ }
44
+ }
45
+ addresses := []netip.Addr{}
46
+ if address, err := netip.ParseAddr(host); err == nil {
47
+ addresses = append(addresses, address)
48
+ } else {
49
+ if !validHost(host) {
50
+ return netip.Addr{}, errors.New("invalid proxy hostname")
51
+ }
52
+ var err error
53
+ addresses, err = net.DefaultResolver.LookupNetIP(ctx, "ip", host)
54
+ if err != nil {
55
+ return netip.Addr{}, errors.New("proxy DNS unavailable")
56
+ }
57
+ }
58
+ if len(addresses) == 0 || len(addresses) > 32 {
59
+ return netip.Addr{}, errors.New("invalid proxy DNS answer count")
60
+ }
61
+ for _, address := range addresses {
62
+ if !address.IsGlobalUnicast() || address.Is4In6() {
63
+ return netip.Addr{}, errors.New("unsafe proxy address")
64
+ }
65
+ for _, prefix := range s.prefixes {
66
+ if prefix.Contains(address) {
67
+ return netip.Addr{}, errors.New("protected proxy address")
68
+ }
69
+ }
70
+ }
71
+ return addresses[0], nil
72
+ }
73
+
74
+ func validHost(host string) bool {
75
+ if len(host) == 0 || len(host) > 253 {
76
+ return false
77
+ }
78
+ for _, label := range strings.Split(host, ".") {
79
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
80
+ return false
81
+ }
82
+ for _, char := range label {
83
+ if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
84
+ return false
85
+ }
86
+ }
87
+ }
88
+ return true
89
+ }
@@ -0,0 +1,60 @@
1
+ package proxytransport
2
+
3
+ import (
4
+ "context"
5
+ "net"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+ "time"
10
+ )
11
+
12
+ func TestScopeBlocksProtectedIPv4AndIPv6AndRetainsUnprotectedPrivateUpstreams(t *testing.T) {
13
+ scope, err := NewScope([]string{"10.20.0.0/16", "fd20::/64"}, []string{"payroll.example.internal"})
14
+ if err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ for _, host := range []string{"10.20.1.1", "fd20::10", "payroll.example.internal", "alias.payroll.example.internal", "127.0.0.1", "::1"} {
18
+ if _, err := scope.Resolve(context.Background(), host); err == nil {
19
+ t.Fatalf("unsafe destination %s accepted", host)
20
+ }
21
+ }
22
+ for _, host := range []string{"10.30.1.1", "fd30::10"} {
23
+ if _, err := scope.Resolve(context.Background(), host); err != nil {
24
+ t.Fatalf("unprotected enterprise upstream %s rejected: %v", host, err)
25
+ }
26
+ }
27
+ }
28
+ func TestGuardRejectsAmbiguousHTTPAuthorityBeforeDial(t *testing.T) {
29
+ dialed := false
30
+ handler, err := New(map[string]string{"node": "0123456789abcdef0123456789abcdef"}, func(context.Context, string, string) (net.Conn, error) { dialed = true; return nil, nil }, time.Now().Add(time.Minute))
31
+ if err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ defer func() { _ = handler.Close() }()
35
+ request := httptest.NewRequest(http.MethodGet, "http://public.example/", nil)
36
+ request.Host = "protected.internal"
37
+ request.SetBasicAuth("node", "0123456789abcdef0123456789abcdef")
38
+ request.Header.Set("Proxy-Authorization", request.Header.Get("Authorization"))
39
+ request.Header.Del("Authorization")
40
+ response := httptest.NewRecorder()
41
+ handler.ServeHTTP(response, request)
42
+ if response.Code != http.StatusBadRequest || dialed {
43
+ t.Fatalf("ambiguous request status=%d dialed=%t", response.Code, dialed)
44
+ }
45
+ }
46
+ func TestGuardRejectsExpiredRenewalAndCannotReopenAfterClose(t *testing.T) {
47
+ handler, err := New(map[string]string{"node": "0123456789abcdef0123456789abcdef"}, func(context.Context, string, string) (net.Conn, error) { return nil, nil }, time.Now().Add(time.Minute))
48
+ if err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ if handler.Refresh(time.Now().Add(-time.Second)) == nil {
52
+ t.Fatal("expired renewal accepted")
53
+ }
54
+ if err := handler.Close(); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ if handler.Ready() || handler.Refresh(time.Now().Add(time.Minute)) == nil {
58
+ t.Fatal("closed transport resurrected")
59
+ }
60
+ }
@@ -104,6 +104,7 @@ type NamespaceUpsertInput struct {
104
104
  }
105
105
 
106
106
  type PodView struct {
107
+ UID string `json:"uid,omitempty"`
107
108
  Name string `json:"name"`
108
109
  Namespace string `json:"namespace"`
109
110
  Phase string `json:"phase"`
@@ -240,6 +241,7 @@ type NodeResourceSummaryView struct {
240
241
  }
241
242
 
242
243
  type PodDetailView struct {
244
+ UID string `json:"uid,omitempty"`
243
245
  Name string `json:"name"`
244
246
  Namespace string `json:"namespace"`
245
247
  Phase string `json:"phase"`