knoxcall 0.0.1 → 1.0.0
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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- metadata +70 -9
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "knoxcall/errors"
|
|
3
|
+
require "knoxcall/intercept_pipeline"
|
|
4
|
+
|
|
5
|
+
module KnoxCall
|
|
6
|
+
# What {Resources::Wrap#intercept!} returns: the route-aware controls plus
|
|
7
|
+
# +uninstall+.
|
|
8
|
+
class InterceptHandle
|
|
9
|
+
attr_reader :pipeline
|
|
10
|
+
|
|
11
|
+
def initialize(pipeline)
|
|
12
|
+
@pipeline = pipeline
|
|
13
|
+
@active = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def active? = @active
|
|
17
|
+
|
|
18
|
+
# Stop intercepting and drop the manifest. Ruby cannot un-prepend a module,
|
|
19
|
+
# so the seam stays on +Net::HTTP+ but calls +super+ immediately once no
|
|
20
|
+
# handle is active — every request goes exactly where it did before.
|
|
21
|
+
# Idempotent.
|
|
22
|
+
def uninstall
|
|
23
|
+
return unless @active
|
|
24
|
+
|
|
25
|
+
@active = false
|
|
26
|
+
@pipeline.stop
|
|
27
|
+
Intercept.clear(self)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
alias stop uninstall
|
|
31
|
+
|
|
32
|
+
def manifest = @pipeline.manifest
|
|
33
|
+
def ready = @pipeline.ready
|
|
34
|
+
def refresh = @pipeline.refresh
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The opt-in, EXPERIMENTAL process-wide seam (founder decision D7,
|
|
38
|
+
# 2026-09-25): a module prepended onto +Net::HTTP+ so that +Net::HTTP#request+
|
|
39
|
+
# — the one method every Net::HTTP verb, +Net::HTTP.get+, Faraday's default
|
|
40
|
+
# adapter, +rest-client+ and +httparty+ funnel through — consults the
|
|
41
|
+
# route-aware pipeline first. Not reached: Typhoeus / Curb / +http.rb+ (their
|
|
42
|
+
# own socket layer). One handle per process.
|
|
43
|
+
#
|
|
44
|
+
# This is a convenience, not a security boundary: it is a process global, it
|
|
45
|
+
# composes with other Net::HTTP patchers (WebMock, VCR, APM agents) in
|
|
46
|
+
# install order, and a request made while a handle is active is decided by
|
|
47
|
+
# the same table as every other seam. Route mode is the custody path — the
|
|
48
|
+
# key never enters your process.
|
|
49
|
+
module Intercept
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
@installed = nil
|
|
52
|
+
@prepended_on = nil
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# The active handle, or nil.
|
|
56
|
+
def installed
|
|
57
|
+
@mutex.synchronize { @installed }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prepend the seam (once per +Net::HTTP+ class — the constant is re-read
|
|
61
|
+
# so a class swapped in later, WebMock-style, is covered) and activate the
|
|
62
|
+
# handle. A second install while one is active is refused.
|
|
63
|
+
def install(pipeline)
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
raise Error, "wrap.intercept! is already installed in this process; uninstall the existing handle first" if @installed
|
|
66
|
+
|
|
67
|
+
target = ::Net::HTTP
|
|
68
|
+
unless @prepended_on.equal?(target)
|
|
69
|
+
target.prepend(NetHTTPRequest)
|
|
70
|
+
@prepended_on = target
|
|
71
|
+
end
|
|
72
|
+
@installed = InterceptHandle.new(pipeline)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clear(handle)
|
|
77
|
+
@mutex.synchronize { @installed = nil if @installed.equal?(handle) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The full URL a +Net::HTTP+ instance + request pair addresses.
|
|
81
|
+
def url_for(http, req)
|
|
82
|
+
"#{origin_for(http)}#{req.path}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The origin a +Net::HTTP+ instance connects to, as a URL with no path
|
|
86
|
+
# (+http://host:port+) — what the seam can decide on BEFORE a request
|
|
87
|
+
# exists, at connect time.
|
|
88
|
+
def origin_for(http)
|
|
89
|
+
ssl = http.use_ssl?
|
|
90
|
+
scheme = ssl ? "https" : "http"
|
|
91
|
+
host = http.address.to_s
|
|
92
|
+
host = "[#{host}]" if host.include?(":") && !host.start_with?("[")
|
|
93
|
+
default = ssl ? 443 : 80
|
|
94
|
+
authority = http.port == default ? host : "#{host}:#{http.port}"
|
|
95
|
+
"#{scheme}://#{authority}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The prepended seam. +request+ is where every Net::HTTP call lands —
|
|
100
|
+
# but +Net::HTTP+ CONNECTS in +start+, before +request+ ever runs, so a
|
|
101
|
+
# request the seam is about to reroute would still open a TCP (and TLS)
|
|
102
|
+
# connection to the real upstream first, and fail outright when that host
|
|
103
|
+
# is unreachable from the process. Measured by the CI smoke (#1000): the
|
|
104
|
+
# echo lives on the runner's loopback, the container's loopback refuses,
|
|
105
|
+
# and +Net::HTTP.get_response+ died in +connect+ before KnoxCall was ever
|
|
106
|
+
# asked. So +connect+ is part of the seam too: a host the decision table
|
|
107
|
+
# would reroute defers its connection, and a request that ends up direct
|
|
108
|
+
# — unlisted, route-around, the kill switch, or the +unavailable: :direct+
|
|
109
|
+
# fallback — connects at that moment instead.
|
|
110
|
+
module NetHTTPRequest
|
|
111
|
+
def connect
|
|
112
|
+
handle = Intercept.installed
|
|
113
|
+
if !@knoxcall_connect_now && handle && handle.active? && !InterceptContext.suppressed? &&
|
|
114
|
+
!handle.pipeline.decide("#{Intercept.origin_for(self)}/", "GET").direct?
|
|
115
|
+
@knoxcall_connect_deferred = true
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
super
|
|
119
|
+
end
|
|
120
|
+
private :connect
|
|
121
|
+
|
|
122
|
+
def request(req, body = nil, &block)
|
|
123
|
+
handle = Intercept.installed
|
|
124
|
+
if handle.nil? || !handle.active? || InterceptContext.suppressed?
|
|
125
|
+
knoxcall_connect_if_deferred
|
|
126
|
+
return super
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
url = Intercept.url_for(self, req)
|
|
130
|
+
decision = handle.pipeline.decide(url, req.method)
|
|
131
|
+
if decision.direct?
|
|
132
|
+
direct_headers = {}
|
|
133
|
+
req.each_header { |k, v| direct_headers[k] = v }
|
|
134
|
+
handle.pipeline.direct_decided(decision, url, method: req.method, headers: direct_headers)
|
|
135
|
+
knoxcall_connect_if_deferred
|
|
136
|
+
return super
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The body is held in memory (Net::HTTP's own requirement for a resend
|
|
140
|
+
# too), so a resend after a routing refusal is replayable.
|
|
141
|
+
stream = req.body_stream
|
|
142
|
+
payload = body || req.body || stream&.read
|
|
143
|
+
headers = {}
|
|
144
|
+
req.each_header { |k, v| headers[k] = v }
|
|
145
|
+
|
|
146
|
+
resp = handle.pipeline.send(decision, url: url, method: req.method, headers: headers, body: payload)
|
|
147
|
+
if resp == InterceptPipeline::DIRECT
|
|
148
|
+
# Perform the ORIGINAL request ourselves — connecting now, since the
|
|
149
|
+
# connection was deferred for this host. A consumed body stream is
|
|
150
|
+
# replaced by its bytes so Net::HTTP can send them.
|
|
151
|
+
knoxcall_connect_if_deferred
|
|
152
|
+
if stream
|
|
153
|
+
req.body_stream = nil
|
|
154
|
+
req.body = payload
|
|
155
|
+
return super(req, nil, &block)
|
|
156
|
+
end
|
|
157
|
+
return super(req, body, &block)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
yield resp if block_given?
|
|
161
|
+
resp
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Establish the connection +connect+ deferred, through the real
|
|
167
|
+
# +Net::HTTP#connect+, exactly once.
|
|
168
|
+
def knoxcall_connect_if_deferred
|
|
169
|
+
return unless @knoxcall_connect_deferred
|
|
170
|
+
|
|
171
|
+
@knoxcall_connect_deferred = false
|
|
172
|
+
@knoxcall_connect_now = true
|
|
173
|
+
begin
|
|
174
|
+
connect
|
|
175
|
+
ensure
|
|
176
|
+
@knoxcall_connect_now = false
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "knoxcall/errors"
|
|
4
|
+
require "knoxcall/warnings"
|
|
5
|
+
require "knoxcall/wrap_transport"
|
|
6
|
+
require "knoxcall/intercept_resolver"
|
|
7
|
+
require "knoxcall/intercept_store"
|
|
8
|
+
require "knoxcall/route_refusal"
|
|
9
|
+
require "knoxcall/egress_observations"
|
|
10
|
+
|
|
11
|
+
module KnoxCall
|
|
12
|
+
# Thread-local (fiber-local) flags the route-aware seams read.
|
|
13
|
+
#
|
|
14
|
+
# - +routed { }+ marks a call site: with +require_context: true+ only egress
|
|
15
|
+
# performed inside the block is intercepted — you mark the CALL SITE, not
|
|
16
|
+
# the SDK.
|
|
17
|
+
# - +suppressed { }+ marks the SDK's OWN traffic (the manifest poll, the
|
|
18
|
+
# route / ephemeral hop) so the process-wide Net::HTTP seam never
|
|
19
|
+
# re-intercepts it. The own-host rule already sends it direct; the flag
|
|
20
|
+
# makes that independent of URL parsing.
|
|
21
|
+
module InterceptContext
|
|
22
|
+
ROUTED = :knoxcall_intercept_routed
|
|
23
|
+
SUPPRESS = :knoxcall_intercept_suppress
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def routed(&block) = with_flag(ROUTED, &block)
|
|
28
|
+
def routed? = Thread.current[ROUTED] ? true : false
|
|
29
|
+
def suppressed(&block) = with_flag(SUPPRESS, &block)
|
|
30
|
+
def suppressed? = Thread.current[SUPPRESS] ? true : false
|
|
31
|
+
|
|
32
|
+
def with_flag(key)
|
|
33
|
+
previous = Thread.current[key]
|
|
34
|
+
Thread.current[key] = true
|
|
35
|
+
yield
|
|
36
|
+
ensure
|
|
37
|
+
Thread.current[key] = previous
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The ONE route-aware pipeline behind every Ruby seam — the Faraday adapter
|
|
42
|
+
# ({Resources::Wrap#faraday_connection}), the Faraday middleware
|
|
43
|
+
# ({Resources::Wrap#faraday_middleware}) and the opt-in Net::HTTP seam
|
|
44
|
+
# ({Resources::Wrap#intercept!}) — so the decision table, the own-host
|
|
45
|
+
# refusal, the kill switch and the D4 failure policy apply identically
|
|
46
|
+
# (route-aware-interception-plan.md §2–§3, PARITY §21.1).
|
|
47
|
+
#
|
|
48
|
+
# Per request, first match wins ({InterceptResolver}): KNOXCALL_INTERCEPT=off
|
|
49
|
+
# → direct · unparseable → direct · the client's own hosts and any
|
|
50
|
+
# knoxcall.com host → direct · route-around rule → direct · +require_context+
|
|
51
|
+
# outside +routed { }+ → direct · a manifest entry covering host + path →
|
|
52
|
+
# ROUTE (the Route injects the stored secret; no provider credential travels)
|
|
53
|
+
# · a listed host → EPHEMERAL · otherwise direct.
|
|
54
|
+
#
|
|
55
|
+
# Route mode sends through the existing {Client#call} pipeline
|
|
56
|
+
# (x-knoxcall-route, the client's environment, retry + the one re-mint
|
|
57
|
+
# inherited) — never a second proxy implementation. A KnoxCall-origin 401 in
|
|
58
|
+
# route mode, after that re-mint, means a stale manifest or a refused
|
|
59
|
+
# credential, and a KnoxCall-origin 404 route_not_found means a stale manifest
|
|
60
|
+
# naming a Route that no longer resolves (PARITY §21; RouteRefusal): ONE
|
|
61
|
+
# forced refresh, ONE re-decision, a resend only when the decision changed
|
|
62
|
+
# (a routing refusal is answered before any upstream contact). Never a loop.
|
|
63
|
+
#
|
|
64
|
+
# Unavailability (D4): route mode and escrow fail CLOSED (the SDK's
|
|
65
|
+
# {NetworkError}); transit may opt into going direct (+unavailable: :direct+,
|
|
66
|
+
# transport-wide or per host) — the seam then performs the ORIGINAL request.
|
|
67
|
+
class InterceptPipeline
|
|
68
|
+
# Returned by {#send} when the seam must perform the original request
|
|
69
|
+
# itself: a D4 fallback, or a re-decision to direct after a refusal.
|
|
70
|
+
DIRECT = :direct
|
|
71
|
+
|
|
72
|
+
HOOKS = %i[on_reroute on_refresh on_manifest_error on_unmatched_path on_refused on_fallback
|
|
73
|
+
on_promoted on_route_around on_observation_flush].freeze
|
|
74
|
+
|
|
75
|
+
attr_reader :store, :rules, :observer
|
|
76
|
+
|
|
77
|
+
# @param client [KnoxCall::Client]
|
|
78
|
+
# @param all_hosts [Boolean] the explicit-transport form: every request is "listed"
|
|
79
|
+
# @param hosts [Array<String>, Hash{String => Hash}, nil] hosts to cover even when no Route does;
|
|
80
|
+
# a Hash carries per-host +credential:+ (escrow) / +unavailable:+ (:direct)
|
|
81
|
+
# @param routes [Symbol, String] +:auto+ consults the manifest; +:off+ never polls
|
|
82
|
+
# @param credential [Hash, nil] transport-wide escrow {secret:, scheme:}
|
|
83
|
+
# @param route [String, nil] legacy explicit route slug (every non-direct request goes via it)
|
|
84
|
+
# @param auto_switch [Boolean] legacy promoted-route memory (only consulted with routes: :off)
|
|
85
|
+
# @param route_around [Array<Hash>, nil] extra route-around rules
|
|
86
|
+
# @param disable_default_route_around [Boolean]
|
|
87
|
+
# @param require_context [Boolean] only intercept inside +routed { }+
|
|
88
|
+
# @param unavailable [Symbol] +:error+ (fail closed, default) or +:direct+ (transit only)
|
|
89
|
+
# @param manifest_fetch [#call, nil] test seam: replaces the manifest call
|
|
90
|
+
# @param observe_uncovered [Boolean] report uncovered egress (PARITY §21.3) — see
|
|
91
|
+
# {Resources::Wrap#faraday_connection}; +false+ or KNOXCALL_OBSERVE_UNCOVERED=off turns it off
|
|
92
|
+
# @param observation_report [#call, nil] test seam: replaces the report call
|
|
93
|
+
# @param hooks [Hash{Symbol => #call}] see HOOKS (+on_observation_flush+: +{accepted:, dropped:}+)
|
|
94
|
+
def initialize(client:, all_hosts: false, hosts: nil, routes: :off, credential: nil, route: nil,
|
|
95
|
+
auto_switch: false, route_around: nil, disable_default_route_around: false,
|
|
96
|
+
require_context: false, unavailable: :error, manifest_fetch: nil,
|
|
97
|
+
observe_uncovered: true, observation_report: nil, **hooks)
|
|
98
|
+
unknown = hooks.keys - HOOKS
|
|
99
|
+
raise ArgumentError, "unknown intercept option(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
100
|
+
|
|
101
|
+
@client = client
|
|
102
|
+
@all_hosts = all_hosts ? true : false
|
|
103
|
+
@hosts = Set.new
|
|
104
|
+
@host_options = {}
|
|
105
|
+
add_hosts!(hosts)
|
|
106
|
+
self.class.validate_credential!(credential, "wrap")
|
|
107
|
+
@credential = credential
|
|
108
|
+
@route = route.is_a?(String) && !route.empty? ? route : nil
|
|
109
|
+
@auto_switch = auto_switch ? true : false
|
|
110
|
+
WrapTransport.assert_route_around_rules(route_around) if route_around
|
|
111
|
+
@rules = (disable_default_route_around ? [] : WrapTransport::DEFAULT_ROUTE_AROUND) + Array(route_around)
|
|
112
|
+
@require_context = require_context ? true : false
|
|
113
|
+
@unavailable = unavailable.to_s == "direct" ? :direct : :error
|
|
114
|
+
@hooks = hooks
|
|
115
|
+
@auto_switched = {}
|
|
116
|
+
@unmatched_warned = Set.new
|
|
117
|
+
@mutex = Mutex.new
|
|
118
|
+
@store =
|
|
119
|
+
if routes.to_s == "auto"
|
|
120
|
+
InterceptManifestStore.new(manifest_fetch || method(:fetch_manifest),
|
|
121
|
+
on_refresh: method(:handle_refresh),
|
|
122
|
+
on_error: hooks[:on_manifest_error])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Uncovered-egress observations (PARITY §21.3). ON by default (founder
|
|
126
|
+
# decision 2026-09-26) for the process-wide seam and the middleware
|
|
127
|
+
# (+all_hosts: false+) and for an explicit connection with
|
|
128
|
+
# +routes: :auto+; +observe_uncovered: false+ or the environment turns it
|
|
129
|
+
# off. An explicit connection treats every host as listed, so +:unlisted+
|
|
130
|
+
# never occurs there by construction — the reporter exists so the
|
|
131
|
+
# contract (and the opt-out) reads the same in every form. The report
|
|
132
|
+
# rides the SDK's own credential through +request+ under the suppress
|
|
133
|
+
# flag, so it is never itself intercepted.
|
|
134
|
+
@observer = nil
|
|
135
|
+
if observe_uncovered && !EgressObservations.disabled_by_env? && (!@all_hosts || routes.to_s == "auto")
|
|
136
|
+
report = observation_report || ->(observations) { @client.wrap.report_egress_observations(observations) }
|
|
137
|
+
@observer = EgressObservationReporter.new(report, on_flush: hooks[:on_observation_flush])
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A malformed credential must NOT silently fall through to transit mode and
|
|
142
|
+
# leak the SDK's raw key — fail loud (mirrors the Node fetch() guard).
|
|
143
|
+
def self.validate_credential!(credential, where)
|
|
144
|
+
return if credential.nil?
|
|
145
|
+
|
|
146
|
+
secret = credential.is_a?(Hash) ? (credential[:secret] || credential["secret"]) : nil
|
|
147
|
+
return if secret.is_a?(String) && !secret.empty?
|
|
148
|
+
|
|
149
|
+
raise TypeError,
|
|
150
|
+
"#{where} credential must be { secret: <non-empty String> } for escrow mode; " \
|
|
151
|
+
"omit `credential` entirely for transit mode."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The manifest this pipeline is deciding on, or nil (routes off / not loaded).
|
|
155
|
+
def manifest = @store&.manifest
|
|
156
|
+
|
|
157
|
+
# Load the manifest now if it is stale (the first attempt included). Never
|
|
158
|
+
# raises for a manifest failure; returns the manifest or nil.
|
|
159
|
+
def ready
|
|
160
|
+
return nil if @store.nil?
|
|
161
|
+
|
|
162
|
+
InterceptContext.suppressed { @store.ensure }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Refresh the manifest now (no-op with routes off).
|
|
166
|
+
def refresh
|
|
167
|
+
return nil if @store.nil?
|
|
168
|
+
|
|
169
|
+
InterceptContext.suppressed { @store.refresh("manual", force: true) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Stop polling and drop the manifest (and flush the uncovered-egress
|
|
173
|
+
# reporter once more); the pipeline keeps working on the ephemeral path
|
|
174
|
+
# for listed hosts and direct otherwise.
|
|
175
|
+
def stop
|
|
176
|
+
@store&.stop
|
|
177
|
+
@observer&.stop
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Apply the decision table to one request (refreshing the manifest first
|
|
182
|
+
# when it is stale).
|
|
183
|
+
def decide(url, method)
|
|
184
|
+
manifest = @store && InterceptContext.suppressed { @store.ensure }
|
|
185
|
+
InterceptResolver.resolve(
|
|
186
|
+
url: url, method: method,
|
|
187
|
+
hosts: @all_hosts ? :all : @hosts,
|
|
188
|
+
manifest: manifest,
|
|
189
|
+
own_hosts: own_hosts,
|
|
190
|
+
route_around: @rules,
|
|
191
|
+
kill_switch: InterceptResolver.kill_switch?,
|
|
192
|
+
require_context: @require_context,
|
|
193
|
+
in_context: InterceptContext.routed?
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A direct decision (a seam calls this before performing the original
|
|
198
|
+
# request itself): fires the route-around hook, and — for +:unlisted+
|
|
199
|
+
# only — records an uncovered-egress observation when the request carries
|
|
200
|
+
# a credential-bearing header (PARITY §21.3). Observed AFTER the decision,
|
|
201
|
+
# BEFORE the direct send; never raises into the application's request.
|
|
202
|
+
def direct_decided(decision, url, method: nil, headers: nil)
|
|
203
|
+
if decision.reason == :route_around
|
|
204
|
+
fire(:on_route_around, url: url, host: decision.host, reason: decision.route_around_reason)
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
return unless decision.reason == :unlisted && @observer
|
|
208
|
+
|
|
209
|
+
begin
|
|
210
|
+
obs = EgressObservations.observation_for(url, method, headers)
|
|
211
|
+
@observer.record(obs) if obs
|
|
212
|
+
rescue StandardError
|
|
213
|
+
# best-effort: telemetry must never reach the application's request.
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Send a non-direct decision through KnoxCall. Returns the
|
|
219
|
+
# +Net::HTTPResponse+ from the route or ephemeral hop, or {DIRECT} when the
|
|
220
|
+
# seam must perform the original request itself. +headers+ is the wrapped
|
|
221
|
+
# SDK's request-header Hash (any casing); +body+ the request body (String
|
|
222
|
+
# or nil — held in memory, so a resend after a routing refusal is
|
|
223
|
+
# replayable by construction).
|
|
224
|
+
def send(decision, url:, method:, headers:, body:)
|
|
225
|
+
InterceptContext.suppressed do
|
|
226
|
+
method = method.to_s.upcase
|
|
227
|
+
forwardable = WrapTransport.forwardable_headers(headers)
|
|
228
|
+
auth = header_value(headers, "Authorization")
|
|
229
|
+
|
|
230
|
+
# Legacy explicit route: / auto-switch memory (pre-manifest callers):
|
|
231
|
+
# every non-direct request goes via that slug with the full path.
|
|
232
|
+
legacy = legacy_route_for(decision)
|
|
233
|
+
if legacy
|
|
234
|
+
fire(:on_reroute, host: decision.host, url: url, mode: :route, slug: legacy, reason: :explicit_route)
|
|
235
|
+
return send_route(legacy, full_path(url), method, forwardable, body)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
return send_route_with_refresh(decision, url, method, forwardable, auth, body) if decision.route?
|
|
239
|
+
|
|
240
|
+
# Ephemeral.
|
|
241
|
+
if decision.reason == :no_base_path_match
|
|
242
|
+
key = "#{decision.host} #{first_segment(url)}"
|
|
243
|
+
first = @mutex.synchronize { @unmatched_warned.add?(key) }
|
|
244
|
+
fire(:on_unmatched_path, host: decision.host, url: url) if first
|
|
245
|
+
end
|
|
246
|
+
fire(:on_reroute, host: decision.host, url: url, mode: :ephemeral, reason: decision.reason)
|
|
247
|
+
send_ephemeral(decision, url, method, forwardable, auth, body)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
def add_hosts!(hosts)
|
|
254
|
+
return if hosts.nil?
|
|
255
|
+
|
|
256
|
+
entries = hosts.is_a?(Hash) ? hosts : Array(hosts).to_h { |h| [h, {}] }
|
|
257
|
+
entries.each do |host, opts|
|
|
258
|
+
assert_bare_host!(host)
|
|
259
|
+
opts = (opts || {}).to_h { |k, v| [k.to_sym, v] }
|
|
260
|
+
self.class.validate_credential!(opts[:credential], "intercept host #{host}")
|
|
261
|
+
n = WrapTransport.normalize_host(host)
|
|
262
|
+
@hosts << n
|
|
263
|
+
@host_options[n] = {
|
|
264
|
+
credential: opts[:credential],
|
|
265
|
+
unavailable: opts[:unavailable].to_s == "direct" ? :direct : nil
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A listed host that is not a bare DNS hostname (a scheme/port/path slipped
|
|
271
|
+
# in) could never match a parsed request host and would silently disable
|
|
272
|
+
# the listing — fail loud instead.
|
|
273
|
+
def assert_bare_host!(host)
|
|
274
|
+
h = host.to_s.strip
|
|
275
|
+
parsed = begin
|
|
276
|
+
URI.parse("https://#{h}").host.to_s
|
|
277
|
+
rescue URI::InvalidURIError
|
|
278
|
+
""
|
|
279
|
+
end
|
|
280
|
+
return unless h.empty? || WrapTransport.normalize_host(parsed) != WrapTransport.normalize_host(h)
|
|
281
|
+
|
|
282
|
+
raise WrapSandboxMismatchError,
|
|
283
|
+
"invalid intercept host #{host.inspect}: expected a bare DNS hostname (no scheme, port, or path)."
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The client's management and data-plane hosts, never intercepted.
|
|
287
|
+
def own_hosts
|
|
288
|
+
out = Set.new
|
|
289
|
+
[@client.base_url, @client.proxy_base_url].each do |raw|
|
|
290
|
+
next if raw.nil? || raw.to_s.empty?
|
|
291
|
+
|
|
292
|
+
h = begin
|
|
293
|
+
WrapTransport.normalize_host(URI.parse(raw.to_s).host)
|
|
294
|
+
rescue URI::InvalidURIError
|
|
295
|
+
""
|
|
296
|
+
end
|
|
297
|
+
out << h unless h.empty?
|
|
298
|
+
end
|
|
299
|
+
out
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The store's fetch: the client's environment, and the held version as
|
|
303
|
+
# If-None-Match (a 304 comes back as nil — PARITY §21.1).
|
|
304
|
+
def fetch_manifest(if_none_match: nil)
|
|
305
|
+
@client.wrap.intercept_manifest(environment: @client.environment, if_none_match: if_none_match)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Warn once per entry that will be refused or is ambiguous, then forward
|
|
309
|
+
# to the caller's hook.
|
|
310
|
+
def handle_refresh(info)
|
|
311
|
+
info[:added].each do |e|
|
|
312
|
+
slug = e["slug"]
|
|
313
|
+
host_base = "#{e['host']}#{e['base_path']}"
|
|
314
|
+
if e["requires_clients"]
|
|
315
|
+
Warnings.warn_once(
|
|
316
|
+
"KNOXCALL_INTERCEPT_REQUIRES_CLIENTS:#{slug}",
|
|
317
|
+
"KnoxCall route #{slug.inspect} (#{host_base}) requires a registered client; a bearer-only SDK " \
|
|
318
|
+
"call will be refused (403). Register this process as a client of the route, or leave the " \
|
|
319
|
+
"route out of interception."
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
next unless e["ambiguous"]
|
|
323
|
+
|
|
324
|
+
Warnings.warn_once(
|
|
325
|
+
"KNOXCALL_INTERCEPT_AMBIGUOUS:#{host_base}",
|
|
326
|
+
"KnoxCall: more than one intercept-enabled route covers #{host_base}; the lexically lowest slug " \
|
|
327
|
+
"is used. Disable the others."
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
fire(:on_refresh, **info)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def legacy_route_for(decision)
|
|
334
|
+
return @route if @route
|
|
335
|
+
return nil unless decision.ephemeral? && @auto_switch
|
|
336
|
+
|
|
337
|
+
@mutex.synchronize { @auto_switched[decision.host] }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Every route-mode reroute is marked (PARITY §21.2) — the manifest decision
|
|
341
|
+
# and the legacy explicit +route:+ form alike: both are a third-party SDK's
|
|
342
|
+
# call this pipeline redirected, which is what the API Log's "SDK intercept"
|
|
343
|
+
# origin means.
|
|
344
|
+
def send_route(slug, path, method, headers, body)
|
|
345
|
+
@client.call(slug, method: method, path: path, headers: headers, body: body,
|
|
346
|
+
_origin: Client::SDK_INTERCEPT_ORIGIN)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def send_route_with_refresh(decision, url, method, headers, auth, body)
|
|
350
|
+
fire(:on_reroute, host: decision.host, url: url, mode: :route, slug: decision.slug, reason: decision.reason)
|
|
351
|
+
resp = send_route(decision.slug, decision.path, method, headers, body)
|
|
352
|
+
return resp unless @store && route_refusal?(resp)
|
|
353
|
+
|
|
354
|
+
@store.refresh("route_refused", force: true)
|
|
355
|
+
again = decide(url, method)
|
|
356
|
+
changed = !again.route? || again.slug != decision.slug || again.path != decision.path
|
|
357
|
+
fire(:on_refused, host: decision.host, url: url, slug: decision.slug, status: resp.code.to_i,
|
|
358
|
+
redecided: changed ? again.mode : nil)
|
|
359
|
+
return resp unless changed
|
|
360
|
+
|
|
361
|
+
case again.mode
|
|
362
|
+
when :route
|
|
363
|
+
fire(:on_reroute, host: again.host, url: url, mode: :route, slug: again.slug, reason: again.reason)
|
|
364
|
+
send_route(again.slug, again.path, method, headers, body)
|
|
365
|
+
when :ephemeral
|
|
366
|
+
fire(:on_reroute, host: again.host, url: url, mode: :ephemeral, reason: again.reason)
|
|
367
|
+
send_ephemeral(again, url, method, headers, auth, body)
|
|
368
|
+
else
|
|
369
|
+
DIRECT
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def send_ephemeral(decision, url, method, headers, auth, body)
|
|
374
|
+
host_opts = @host_options[decision.host] || {}
|
|
375
|
+
credential = host_opts[:credential] || @credential
|
|
376
|
+
opts = { method: method, body: body, headers: headers, mode: "transparent" }
|
|
377
|
+
if credential
|
|
378
|
+
# Escrow mode — the raw key never travels; the SDK's own key is an
|
|
379
|
+
# ignored placeholder (no Test/Live assertion).
|
|
380
|
+
opts[:upstream_auth_secret] = credential_field(credential, :secret)
|
|
381
|
+
scheme = credential_field(credential, :scheme)
|
|
382
|
+
opts[:upstream_auth_scheme] = scheme unless scheme.nil?
|
|
383
|
+
else
|
|
384
|
+
# Transit mode — lift the SDK's own Authorization header out-of-band.
|
|
385
|
+
WrapTransport.assert_key_matches_sandbox(auth, @client.sandbox)
|
|
386
|
+
opts[:upstream_authorization] = auth unless auth.nil?
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
begin
|
|
390
|
+
resp = @client.ephemeral(url, **opts)
|
|
391
|
+
rescue NetworkError => e
|
|
392
|
+
# D4: fail closed by default. Going direct is honoured only for TRANSIT
|
|
393
|
+
# traffic — the key is in the process there. Escrow has nothing to go
|
|
394
|
+
# direct with.
|
|
395
|
+
policy = host_opts[:unavailable] || @unavailable
|
|
396
|
+
raise unless policy == :direct && credential.nil?
|
|
397
|
+
|
|
398
|
+
fire(:on_fallback, host: decision.host, url: url, error: e)
|
|
399
|
+
return DIRECT
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Promoted-route hint: a Route now covers this host. With a manifest the
|
|
403
|
+
# hint is a signal to refresh it — the manifest is the truth. Without one
|
|
404
|
+
# (legacy auto_switch), remember the slug directly.
|
|
405
|
+
slug = resp["x-knox-promoted-route"]
|
|
406
|
+
if slug && !decision.host.empty?
|
|
407
|
+
fire(:on_promoted, host: decision.host, slug: slug)
|
|
408
|
+
if @store
|
|
409
|
+
@store.hint
|
|
410
|
+
elsif @auto_switch
|
|
411
|
+
@mutex.synchronize { @auto_switched[decision.host] = slug }
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
resp
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# A KnoxCall-origin refusal on the route data plane: a 401 with neither
|
|
418
|
+
# spelling of "the upstream answered", or a 404 whose envelope error.type is
|
|
419
|
+
# route_not_found (PARITY §21.1; the predicate and its cross-language
|
|
420
|
+
# fixtures live in KnoxCall::RouteRefusal).
|
|
421
|
+
def route_refusal?(resp)
|
|
422
|
+
RouteRefusal.refusal?(status: resp.code.to_i, headers: resp.to_hash, body: resp.body)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def full_path(url)
|
|
426
|
+
u = URI.parse(url)
|
|
427
|
+
path = u.path.to_s.empty? ? "/" : u.path
|
|
428
|
+
u.query ? "#{path}?#{u.query}" : path
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def first_segment(url)
|
|
432
|
+
path = begin
|
|
433
|
+
URI.parse(url).path.to_s
|
|
434
|
+
rescue URI::InvalidURIError
|
|
435
|
+
""
|
|
436
|
+
end
|
|
437
|
+
"/#{path.delete_prefix('/').split('/', 2).first}"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def header_value(headers, name)
|
|
441
|
+
pair = headers.find { |k, _| k.to_s.casecmp(name).zero? }
|
|
442
|
+
pair&.last
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def credential_field(credential, key)
|
|
446
|
+
return nil unless credential.is_a?(Hash)
|
|
447
|
+
|
|
448
|
+
credential.key?(key) ? credential[key] : credential[key.to_s]
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def fire(hook, **info)
|
|
452
|
+
@hooks[hook]&.call(**info)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|