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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/lib/spinner/body_storage.rb +106 -0
- data/lib/spinner/chunk_decoder.rb +72 -0
- data/lib/spinner/host_authority.rb +86 -0
- data/lib/spinner/input.rb +58 -0
- data/lib/spinner/proxy_metadata.rb +93 -0
- data/lib/spinner/reactor.rb +575 -0
- data/lib/spinner/request_parser.rb +183 -0
- data/lib/spinner/response_writer.rb +123 -0
- data/lib/spinner/server.rb +384 -0
- data/lib/spinner/task_pool.rb +62 -0
- data/lib/spinner/thread_pool.rb +229 -0
- data/lib/spinner/tls.rb +116 -0
- data/lib/spinner/transport_socket.rb +121 -0
- data/lib/spinner/upgrade.rb +20 -0
- data/lib/spinner/version.rb +5 -0
- data/lib/spinner/websocket.rb +179 -0
- data/lib/spinner.rb +21 -0
- data/sig/spinner.rbs +4 -0
- metadata +63 -0
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
|