@opensoha/contracts 0.1.26 → 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.
- package/COMPATIBILITY.md +10 -0
- package/auth/catalog_generated.go +2 -2
- package/auth/permission-catalog.json +481 -1
- package/compat/json-schema-ecosystem-0.1.0.json +1 -1
- package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
- package/endpointcontrol/decode.go +64 -0
- package/endpointcontrol/models.go +138 -0
- package/endpointcontrol/models_test.go +51 -0
- package/endpointcontrol/protocol.schema.json +397 -0
- package/fixtures/json-schema/endpoint-controls/invalid/controls.json +16 -0
- package/fixtures/json-schema/endpoint-controls/invalid/missing-channel-decision.json +15 -0
- package/fixtures/json-schema/endpoint-controls/invalid/receipt-content.json +36 -0
- package/fixtures/json-schema/endpoint-controls/valid/controls.json +16 -0
- package/fixtures/json-schema/endpoint-controls/valid/receipt.json +35 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-activate-bad-fingerprint.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-gateway-request.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-private-key-request.json +15 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/eap-profile-without-eap-credential.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/endpoint-dns-observation.json +25 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/gateway-presence.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/mihomo-protection-zero-version.json +45 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/presence-raw-key.json +23 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/presence-zero-sequence.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/invalid/workload-wide-vpn.json +17 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-activate.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-request.json +14 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/eap-profile-enrollment.json +24 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/endpoint-presence.json +22 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/ipv6-configuration.json +102 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/mihomo-protected-policy.json +45 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/site-dns-ipv6.json +25 -0
- package/fixtures/json-schema/network-runtime-protocol/valid/workload-direct-ztna.json +20 -0
- package/fixtures/openapi/DLPShadowDecision/invalid/step-up-type.json +14 -0
- package/fixtures/openapi/DLPShadowDecision/valid/archive-v2.json +14 -0
- package/fixtures/openapi/DLPShadowDecision/valid/step-up.json +14 -0
- package/fixtures/openapi/DLPShadowInput/invalid/self-reported-result.json +7 -0
- package/fixtures/openapi/DLPShadowInput/invalid/too-many-samples.json +14 -0
- package/fixtures/openapi/DLPShadowInput/valid/bounded.json +7 -0
- package/fixtures/openapi/DockerVolumeDLPAppealInput/invalid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPAppealInput/valid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/invalid/p4-response.json +5 -0
- package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/valid/p4-response.json +5 -0
- package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/invalid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/valid/p4-response.json +3 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/archive-unsupported-channel.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/p4.json +6 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/step-up-audit.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/unavailable-engine.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPContent/invalid/unknown-data-level.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPContent/valid/classified-rule.json +16 -0
- package/fixtures/openapi/DockerVolumeDLPContent/valid/p4.json +8 -0
- package/fixtures/openapi/DockerVolumeDLPContent/valid/step-up.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/invalid/p4.json +9 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/invalid/step-up-type.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/valid/archive-v2.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/valid/p4.json +11 -0
- package/fixtures/openapi/DockerVolumeDLPDecision/valid/step-up.json +12 -0
- package/fixtures/openapi/DockerVolumeDLPSaveInput/invalid/p4.json +11 -0
- package/fixtures/openapi/DockerVolumeDLPSaveInput/valid/p4.json +11 -0
- package/fixtures/openapi/DockerVolumeFileReadInput/invalid/p4-response.json +5 -0
- package/fixtures/openapi/DockerVolumeFileReadInput/valid/p4-response.json +4 -0
- package/fixtures/openapi/EndpointDeviceInput/invalid/reported-management.json +5 -0
- package/fixtures/openapi/EndpointDeviceInput/valid/management-state.json +5 -0
- package/fixtures/openapi/EndpointDeviceRegistrationInput/invalid/self-promote-management.json +5 -0
- package/fixtures/openapi/ManagedContentPasteInput/invalid/boundary.json +5 -0
- package/fixtures/openapi/ManagedContentPasteInput/valid/boundary.json +6 -0
- package/fixtures/openapi/ManagedContentPolicy/invalid/self-reported-allow.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/invalid/step-up-audit.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/invalid/unknown-extractor.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/valid/archive-v2.json +8 -0
- package/fixtures/openapi/ManagedContentPolicy/valid/network-and-level.json +10 -0
- package/fixtures/openapi/ManagedContentPolicy/valid/step-up.json +8 -0
- package/fixtures/openapi/ManagedContentReviewInput/invalid/boundary.json +5 -0
- package/fixtures/openapi/ManagedContentReviewInput/valid/boundary.json +5 -0
- package/fixtures/openapi/ManagedContentSaveInput/invalid/boundary.json +14 -0
- package/fixtures/openapi/ManagedContentSaveInput/valid/boundary.json +16 -0
- package/fixtures/openapi/ManagedContentUploadInput/invalid/boundary.json +7 -0
- package/fixtures/openapi/ManagedContentUploadInput/valid/boundary.json +6 -0
- package/fixtures/openapi/NetworkAccessPolicyInput/invalid/unknown-management-state.json +35 -0
- package/fixtures/openapi/NetworkAccessPolicyInput/valid/managed-device.json +35 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/deviceId-characters.json +4 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/nasBindingId-characters.json +4 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/subject-override.json +1 -0
- package/fixtures/openapi/NetworkAdmissionPreviewRequest/valid/device-nas.json +1 -0
- package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-attributeVersion.json +13 -0
- package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-evaluatedAt.json +13 -0
- package/fixtures/openapi/NetworkAdmissionPreviewResult/valid/versioned.json +14 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/missing-runtime-ranges.json +1 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/unknown-source-type.json +9 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/valid/empty-runtime-ranges.json +3 -0
- package/fixtures/openapi/NetworkConflictAnalysisRequest/{invalid → valid}/ipv6-range.json +1 -1
- package/fixtures/openapi/NetworkEAPServerTrust/invalid/no-server-validation.json +5 -0
- package/fixtures/openapi/NetworkEAPServerTrust/valid/named-server.json +9 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/all-sites-implicit.json +45 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/unbounded-stages.json +54 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/zero-heartbeat.json +47 -0
- package/fixtures/openapi/NetworkEndpointResponsePolicyInput/valid/staged-protection.json +47 -0
- package/fixtures/openapi/NetworkEndpointResponseRestoreInput/invalid/missing-reason.json +3 -0
- package/fixtures/openapi/NetworkEndpointResponseRestoreInput/valid/expected-current.json +4 -0
- package/fixtures/openapi/NetworkPolicyPreviewResult/invalid/attribute-version.json +1 -0
- package/fixtures/openapi/NetworkPolicyPreviewResult/valid/admission-version.json +1 -0
- package/fixtures/openapi/NetworkPolicySubjects/invalid/duplicate-role.json +1 -0
- package/fixtures/openapi/NetworkPolicySubjects/invalid/role-characters.json +5 -0
- package/fixtures/openapi/NetworkPolicySubjects/valid/role-bound.json +1 -0
- package/fixtures/openapi/NetworkProxyConfigurationInput/invalid/missing-content.json +1 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/bad-binding-id.json +9 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/empty-content.json +1 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/binding-only.json +11 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/clear-binding-disabled.json +5 -0
- package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/enabled.json +1 -0
- package/fixtures/openapi/NetworkSiteInput/invalid/unknown-governance.json +6 -0
- package/fixtures/openapi/NetworkSiteInput/valid/restricted-unmanaged.json +6 -0
- package/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/proxy_configuration_test.go +38 -0
- package/gen/go/sohaapi/types.go +2825 -196
- package/gen/ts/sohaapi/index.ts +14533 -9883
- package/network/README.md +34 -0
- package/network/network-radius-accounting.schema.json +4 -1
- package/network/network-runtime-protocol.schema.json +2166 -317
- package/network/proxy-runtime.schema.json +482 -67
- package/openapi/network_control_test.go +64 -0
- package/openapi/soha-api.json +52989 -45064
- package/openapi/soha-api.yaml +26472 -20872
- package/openapi/spec_generated.go +3 -3
- package/package.json +6 -3
- package/proxytransport/handler.go +271 -0
- package/proxytransport/probe.go +30 -0
- package/proxytransport/scope.go +89 -0
- package/proxytransport/transport_test.go +60 -0
|
@@ -5,9 +5,9 @@ package openapi
|
|
|
5
5
|
import _ "embed"
|
|
6
6
|
|
|
7
7
|
const (
|
|
8
|
-
Version = "0.1.
|
|
9
|
-
YAMLSHA256 = "
|
|
10
|
-
JSONSHA256 = "
|
|
8
|
+
Version = "0.1.27"
|
|
9
|
+
YAMLSHA256 = "7ec8930963123f5b79dff6dfaddf7200dd0fce51ada6212d47f415d4e7539727"
|
|
10
|
+
JSONSHA256 = "6c1d5c0d4871c5ff6ea82241a02ff8ee4147792c6026a474a9d10a3ef3ca051b"
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
//go:embed soha-api.yaml
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensoha/contracts",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|