spinel-spinner 0.1.0.pre.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 192df4472958ec515d42c017b8aae2423e10a3ada0e2fc81a665d8a517b47246
4
+ data.tar.gz: 471ef455c84ff680da6f725d2ac78664fc22b147f65259e32cd1924b4aefd8dc
5
+ SHA512:
6
+ metadata.gz: c1abf1727775dec5fde5f46b967b895e0d1d246d62a746bd6d628b96962ad84ce06dd414df21471b1cc618e8eb6957c7d3a9813a74054af87fecb3dff63bbcf0
7
+ data.tar.gz: 225b0564014e7a341506c71db3d66c8f753fee28558fccb01dd19af4c54890b1becd6946e08a0aae0ffca177bd4d83931e477eb32f98c77ae0f3e50ada11afd3
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ ## [Unreleased]
2
+
3
+ - Rename the distribution to `spinel-spinner` while retaining `require "spinner"`
4
+ and the `Spinner` namespace.
5
+
6
+ ## [0.1.0.pre.1] - 2026-10-28
7
+
8
+ - Prepare the first public preview of the packaged Spinel HTTP server.
9
+ - Parse bracketed Forwarded IPv6 addresses without storing a MatchData value,
10
+ avoiding a Spinel C type mismatch when compiled with Hamster.
11
+
12
+ ## [0.1.0] - 2026-10-18
13
+
14
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dillon Wyatt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # Spinner
2
+
3
+ Spinner is a native Spinel HTTP/1.1 server. An application implements `call(env)`
4
+ and returns `[status, headers, body]`. Compile the application with the packaged
5
+ library, then run the resulting executable:
6
+
7
+ ```ruby
8
+ require "spinner"
9
+
10
+ class Application
11
+ def call(env)
12
+ [200, {"content-type" => "text/plain"}, ["Hello"]]
13
+ end
14
+ end
15
+
16
+ Spinner::Server.new(Application.new, host: "127.0.0.1", port: 4000).run
17
+ ```
18
+
19
+ ```sh
20
+ spinel app.rb --require-gate -I lib -o app
21
+ ./app
22
+ ```
23
+
24
+ The environment includes request method, path, query, protocol, validated Host,
25
+ content headers, normalized `HTTP_*` headers, `REMOTE_ADDR`, `rack.url_scheme`,
26
+ and binary `rack.input`. Input supports `read(length = nil, buffer = nil)`,
27
+ `gets`, `each`, `rewind`, and `close`. Dispatch waits for the complete request
28
+ body. Input and temporary storage belong to the request and are closed when its
29
+ response finishes. Calling `read` without a length explicitly allocates the
30
+ remaining body; consume with sized reads to keep application memory bounded.
31
+
32
+ Headers use lowercase names and string or string-array values. Arrays emit
33
+ separate fields, including `set-cookie`. Response framing belongs to Spinner.
34
+ An array body produces a Content-Length response; an enumerable body streams
35
+ with HTTP/1.1 chunking (HTTP/1.0 streams close the connection). HEAD closes the
36
+ body without enumerating streams. Finite HTTP/1.1 streams preserve keep-alive
37
+ and pipelining. Body `close` runs after completion, errors, or disconnection.
38
+
39
+ | Option | Default | Behavior |
40
+ | --- | --- | --- |
41
+ | `max_body_bytes` | 1 MiB | Decoded body limit for both framing modes; overflow returns 413 |
42
+ | `body_memory_bytes` | 64 KiB | Spill threshold, between zero and body limit |
43
+ | `spool_directory` | `TMPDIR` or `/tmp` | Exclusive binary files, mode 0600 |
44
+ | `spool_budget_bytes` | 128 MiB | Aggregate server disk budget; divided evenly across worker processes |
45
+ | `storage_workers` | 2 | Disk workers per process; socket reads pause during pending writes |
46
+ | `max_header_bytes` | 32 KiB | Request header limit |
47
+ | `header_timeout`, `body_timeout`, `write_timeout` | 5 seconds each | Ordinary network deadlines |
48
+ | `stream_idle_timeout` | 30 seconds | Stream/upgrade transport idle deadline, refreshed on progress |
49
+ | `max_streams`, `max_upgrades` | 32 each | Separate execution/admission limits per process; excess returns 503 before headers |
50
+ | `trusted_proxies` | `[]` | IPv4/IPv6 CIDRs allowed to supply standardized Forwarded metadata |
51
+ | `threads`, `queue_capacity`, `max_connections` | 4, 64, 128 | Application workers, queued work, accepted connections per process |
52
+ | `queue_timeout`, `keepalive_timeout` | 5 seconds each | Admission wait and idle connection deadlines |
53
+ | `max_requests_per_connection` | 100 | Keep-alive request ceiling |
54
+ | `drain_timeout` | 10 seconds | Shutdown deadline |
55
+ | `worker_processes` | 1 | Supervised TCP workers; HUP rolls them one at a time |
56
+ | `reactor_heartbeat` | `false` | Emit the once-per-second `SPINNER_METRIC event=reactor` snapshot |
57
+ | `request_metrics` | `false` | Emit routine `queued`, `start`, and `finish` request metrics |
58
+ | `unix_path`, `listener` | `nil` | Unix socket or supplied listener, mutually exclusive |
59
+ | `tls_cert`, `tls_key` | `nil` | PEM chain/key pair for TCP TLS |
60
+
61
+ Spool exhaustion and filesystem failures return 503 and release storage.
62
+ Transport, application, stream, storage, admission, worker, TLS, and shutdown
63
+ metrics go to stderr as `SPINNER_METRIC event=...` records. Unix sockets have
64
+ an empty remote address. Forwarded fields are ignored for untrusted immediate
65
+ peers. Trusted chains are validated in full, then walked from the nearest peer
66
+ until the first untrusted address. Malformed chains do not change identity or
67
+ scheme; X-Forwarded-* fields never change them.
68
+
69
+ The periodic reactor heartbeat and routine request metrics are disabled by
70
+ default. Enable them in Ruby with `reactor_heartbeat: true` and
71
+ `request_metrics: true`, or with `SPINNER_REACTOR_HEARTBEAT=1` and
72
+ `SPINNER_REQUEST_METRICS=1`. Request metrics include queue admission and
73
+ worker/stream lifecycle events; failure, rejection, worker, and shutdown
74
+ metrics remain enabled.
75
+
76
+ TLS loads libssl when requested, checks chain/key loading and key matching,
77
+ permits TLS 1.2/1.3, and selects only `http/1.1` via ALPN. Clients can omit ALPN.
78
+ Set `SPINNER_SSL_LIBRARY` to a matching OpenSSL shared library if needed.
79
+
80
+ TERM/INT stop admission, close idle and partial clients, and allow active work
81
+ to finish within the drain deadline. HUP in multiprocess mode rolls workers.
82
+ Unexpected worker exits trigger replacement; more than five unexpected exits
83
+ in ten seconds stop the supervisor. Workers detect parent death. Network
84
+ timeouts do not safely interrupt arbitrary application or enumerator code.
85
+ A timed-out enumerator retains its bounded execution slot until it returns or
86
+ the worker exits. Recover stuck application code by replacing the process.
87
+ Abruptly killing an unsupervised process can leave a named spool file; a
88
+ supervisor removes its dead workers' spool files.
89
+
90
+ Optional WebSockets use the bundled adapter:
91
+
92
+ ```ruby
93
+ require "spinner/websocket"
94
+
95
+ class Echo
96
+ def call(session)
97
+ message = session.read_message # [opcode, binary bytes], or nil after close
98
+ session.write_message(message[0], message[1]) unless message.nil?
99
+ session.close
100
+ end
101
+ end
102
+
103
+ class Application
104
+ def call(env)
105
+ [101, {"upgrade" => "websocket"}, Echo.new]
106
+ end
107
+ end
108
+
109
+ application = Spinner::WebSocket::Adapter.new(Application.new,
110
+ max_message_bytes: 1048576, idle_timeout: 30.0)
111
+ Spinner::Server.new(application).run
112
+ ```
113
+
114
+ The adapter validates handshakes before application dispatch, bounds messages,
115
+ validates masking and UTF-8 text, reassembles fragmentation, handles ping/pong,
116
+ and exchanges close frames. It does not negotiate extensions or subprotocols.
117
+ The core upgrade API accepts `[101, headers, Spinner::Upgrade.new { |transport,
118
+ buffered_bytes| ... }]`. The callback owns the transport until it returns;
119
+ Spinner keeps the connection counted and closes it on return or shutdown.
120
+
121
+ Supported methods are GET, HEAD, POST, PUT, PATCH, DELETE, and OPTIONS, including
122
+ `OPTIONS *`. Absolute targets must match Host and transport scheme. TRACE and
123
+ CONNECT are disabled. Duplicate Host/framing/singleton fields, normalization
124
+ collisions, trailers, and ambiguous framing are rejected. Ordinary repeated
125
+ fields combine with commas; Cookie uses semicolons. Routing, multipart/form
126
+ parsing, authentication, and middleware belong to the application. Full Rack
127
+ compatibility, HTTP/2, mTLS, SNI certificate selection, early hints, sendfile,
128
+ and Puma CLI parity remain outside this contract.
129
+
130
+ The reference dependency is **Puma 8.0.2**. Tests build and unpack the gem,
131
+ compile basic startup and native component probes separately from the expanded
132
+ application fixture, and exercise native executables over sockets:
133
+
134
+ For a package-level preview, build and install the gem, then compile with the
135
+ installed library path. Spinel is a separate compiler prerequisite:
136
+
137
+ ```sh
138
+ gem build spinel-spinner.gemspec
139
+ gem install --local spinel-spinner-0.1.0.pre.1.gem
140
+ spinel app.rb --require-gate -I "$(ruby -e 'puts Gem::Specification.find_by_name("spinel-spinner").full_gem_path')/lib" -o app
141
+ ```
142
+
143
+ The gem is named `spinel-spinner`; application code uses `require "spinner"`
144
+ and the `Spinner` namespace. In a Gemfile, use
145
+ `gem "spinel-spinner", require: "spinner"` when using Bundler's automatic requires.
146
+
147
+ The installed gem provides Ruby source to the compiler; it does not provide a
148
+ precompiled server executable. Applications still require a Spinel compilation
149
+ step for their own entrypoint.
150
+
151
+ ```sh
152
+ bundle install
153
+ bundle exec rake test
154
+ bundle exec rake soak
155
+ ```
156
+
157
+ The GitHub Actions workflow is manual-only for now. It uses a GitHub-hosted
158
+ Ubuntu runner, installs the native test tools, and builds a pinned Spinel
159
+ revision. Select **Run workflow** in the Actions tab; enable the `soak` input to
160
+ run the longer resource and restart checks. Locally, provide Spinel, a C
161
+ compiler, OpenSSL, and Nginx on `PATH`. `KEEP_SPINNER_TEST_ARTIFACTS=1` retains
162
+ build and process artifacts; failed startup/process logs are retained
163
+ automatically. See
164
+ [test/COVERAGE.md](test/COVERAGE.md) for evidence and remaining release gates.
@@ -0,0 +1,106 @@
1
+ module Spinner
2
+ # One budget per worker process. Reservations include every byte in a spool.
3
+ class StorageBudget
4
+ def initialize(limit)
5
+ @limit = limit
6
+ @used = 0
7
+ @lock = Mutex.new
8
+ end
9
+
10
+ def reserve(bytes)
11
+ @lock.synchronize do
12
+ if @used + bytes > @limit
13
+ warn "SPINNER_METRIC event=spool_exhausted bytes=#{@used} limit=#{@limit}"
14
+ raise RequestError.new(503, "spool budget exhausted")
15
+ end
16
+ @used += bytes
17
+ end
18
+ end
19
+
20
+ def release(bytes)
21
+ @lock.synchronize { @used -= bytes }
22
+ end
23
+ end
24
+
25
+ class BodyStorage
26
+ attr_reader :size
27
+
28
+ def initialize(maximum, threshold, directory, budget)
29
+ @maximum = maximum
30
+ @threshold = threshold
31
+ @directory = directory
32
+ @budget = budget
33
+ @bytes = "".b
34
+ @file = nil
35
+ @path = nil
36
+ @reserved = 0
37
+ @size = 0
38
+ @closed = false
39
+ end
40
+
41
+ def append(bytes)
42
+ raise IOError, "closed body" if @closed
43
+ raise RequestError.new(413, "request body too large") if @size + bytes.bytesize > @maximum
44
+ if @file.nil? && @size + bytes.bytesize > @threshold
45
+ # Exclusive creation and mode 0600 prevent following an existing path.
46
+ 20.times do
47
+ @path = File.join(@directory, "spinner-#{Process.pid}-#{rand(1_000_000_000)}.body")
48
+ begin
49
+ @file = File.open(@path, File::RDWR | File::CREAT | File::EXCL, 0600)
50
+ @file.binmode
51
+ break
52
+ rescue Errno::EEXIST
53
+ @path = nil
54
+ end
55
+ end
56
+ raise IOError, "cannot create spool" if @file.nil?
57
+ reserve(@bytes.bytesize)
58
+ @file.write(@bytes)
59
+ @bytes = "".b
60
+ warn "SPINNER_METRIC event=body_spill pid=#{Process.pid}"
61
+ end
62
+ if @file.nil?
63
+ @bytes << bytes
64
+ else
65
+ reserve(bytes.bytesize)
66
+ @file.write(bytes)
67
+ end
68
+ @size += bytes.bytesize
69
+ rescue SystemCallError, IOError => error
70
+ close
71
+ raise RequestError.new(503, "body storage unavailable: #{error.message}")
72
+ end
73
+
74
+ def reserve(bytes)
75
+ @budget.reserve(bytes)
76
+ @reserved += bytes
77
+ end
78
+
79
+ def input
80
+ if @file.nil?
81
+ Input.new(@bytes)
82
+ else
83
+ @file.flush
84
+ @file.rewind
85
+ Input.new("".b, @file)
86
+ end
87
+ end
88
+
89
+ def close
90
+ return if @closed
91
+ @closed = true
92
+ warn "SPINNER_METRIC event=body_cleanup bytes=#{@size} disk_bytes=#{@reserved}" if @reserved > 0
93
+ begin
94
+ @file.close unless @file.nil? || @file.closed?
95
+ ensure
96
+ File.unlink(@path) if !@path.nil? && File.exist?(@path)
97
+ end
98
+ ensure
99
+ if @reserved > 0
100
+ @budget.release(@reserved)
101
+ @reserved = 0
102
+ end
103
+ @bytes = "".b
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,72 @@
1
+ module Spinner
2
+ # Incremental HTTP/1.1 chunk framing. Only decoded bytes count toward the
3
+ # body limit; trailer fields are deliberately rejected until modeled safely.
4
+ class ChunkDecoder
5
+ attr_reader :remainder
6
+
7
+ def initialize(storage, maximum = RequestParser::MAX_BODY_BYTES)
8
+ @storage = storage
9
+ @maximum = maximum
10
+ @buffer = "".b
11
+ @size = 0
12
+ @remaining = 0
13
+ @state = :size
14
+ @remainder = "".b
15
+ end
16
+
17
+ def complete?
18
+ @state == :done
19
+ end
20
+
21
+ def body
22
+ @storage.input
23
+ end
24
+
25
+ def feed(bytes)
26
+ raise RequestError.new(400, "chunk body already complete") if complete?
27
+ @buffer << bytes
28
+ loop do
29
+ case @state
30
+ when :size
31
+ boundary = @buffer.index("\r\n")
32
+ raise RequestError.new(400, "chunk size line too long") if boundary.nil? && @buffer.bytesize > 1024
33
+ break if boundary.nil?
34
+ raise RequestError.new(400, "chunk size line too long") if boundary > 1024
35
+ line = @buffer.slice!(0, boundary + 2).byteslice(0, boundary)
36
+ token = line.split(";", 2).first
37
+ raise RequestError.new(400, "invalid chunk size") unless token.match?(/\A[0-9a-fA-F]+\z/)
38
+ raise RequestError.new(400, "invalid chunk extension") if line.match?(/[\x00-\x1f\x7f]/)
39
+ digits = token.downcase.sub(/\A0+/, "")
40
+ digits = "0" if digits.empty?
41
+ limit = (@maximum - @size).to_s(16)
42
+ if digits.length > limit.length || (digits.length == limit.length && digits > limit)
43
+ raise RequestError.new(413, "request body exceeds configured limit")
44
+ end
45
+ @remaining = digits.to_i(16)
46
+ @state = @remaining == 0 ? :trailers : :data
47
+ when :data
48
+ break if @buffer.empty?
49
+ count = [@remaining, @buffer.bytesize].min
50
+ part = @buffer.slice!(0, count).b
51
+ @storage.append(part)
52
+ @size += count
53
+ @remaining -= count
54
+ @state = :terminator if @remaining == 0
55
+ when :terminator
56
+ break if @buffer.bytesize < 2
57
+ raise RequestError.new(400, "invalid chunk terminator") unless @buffer.start_with?("\r\n")
58
+ @buffer.slice!(0, 2)
59
+ @state = :size
60
+ when :trailers
61
+ break if @buffer.bytesize < 2
62
+ raise RequestError.new(400, "chunk trailers unsupported") unless @buffer.start_with?("\r\n")
63
+ @remainder = @buffer.byteslice(2, @buffer.bytesize - 2).to_s.b
64
+ @buffer = "".b
65
+ @state = :done
66
+ break
67
+ end
68
+ end
69
+ complete?
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,86 @@
1
+ module Spinner
2
+ # Host is mandatory in this HTTP/1.1 subset. Validate without DNS lookup.
3
+ class HostAuthority
4
+ attr_reader :name
5
+
6
+ def initialize(authority)
7
+ @name = ""
8
+ if authority.start_with?("[")
9
+ closing = authority.index("]")
10
+ raise RequestError.new(400, "invalid Host literal") if closing.nil?
11
+ literal = authority.byteslice(1, closing - 1)
12
+ unless ipv6?(literal)
13
+ raise RequestError.new(400, "invalid Host IPv6 address")
14
+ end
15
+ @name = authority.byteslice(0, closing + 1)
16
+ suffix = authority.byteslice(closing + 1, authority.bytesize - closing - 1)
17
+ unless suffix.empty?
18
+ raise RequestError.new(400, "invalid Host authority") unless suffix.start_with?(":")
19
+ validate_port(suffix.delete_prefix(":"))
20
+ end
21
+ else
22
+ parts = authority.split(":", 2)
23
+ @name = parts[0].to_s
24
+ hostname = @name.delete_suffix(".")
25
+ if hostname.empty? || hostname.bytesize > 253 || hostname.end_with?(".")
26
+ raise RequestError.new(400, "missing or invalid Host")
27
+ end
28
+ hostname.split(".").each do |label|
29
+ unless label.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/)
30
+ raise RequestError.new(400, "invalid Host name")
31
+ end
32
+ end
33
+ if hostname.match?(/\A[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\z/)
34
+ raise RequestError.new(400, "invalid Host IPv4 address") unless ipv4?(hostname)
35
+ end
36
+ validate_port(parts[1]) if parts.length == 2
37
+ end
38
+ end
39
+
40
+ def validate_port(port)
41
+ unless port.match?(/\A[0-9]{1,5}\z/) && port.to_i >= 1 && port.to_i <= 65535
42
+ raise RequestError.new(400, "invalid Host port")
43
+ end
44
+ end
45
+
46
+ def ipv4?(address)
47
+ parts = address.split(".", -1)
48
+ return false unless parts.length == 4
49
+ parts.each do |part|
50
+ return false unless part.match?(/\A(?:0|[1-9][0-9]{0,2})\z/) && part.to_i <= 255
51
+ end
52
+ true
53
+ end
54
+
55
+ # Validate groups without constructing a 128-bit integer: the bundled
56
+ # IPAddr overflows on addresses such as 2001:db8::1 in this Spinel revision.
57
+ def ipv6?(address)
58
+ text = address
59
+ if text.include?(".")
60
+ colon = text.rindex(":")
61
+ return false if colon.nil?
62
+ tail = text.byteslice(colon + 1, text.bytesize - colon - 1)
63
+ return false unless ipv4?(tail)
64
+ # An IPv4 tail occupies two hexadecimal groups.
65
+ text = text.byteslice(0, colon + 1) + "0:0"
66
+ end
67
+ halves = text.split("::", -1)
68
+ return false if halves.length > 2
69
+ groups = []
70
+ halves.each do |half|
71
+ next if half.empty?
72
+ return false if half.start_with?(":") || half.end_with?(":")
73
+ groups += half.split(":")
74
+ end
75
+ if halves.length == 2
76
+ return false unless groups.length < 8
77
+ else
78
+ return false unless groups.length == 8
79
+ end
80
+ groups.each do |group|
81
+ return false unless group.match?(/\A[0-9A-Fa-f]{1,4}\z/)
82
+ end
83
+ true
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,58 @@
1
+ require "stringio"
2
+
3
+ module Spinner
4
+ # Both backends expose binary IO semantics without copying a spool into RAM.
5
+ class Input
6
+ def initialize(bytes, file = nil)
7
+ @file = file
8
+ @memory = file.nil? ? StringIO.new(bytes.b, "rb") : nil
9
+ @closed = false
10
+ end
11
+
12
+ def read(length = nil, buffer = nil)
13
+ raise IOError, "closed input" if @closed
14
+ result = if @file.nil?
15
+ length.nil? ? @memory.read : @memory.read(length)
16
+ else
17
+ length.nil? ? @file.read : @file.read(length)
18
+ end
19
+ buffer.replace(result.to_s.b) unless buffer.nil?
20
+ result.nil? ? nil : (buffer.nil? ? result.b : buffer)
21
+ end
22
+
23
+ def gets(separator = "\n", limit = nil)
24
+ raise IOError, "closed input" if @closed
25
+ result = if @file.nil?
26
+ limit.nil? ? @memory.gets(separator) : @memory.gets(separator, limit)
27
+ else
28
+ limit.nil? ? @file.gets(separator) : @file.gets(separator, limit)
29
+ end
30
+ result.nil? ? nil : result.b
31
+ end
32
+
33
+ def each
34
+ return enum_for(:each) unless block_given?
35
+ while (line = gets)
36
+ yield line
37
+ end
38
+ self
39
+ end
40
+
41
+ def rewind
42
+ raise IOError, "closed input" if @closed
43
+ @file.nil? ? @memory.rewind : @file.rewind
44
+ 0
45
+ end
46
+
47
+ def close
48
+ return if @closed
49
+ @closed = true
50
+ if @file.nil?
51
+ @memory.close
52
+ else
53
+ @file.close unless @file.closed?
54
+ end
55
+ nil
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,93 @@
1
+ module Spinner
2
+ class ProxyMetadata
3
+ def initialize(proxies)
4
+ raise ArgumentError, "trusted_proxies must be an array of CIDRs" unless proxies.is_a?(Array) && proxies.all? { |proxy| proxy.is_a?(String) }
5
+ @networks = proxies.map do |proxy|
6
+ address, prefix = proxy.split("/", 2)
7
+ bytes = address_bytes(address)
8
+ raise ArgumentError, "invalid trusted proxy" if bytes.nil?
9
+ bits = prefix.nil? ? bytes.length * 8 : prefix.to_i
10
+ if (!prefix.nil? && !prefix.match?(/\A[0-9]+\z/)) || bits < 0 || bits > bytes.length * 8
11
+ raise ArgumentError, "invalid proxy prefix"
12
+ end
13
+ [bytes, bits]
14
+ end
15
+ end
16
+
17
+ def address_bytes(address)
18
+ validator = HostAuthority.allocate
19
+ if validator.ipv4?(address)
20
+ return address.split(".").map(&:to_i)
21
+ end
22
+ return nil unless validator.ipv6?(address)
23
+ text = address
24
+ if text.include?(".")
25
+ index = text.rindex(":")
26
+ tail = text.byteslice(index + 1, text.bytesize).split(".").map(&:to_i)
27
+ text = text.byteslice(0, index + 1) + ((tail[0] << 8) | tail[1]).to_s(16) + ":" + ((tail[2] << 8) | tail[3]).to_s(16)
28
+ end
29
+ halves = text.split("::", -1)
30
+ left = halves[0].empty? ? [] : halves[0].split(":")
31
+ right = halves.length == 2 && !halves[1].empty? ? halves[1].split(":") : []
32
+ groups = halves.length == 2 ? left + Array.new(8 - left.length - right.length, "0") + right : left
33
+ bytes = []
34
+ groups.each { |group| value = group.to_i(16); bytes << (value >> 8); bytes << (value & 255) }
35
+ bytes
36
+ end
37
+
38
+ def trusted?(address)
39
+ bytes = address_bytes(address)
40
+ return false if bytes.nil?
41
+ @networks.any? do |network, bits|
42
+ next false unless bytes.length == network.length
43
+ whole = bits / 8
44
+ rest = bits % 8
45
+ bytes.take(whole) == network.take(whole) && (rest == 0 || (bytes[whole] >> (8 - rest)) == (network[whole] >> (8 - rest)))
46
+ end
47
+ end
48
+
49
+ def apply(env, peer)
50
+ env["REMOTE_ADDR"] = peer
51
+ return env unless trusted?(peer) && env.key?("HTTP_FORWARDED")
52
+ chain = []
53
+ env["HTTP_FORWARDED"].split(",").each do |element|
54
+ fields = {}
55
+ element.split(";").each do |part|
56
+ key, value = part.strip.split("=", 2)
57
+ return env if value.nil? || fields.key?(key.downcase)
58
+ if value.start_with?('"') && value.end_with?('"')
59
+ value = value.byteslice(1, value.bytesize - 2)
60
+ end
61
+ return env if value.include?('"') || value.include?("\\")
62
+ fields[key.downcase] = value
63
+ end
64
+ address = fields["for"]
65
+ return env if address.nil?
66
+ if address.start_with?("[")
67
+ return env unless address.match?(/\A\[([^\]]+)\](?::[0-9]{1,5})?\z/)
68
+ closing = address.index("]")
69
+ address = address.byteslice(1, closing - 1)
70
+ elsif address.include?(":")
71
+ return env unless address.match?(/\A[0-9.]+:[0-9]{1,5}\z/)
72
+ address = address.split(":")[0]
73
+ end
74
+ return env if address_bytes(address).nil?
75
+ proto = fields["proto"]
76
+ return env if !proto.nil? && !["http", "https"].include?(proto)
77
+ chain << [address, proto]
78
+ end
79
+ current = peer
80
+ selected = nil
81
+ chain.reverse_each do |entry|
82
+ break unless trusted?(current)
83
+ selected = entry
84
+ current = entry[0]
85
+ end
86
+ unless selected.nil?
87
+ env["REMOTE_ADDR"] = selected[0]
88
+ env["rack.url_scheme"] = selected[1] unless selected[1].nil?
89
+ end
90
+ env
91
+ end
92
+ end
93
+ end