butler-http 0.1.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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +22 -0
- data/README.md +659 -0
- data/Rakefile +10 -0
- data/lib/butler/async.rb +130 -0
- data/lib/butler/body.rb +130 -0
- data/lib/butler/client.rb +206 -0
- data/lib/butler/configuration.rb +161 -0
- data/lib/butler/connection.rb +21 -0
- data/lib/butler/connection_pool.rb +64 -0
- data/lib/butler/errors.rb +78 -0
- data/lib/butler/headers.rb +106 -0
- data/lib/butler/pipeline/chain.rb +18 -0
- data/lib/butler/pipeline/context.rb +21 -0
- data/lib/butler/pipeline/middleware.rb +16 -0
- data/lib/butler/pipeline/middlewares/circuit_breaker_middleware.rb +21 -0
- data/lib/butler/pipeline/middlewares/retry_middleware.rb +67 -0
- data/lib/butler/pipeline/middlewares/security_middleware.rb +14 -0
- data/lib/butler/pipeline/middlewares/telemetry_middleware.rb +37 -0
- data/lib/butler/pipeline/middlewares/timeout_middleware.rb +17 -0
- data/lib/butler/quic/crypto/aead.rb +69 -0
- data/lib/butler/quic/crypto/header_protection.rb +51 -0
- data/lib/butler/quic/crypto/hkdf.rb +44 -0
- data/lib/butler/quic/crypto/key_schedule.rb +50 -0
- data/lib/butler/quic/packet.rb +113 -0
- data/lib/butler/quic/varint.rb +56 -0
- data/lib/butler/rails/notifications.rb +10 -0
- data/lib/butler/rails/railtie.rb +24 -0
- data/lib/butler/request.rb +92 -0
- data/lib/butler/resilience/backoff.rb +17 -0
- data/lib/butler/resilience/circuit_breaker.rb +120 -0
- data/lib/butler/resilience/deadline.rb +55 -0
- data/lib/butler/resilience/retry_policy.rb +76 -0
- data/lib/butler/resilience/timeout.rb +22 -0
- data/lib/butler/response.rb +81 -0
- data/lib/butler/security/host_policy.rb +29 -0
- data/lib/butler/security/limits.rb +39 -0
- data/lib/butler/security/redirect_policy.rb +28 -0
- data/lib/butler/security/tls.rb +51 -0
- data/lib/butler/stream.rb +44 -0
- data/lib/butler/telemetry/instrumentation.rb +59 -0
- data/lib/butler/telemetry/logger.rb +37 -0
- data/lib/butler/telemetry/open_telemetry_bridge.rb +46 -0
- data/lib/butler/testing/fake_transport.rb +29 -0
- data/lib/butler/testing/stub.rb +68 -0
- data/lib/butler/testing/stub_registry.rb +32 -0
- data/lib/butler/testing.rb +33 -0
- data/lib/butler/transport.rb +118 -0
- data/lib/butler/uri.rb +77 -0
- data/lib/butler/version.rb +3 -0
- data/lib/butler.rb +176 -0
- data/sig/butler/client.rbs +41 -0
- data/sig/butler/configuration.rbs +63 -0
- data/sig/butler/errors.rbs +67 -0
- data/sig/butler/headers.rbs +22 -0
- data/sig/butler/request.rbs +21 -0
- data/sig/butler/response.rbs +28 -0
- data/sig/butler.rbs +22 -0
- metadata +191 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Unlike the Net::HTTP-based pool this replaces, Butler::ConnectionPool is
|
|
2
|
+
# not a socket-level checkout/checkin pool — Async::HTTP::Client already
|
|
3
|
+
# does per-origin pooling and HTTP/2 multiplexing internally (see
|
|
4
|
+
# butler/transport.rb). This is a thin, bounded registry of one memoized
|
|
5
|
+
# Butler::Connection per origin, so repeated requests to the same host
|
|
6
|
+
# reuse the same underlying Async::HTTP::Client instead of rebuilding one
|
|
7
|
+
# (and paying DNS/TCP/TLS setup again) on every call.
|
|
8
|
+
class Butler::ConnectionPool
|
|
9
|
+
Entry = Struct.new(:connection, :last_used_at)
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@entries = {} # fingerprint => Entry, insertion-ordered (cheap LRU)
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def acquire(uri, config)
|
|
18
|
+
key = fingerprint(uri, config)
|
|
19
|
+
now = monotonic_now
|
|
20
|
+
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
if (entry = @entries.delete(key))
|
|
23
|
+
if now - entry.last_used_at <= config.pool.idle_timeout
|
|
24
|
+
entry.last_used_at = now
|
|
25
|
+
@entries[key] = entry # touch: move to the end
|
|
26
|
+
return entry.connection
|
|
27
|
+
else
|
|
28
|
+
entry.connection.close # sat idle past pool.idle_timeout — rebuild fresh below rather than reuse
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
connection = Butler::Transport.current.build_connection(uri, config)
|
|
33
|
+
@entries[key] = Entry.new(connection, now)
|
|
34
|
+
evict_if_needed!
|
|
35
|
+
connection
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset!
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@entries.each_value { |entry| entry.connection.close }
|
|
42
|
+
@entries.clear
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def fingerprint(uri, config)
|
|
49
|
+
transport_name = Butler::Transport.current.equal?(Butler::Testing::FakeTransport) ? "fake" : "real"
|
|
50
|
+
"#{uri.origin_key}|proxy=#{config.proxy}|http_version=#{config.http_version}|verify_tls=#{config.security.verify_tls}|transport=#{transport_name}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def evict_if_needed!
|
|
54
|
+
max = @config.pool.max_connections
|
|
55
|
+
while @entries.size > max
|
|
56
|
+
_key, entry = @entries.shift
|
|
57
|
+
entry&.connection&.close
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def monotonic_now
|
|
62
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Butler's error hierarchy. Every error Butler itself raises is a
|
|
2
|
+
# Butler::Errors::Error, so `rescue Butler::Errors::Error` is always a safe
|
|
3
|
+
# top-level catch-all for "something about this HTTP call failed."
|
|
4
|
+
#
|
|
5
|
+
# The shape mirrors the PRD's proposed hierarchy closely, with two additions
|
|
6
|
+
# not in that list: LimitExceeded (raised by Security::Limits, since response
|
|
7
|
+
# and header size caps need somewhere to live) and TooManyRedirectsError
|
|
8
|
+
# (carried over from the previous implementation).
|
|
9
|
+
module Butler::Errors
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# The request was misconfigured (bad option, invalid combination) before
|
|
13
|
+
# anything was sent over the wire.
|
|
14
|
+
class ConfigurationError < Error; end
|
|
15
|
+
|
|
16
|
+
# The request itself couldn't be built/sent as specified.
|
|
17
|
+
class RequestError < Error; end
|
|
18
|
+
|
|
19
|
+
class TooManyRedirectsError < RequestError; end
|
|
20
|
+
|
|
21
|
+
# Something went wrong at or below the transport layer, i.e. the request
|
|
22
|
+
# never received a well-formed HTTP response.
|
|
23
|
+
class TransportError < Error; end
|
|
24
|
+
class ConnectionError < TransportError; end
|
|
25
|
+
class TimeoutError < TransportError; end
|
|
26
|
+
class TLSError < TransportError; end
|
|
27
|
+
|
|
28
|
+
# A TLSError specifically caused by certificate verification failing
|
|
29
|
+
# (self-signed, expired, hostname mismatch, untrusted root — anything
|
|
30
|
+
# OpenSSL itself reports as "certificate verify failed"). Split out from
|
|
31
|
+
# the general TLSError deliberately: unlike a transient TLS handshake
|
|
32
|
+
# failure, a bad certificate won't become valid on the very next retry a
|
|
33
|
+
# few hundred milliseconds later, so RetryPolicy excludes this subclass
|
|
34
|
+
# specifically while still retrying TLSError generally. Still a TLSError
|
|
35
|
+
# (rescue TLSError still catches it) — only its retry eligibility differs.
|
|
36
|
+
class CertificateVerificationError < TLSError; end
|
|
37
|
+
|
|
38
|
+
class DNSFailure < TransportError; end
|
|
39
|
+
class ProtocolError < TransportError; end
|
|
40
|
+
|
|
41
|
+
# A well-formed HTTP response was received. Only raised when a call is
|
|
42
|
+
# made with raise_on_error: true; carries the Butler::Response so rescuers
|
|
43
|
+
# can still inspect status/body/headers.
|
|
44
|
+
class HTTPError < Error
|
|
45
|
+
attr_reader :response
|
|
46
|
+
|
|
47
|
+
def initialize(response)
|
|
48
|
+
@response = response
|
|
49
|
+
super("#{response.status} response for #{response.uri}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class ClientError < HTTPError; end
|
|
54
|
+
class ServerError < HTTPError; end
|
|
55
|
+
|
|
56
|
+
# Resilience-layer errors.
|
|
57
|
+
class RetryExhausted < Error
|
|
58
|
+
attr_reader :cause_response, :attempts
|
|
59
|
+
|
|
60
|
+
def initialize(message, cause_response: nil, attempts: nil)
|
|
61
|
+
@cause_response = cause_response
|
|
62
|
+
@attempts = attempts
|
|
63
|
+
super(message)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class CircuitOpen < Error; end
|
|
68
|
+
class Cancelled < Error; end
|
|
69
|
+
|
|
70
|
+
# Security::Limits (max_response_size / max_header_size). Not in the PRD's
|
|
71
|
+
# enumerated list, but there's nowhere else for this to live.
|
|
72
|
+
class LimitExceeded < Error; end
|
|
73
|
+
|
|
74
|
+
# HostPolicy allow/block list rejections. Kept as RequestError rather than
|
|
75
|
+
# a new subclass since "this request isn't allowed to be sent" is exactly
|
|
76
|
+
# what RequestError already means.
|
|
77
|
+
class HostNotAllowed < RequestError; end
|
|
78
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# A case-insensitive, order-preserving, multi-value header map. Header
|
|
2
|
+
# names are stored lower-cased internally (the shape both HTTP/2 and
|
|
3
|
+
# Protocol::HTTP expect on the wire) but multiple values for the same name
|
|
4
|
+
# (e.g. repeated Set-Cookie lines) are kept as separate pairs rather than
|
|
5
|
+
# collapsed, since #[]= collapsing them would silently lose data on
|
|
6
|
+
# responses.
|
|
7
|
+
class Butler::Headers
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def initialize(pairs = nil)
|
|
11
|
+
@pairs = []
|
|
12
|
+
pairs&.each { |k, v| add(k, v) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_a(pairs)
|
|
16
|
+
new(pairs)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Accepts a Butler::Headers as-is, or builds one from a Hash/Array of pairs.
|
|
20
|
+
def self.coerce(headers)
|
|
21
|
+
return headers if headers.is_a?(Butler::Headers)
|
|
22
|
+
|
|
23
|
+
new(headers)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# First value for +key+ (the common case — most headers only ever appear
|
|
27
|
+
# once). Use #values_at to read every value of a repeated header.
|
|
28
|
+
def [](key)
|
|
29
|
+
dk = downcase(key)
|
|
30
|
+
pair = @pairs.find { |k, _| k == dk }
|
|
31
|
+
pair && pair[1]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Replaces every existing value for +key+ with a single new one.
|
|
35
|
+
def []=(key, value)
|
|
36
|
+
delete(key)
|
|
37
|
+
add(key, value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Appends an additional value for +key+ without removing existing ones.
|
|
41
|
+
def add(key, value)
|
|
42
|
+
@pairs << [downcase(key), value.to_s]
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def key?(key)
|
|
47
|
+
dk = downcase(key)
|
|
48
|
+
@pairs.any? { |k, _| k == dk }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete(key)
|
|
52
|
+
dk = downcase(key)
|
|
53
|
+
removed = @pairs.select { |k, _| k == dk }.map { |_, v| v }
|
|
54
|
+
@pairs.reject! { |k, _| k == dk }
|
|
55
|
+
removed.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def values_at(key)
|
|
59
|
+
dk = downcase(key)
|
|
60
|
+
@pairs.select { |k, _| k == dk }.map { |_, v| v }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each
|
|
64
|
+
return enum_for(:each) unless block_given?
|
|
65
|
+
|
|
66
|
+
@pairs.each { |k, v| yield k, v }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Array of [lower-cased-name, value] pairs — exactly the shape
|
|
70
|
+
# Protocol::HTTP::Methods#get/post/... expects for its headers argument.
|
|
71
|
+
def to_a
|
|
72
|
+
@pairs.map(&:dup)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
@pairs.each_with_object({}) { |(k, v), h| h[k] = v }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +other+'s keys replace this Headers' values for the same key (so
|
|
80
|
+
# per-request headers override client-level defaults, rather than
|
|
81
|
+
# duplicating them).
|
|
82
|
+
def merge(other)
|
|
83
|
+
merged = Butler::Headers.new(@pairs)
|
|
84
|
+
Butler::Headers.coerce(other).each { |k, v| merged[k] = v }
|
|
85
|
+
merged
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def empty?
|
|
89
|
+
@pairs.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def size
|
|
93
|
+
@pairs.size
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize_copy(source)
|
|
97
|
+
super
|
|
98
|
+
@pairs = @pairs.map(&:dup)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def downcase(key)
|
|
104
|
+
key.to_s.downcase
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
# Builds the nested-proc chain out of an ordered list of middlewares
|
|
4
|
+
# plus a terminal step (ConnectionPool#acquire + Transport#call). Each
|
|
5
|
+
# middleware wraps the next, exactly like Rack.
|
|
6
|
+
class Chain
|
|
7
|
+
def initialize(middlewares, terminal:)
|
|
8
|
+
@app = middlewares.reverse.reduce(terminal) do |next_middleware, middleware|
|
|
9
|
+
->(context) { middleware.call(context, next_middleware) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(context)
|
|
14
|
+
@app.call(context)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
# Carries everything one request attempt through the middleware chain
|
|
4
|
+
# needs. A fresh Context is built for every redirect hop (a new
|
|
5
|
+
# Butler::Request against the new URI), but shares the same Deadline
|
|
6
|
+
# across every hop and every retry attempt within a hop.
|
|
7
|
+
class Context
|
|
8
|
+
attr_accessor :client, :request, :response, :config, :deadline, :attempt, :metadata
|
|
9
|
+
|
|
10
|
+
def initialize(client:, request:, config:, deadline:)
|
|
11
|
+
@client = client
|
|
12
|
+
@request = request
|
|
13
|
+
@config = config
|
|
14
|
+
@deadline = deadline
|
|
15
|
+
@response = nil
|
|
16
|
+
@attempt = 0
|
|
17
|
+
@metadata = {}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
# Documents the interface a pipeline middleware implements — not
|
|
4
|
+
# enforced through inheritance, just the shape every built-in
|
|
5
|
+
# middleware (and anything passed to Client#use) follows:
|
|
6
|
+
#
|
|
7
|
+
# def call(context, next_middleware)
|
|
8
|
+
# # ...inspect/modify context.request...
|
|
9
|
+
# response = next_middleware.call(context)
|
|
10
|
+
# # ...inspect/modify response, or handle an exception...
|
|
11
|
+
# response
|
|
12
|
+
# end
|
|
13
|
+
module Middleware
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
module Middlewares
|
|
4
|
+
class CircuitBreakerMiddleware
|
|
5
|
+
def initialize(circuit_breaker)
|
|
6
|
+
@circuit_breaker = circuit_breaker
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(context, next_middleware)
|
|
10
|
+
return next_middleware.call(context) unless context.config.circuit_breaker.enabled
|
|
11
|
+
|
|
12
|
+
scope_key = @circuit_breaker.scope_key_for(context.request, context.config)
|
|
13
|
+
@circuit_breaker.call(
|
|
14
|
+
scope_key, context.config.circuit_breaker,
|
|
15
|
+
failure: ->(response) { response.server_error? }
|
|
16
|
+
) { next_middleware.call(context) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
module Middlewares
|
|
4
|
+
# The only middleware with a loop. Two independent retry paths — a
|
|
5
|
+
# raised transport-level exception, or a response whose status is
|
|
6
|
+
# retryable — each consulting Resilience::RetryPolicy for whether to
|
|
7
|
+
# retry and how long to wait first (see that class for the exact
|
|
8
|
+
# method/status/idempotency/deadline rules).
|
|
9
|
+
class RetryMiddleware
|
|
10
|
+
def initialize(retry_policy)
|
|
11
|
+
@retry_policy = retry_policy
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(context, next_middleware)
|
|
15
|
+
attempt = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
attempt += 1
|
|
19
|
+
context.attempt = attempt
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
response = next_middleware.call(context)
|
|
23
|
+
rescue Butler::Errors::TransportError => e
|
|
24
|
+
retry_options = context.config.retry
|
|
25
|
+
if @retry_policy.retry_on_exception?(exception: e, attempt: attempt, deadline: context.deadline, retry_options: retry_options)
|
|
26
|
+
wait_before_retry(@retry_policy.delay_for(attempt: attempt, retry_options: retry_options, deadline: context.deadline))
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
raise wrap_exhausted(e, attempt, context.deadline)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
retry_options = context.config.retry
|
|
33
|
+
if @retry_policy.retry_on_response?(request: context.request, response: response, attempt: attempt, deadline: context.deadline, retry_options: retry_options)
|
|
34
|
+
wait_before_retry(@retry_policy.delay_for(response: response, attempt: attempt, retry_options: retry_options, deadline: context.deadline))
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return response
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def wrap_exhausted(error, attempt, deadline)
|
|
45
|
+
return error if attempt <= 1
|
|
46
|
+
|
|
47
|
+
if deadline.expired?
|
|
48
|
+
Butler::Errors::TimeoutError.new("deadline exceeded after #{attempt} attempt(s): #{error.message}")
|
|
49
|
+
else
|
|
50
|
+
Butler::Errors::RetryExhausted.new("giving up after #{attempt} attempt(s): #{error.message}", attempts: attempt)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Plain Kernel#sleep, not Async::Task#sleep (deprecated as of async
|
|
55
|
+
# 2.x): the `async` gem registers itself as a Fiber scheduler, so
|
|
56
|
+
# Kernel#sleep already yields to sibling fibers/tasks rather than
|
|
57
|
+
# blocking the thread whenever one is active — no need to reach for
|
|
58
|
+
# the task-specific API.
|
|
59
|
+
def wait_before_retry(seconds)
|
|
60
|
+
return if seconds <= 0
|
|
61
|
+
|
|
62
|
+
sleep(seconds)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
module Middlewares
|
|
4
|
+
# Runs first, before any socket is touched: rejects the request
|
|
5
|
+
# outright if its host isn't allowed (see Butler::Security::HostPolicy).
|
|
6
|
+
class SecurityMiddleware
|
|
7
|
+
def call(context, next_middleware)
|
|
8
|
+
Butler::Security::HostPolicy.check!(context.request.uri, context.config)
|
|
9
|
+
next_middleware.call(context)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
module Middlewares
|
|
4
|
+
# Wraps the rest of the chain in Butler::Telemetry::Instrumentation
|
|
5
|
+
# (standalone pub/sub + optional ActiveSupport::Notifications bridge)
|
|
6
|
+
# and, when OpenTelemetry is loaded, a real tracing span with correct
|
|
7
|
+
# timing around the actual request execution. Its payload is built
|
|
8
|
+
# from an explicit allow-list of fields — it structurally never reads
|
|
9
|
+
# request/response bodies or Authorization/Cookie/Set-Cookie headers.
|
|
10
|
+
class TelemetryMiddleware
|
|
11
|
+
def call(context, next_middleware)
|
|
12
|
+
return next_middleware.call(context) unless context.config.telemetry.enabled
|
|
13
|
+
|
|
14
|
+
request = context.request
|
|
15
|
+
payload = { method: request.method, host: request.uri.host, port: request.uri.port, path: request.uri.uri.path }
|
|
16
|
+
|
|
17
|
+
Butler::Telemetry::Instrumentation.instrument(:request, payload) do
|
|
18
|
+
in_span(context.config, payload) do
|
|
19
|
+
response = next_middleware.call(context)
|
|
20
|
+
payload[:status] = response.status
|
|
21
|
+
payload[:attempt] = context.attempt
|
|
22
|
+
response
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def in_span(config, payload, &block)
|
|
30
|
+
return block.call unless Butler::Telemetry::OpenTelemetryBridge.available?(config)
|
|
31
|
+
|
|
32
|
+
Butler::Telemetry::OpenTelemetryBridge.in_span(payload, &block)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Pipeline
|
|
3
|
+
module Middlewares
|
|
4
|
+
# The total-budget checkpoint: raises before even attempting the next
|
|
5
|
+
# step once context.deadline has expired. Per-attempt connect/read/
|
|
6
|
+
# write timeouts are separate — those are handed to Async::HTTP as
|
|
7
|
+
# raw seconds and enforced by Butler::Resilience::Timeout inside
|
|
8
|
+
# Transport::Async.
|
|
9
|
+
class TimeoutMiddleware
|
|
10
|
+
def call(context, next_middleware)
|
|
11
|
+
context.deadline.exceeded!
|
|
12
|
+
next_middleware.call(context)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Quic
|
|
3
|
+
module Crypto
|
|
4
|
+
# QUIC packet protection (RFC 9001 section 5.3): AEAD-encrypts a
|
|
5
|
+
# packet's payload with the unprotected header as associated data.
|
|
6
|
+
# The AEAD itself is OpenSSL's (AES-GCM / ChaCha20-Poly1305); this
|
|
7
|
+
# module only builds the nonce the spec describes and wires OpenSSL's
|
|
8
|
+
# streaming cipher API to it.
|
|
9
|
+
module AEAD
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
CIPHERS = {
|
|
13
|
+
aes_128_gcm: "aes-128-gcm",
|
|
14
|
+
aes_256_gcm: "aes-256-gcm",
|
|
15
|
+
chacha20_poly1305: "chacha20-poly1305",
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
TAG_LENGTH = 16
|
|
19
|
+
|
|
20
|
+
def seal(cipher_suite, key, nonce, plaintext, aad)
|
|
21
|
+
cipher = ::OpenSSL::Cipher.new(CIPHERS.fetch(cipher_suite))
|
|
22
|
+
cipher.encrypt
|
|
23
|
+
cipher.key = key
|
|
24
|
+
cipher.iv = nonce
|
|
25
|
+
cipher.auth_data = aad
|
|
26
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
27
|
+
ciphertext + cipher.auth_tag
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raises Butler::Errors::TLSError (rather than letting the OpenSSL
|
|
31
|
+
# error leak) if the packet fails to authenticate — a forged or
|
|
32
|
+
# corrupted packet, or protection keys that don't actually match
|
|
33
|
+
# this packet. Also raised (not a raw ArgumentError/TypeError) if
|
|
34
|
+
# +ciphertext_and_tag+ is too short to even contain a tag — this is
|
|
35
|
+
# a public entry point that untrusted-length network data can reach
|
|
36
|
+
# directly, not just through Packet.unprotect's own length check.
|
|
37
|
+
def open(cipher_suite, key, nonce, ciphertext_and_tag, aad)
|
|
38
|
+
if ciphertext_and_tag.bytesize < TAG_LENGTH
|
|
39
|
+
raise Butler::Errors::TLSError, "QUIC packet too short (#{ciphertext_and_tag.bytesize} bytes) to contain an AEAD tag"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
tag = ciphertext_and_tag.byteslice(-TAG_LENGTH, TAG_LENGTH)
|
|
43
|
+
ciphertext = ciphertext_and_tag.byteslice(0, ciphertext_and_tag.bytesize - TAG_LENGTH)
|
|
44
|
+
|
|
45
|
+
cipher = ::OpenSSL::Cipher.new(CIPHERS.fetch(cipher_suite))
|
|
46
|
+
cipher.decrypt
|
|
47
|
+
cipher.key = key
|
|
48
|
+
cipher.iv = nonce
|
|
49
|
+
cipher.auth_tag = tag
|
|
50
|
+
cipher.auth_data = aad
|
|
51
|
+
cipher.update(ciphertext) + cipher.final
|
|
52
|
+
rescue ::OpenSSL::Cipher::CipherError => e
|
|
53
|
+
raise Butler::Errors::TLSError, "QUIC packet failed to authenticate: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# RFC 9001 section 5.3: the packet-protection nonce is the packet
|
|
57
|
+
# number, left-padded with zeros to the IV's length, XORed with the
|
|
58
|
+
# IV — built byte-by-byte rather than via Array#pack("Q>") since a
|
|
59
|
+
# QUIC packet number can in principle need up to 8 bytes and this
|
|
60
|
+
# keeps the padding/truncation behavior explicit either way.
|
|
61
|
+
def nonce_for(iv, packet_number)
|
|
62
|
+
pn_bytes = Array.new(iv.bytesize, 0)
|
|
63
|
+
iv.bytesize.times { |i| pn_bytes[iv.bytesize - 1 - i] = (packet_number >> (8 * i)) & 0xff }
|
|
64
|
+
iv.bytes.zip(pn_bytes).map { |a, b| a ^ b }.pack("C*")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Quic
|
|
3
|
+
module Crypto
|
|
4
|
+
# QUIC header protection (RFC 9001 section 5.4): a 5-byte mask,
|
|
5
|
+
# derived from a 16-byte sample of the packet's own ciphertext, that
|
|
6
|
+
# XORs out the packet number's length bits and the packet number
|
|
7
|
+
# itself so on-path observers can't trivially correlate packet
|
|
8
|
+
# numbers across a connection. This is deliberately *not* an AEAD —
|
|
9
|
+
# it has no authentication of its own, which is why it's only ever
|
|
10
|
+
# applied on top of (never instead of) AEAD packet protection.
|
|
11
|
+
module HeaderProtection
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
SAMPLE_LENGTH = 16
|
|
15
|
+
MASK_LENGTH = 5
|
|
16
|
+
|
|
17
|
+
# RFC 9001 5.4.3 (AES-based suites) / 5.4.4 (ChaCha20-based suites).
|
|
18
|
+
def mask_for(cipher_suite, hp_key, sample)
|
|
19
|
+
if cipher_suite == :chacha20_poly1305
|
|
20
|
+
chacha20_mask(hp_key, sample)
|
|
21
|
+
else
|
|
22
|
+
aes_ecb_mask(hp_key, sample)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# RFC 9001 5.4.3: the mask is the sample AES-ECB-encrypted under the
|
|
27
|
+
# header-protection key — no IV, since ECB is only ever used here
|
|
28
|
+
# for this one fixed-size block, not as a general-purpose cipher.
|
|
29
|
+
def aes_ecb_mask(hp_key, sample)
|
|
30
|
+
cipher = ::OpenSSL::Cipher.new(hp_key.bytesize == 32 ? "aes-256-ecb" : "aes-128-ecb")
|
|
31
|
+
cipher.encrypt
|
|
32
|
+
cipher.key = hp_key
|
|
33
|
+
cipher.padding = 0
|
|
34
|
+
(cipher.update(sample) + cipher.final).byteslice(0, MASK_LENGTH)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# RFC 9001 5.4.4: the sample itself doubles as ChaCha20's 16-byte
|
|
38
|
+
# IV (a 4-byte little-endian counter followed by a 12-byte nonce —
|
|
39
|
+
# exactly the layout OpenSSL's raw "chacha20" cipher expects), used
|
|
40
|
+
# to encrypt 5 zero bytes.
|
|
41
|
+
def chacha20_mask(hp_key, sample)
|
|
42
|
+
cipher = ::OpenSSL::Cipher.new("chacha20")
|
|
43
|
+
cipher.encrypt
|
|
44
|
+
cipher.key = hp_key
|
|
45
|
+
cipher.iv = sample
|
|
46
|
+
cipher.update("\x00".b * MASK_LENGTH) + cipher.final
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Quic
|
|
3
|
+
module Crypto
|
|
4
|
+
# RFC 5869 HKDF, plus TLS 1.3's HKDF-Expand-Label construction (RFC
|
|
5
|
+
# 8446 section 7.1) that QUIC-TLS layers all of its key derivation on
|
|
6
|
+
# top of (RFC 9001 section 5). All actual cryptographic work (HMAC) is
|
|
7
|
+
# OpenSSL's — this module only assembles it into the shape the specs
|
|
8
|
+
# describe; it never implements a hash or cipher primitive itself.
|
|
9
|
+
module HKDF
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def extract(salt, ikm, hash: "SHA256")
|
|
13
|
+
::OpenSSL::HMAC.digest(hash, salt, ikm)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def expand(prk, info, length, hash: "SHA256")
|
|
17
|
+
hash_len = ::OpenSSL::Digest.new(hash).digest_length
|
|
18
|
+
blocks = (length.to_f / hash_len).ceil
|
|
19
|
+
raise ArgumentError, "length too large for HKDF-Expand" if blocks > 255
|
|
20
|
+
|
|
21
|
+
t = +""
|
|
22
|
+
okm = +""
|
|
23
|
+
(1..blocks).each do |i|
|
|
24
|
+
t = ::OpenSSL::HMAC.digest(hash, prk, t + info + [i].pack("C"))
|
|
25
|
+
okm << t
|
|
26
|
+
end
|
|
27
|
+
okm.byteslice(0, length)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# RFC 8446 section 7.1: HKDF-Expand-Label(Secret, Label, Context,
|
|
31
|
+
# Length). QUIC-TLS always uses the "tls13 " label prefix (RFC 9001
|
|
32
|
+
# section 5.1) and, for every secret Butler derives, an empty
|
|
33
|
+
# Context.
|
|
34
|
+
def expand_label(secret, label, length, context: "", hash: "SHA256")
|
|
35
|
+
full_label = "tls13 #{label}"
|
|
36
|
+
hkdf_label = [length].pack("n") <<
|
|
37
|
+
[full_label.bytesize].pack("C") << full_label.b <<
|
|
38
|
+
[context.bytesize].pack("C") << context.b
|
|
39
|
+
expand(secret, hkdf_label, length, hash: hash)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class Butler
|
|
2
|
+
module Quic
|
|
3
|
+
module Crypto
|
|
4
|
+
# QUIC-TLS key derivation for the Initial packet number space (RFC
|
|
5
|
+
# 9001 section 5.2). Initial secrets are derived from a fixed,
|
|
6
|
+
# version-specific public salt (not a real negotiated secret), so
|
|
7
|
+
# every QUIC v1 connection's Initial keys are public and the same
|
|
8
|
+
# derivation applies to every connection — which is exactly what lets
|
|
9
|
+
# RFC 9001 Appendix A publish literal test vectors for them.
|
|
10
|
+
#
|
|
11
|
+
# Handshake/1-RTT secrets (derived from the real TLS 1.3 handshake
|
|
12
|
+
# instead) are Stage 4+ work, not this module's.
|
|
13
|
+
module KeySchedule
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# RFC 9001 section 5.2's QUIC v1 Initial salt.
|
|
17
|
+
INITIAL_SALT_V1 = ["38762cf7f55934b34d179ae6a4c80cadccbb7f0a"].pack("H*").freeze
|
|
18
|
+
|
|
19
|
+
KEY_LENGTHS = {
|
|
20
|
+
aes_128_gcm: 16,
|
|
21
|
+
aes_256_gcm: 32,
|
|
22
|
+
chacha20_poly1305: 32,
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Returns { client: client_initial_secret, server: server_initial_secret }
|
|
26
|
+
# for the given Destination Connection ID (the DCID the client chose
|
|
27
|
+
# for its first Initial packet).
|
|
28
|
+
def initial_secrets(destination_connection_id)
|
|
29
|
+
initial_secret = HKDF.extract(INITIAL_SALT_V1, destination_connection_id)
|
|
30
|
+
{
|
|
31
|
+
client: HKDF.expand_label(initial_secret, "client in", 32),
|
|
32
|
+
server: HKDF.expand_label(initial_secret, "server in", 32),
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# RFC 9001 section 5.1: derives the AEAD key/iv and the
|
|
37
|
+
# header-protection key from a traffic secret, sized for
|
|
38
|
+
# +cipher_suite+.
|
|
39
|
+
def packet_protection_keys(secret, cipher_suite)
|
|
40
|
+
key_length = KEY_LENGTHS.fetch(cipher_suite)
|
|
41
|
+
{
|
|
42
|
+
key: HKDF.expand_label(secret, "quic key", key_length),
|
|
43
|
+
iv: HKDF.expand_label(secret, "quic iv", 12),
|
|
44
|
+
hp: HKDF.expand_label(secret, "quic hp", key_length),
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|