hyperion-rb 1.4.2 → 1.5.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/CHANGELOG.md +27 -0
- data/README.md +11 -0
- data/lib/hyperion/adapter/rack.rb +11 -1
- data/lib/hyperion/cli.rb +112 -41
- data/lib/hyperion/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab7691ac6671b0e0c9606c281c55659c76675b71f3d461d1fb5bf6a03680861b
|
|
4
|
+
data.tar.gz: b7ad35585d56e59d4a7b5c9fcb6d4e016e72b4c3f99496ba675ca7e871865718
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8911a91c7932b332a9d5f069099c7f6ded94d9b5978dffd259881ab482066d5328c508ca5983101c6d9d04b18c1353664766bf759ec66cb034d3bcdf84f01a89
|
|
7
|
+
data.tar.gz: 7c948c98eb9aea2cb31595e08deca0c4e98c2281105a18fc0419678da25a04ac9b04f9defe564ea660104cf935399582506eb87769f6f9fbbca74f568c8f904b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.0] - 2026-04-27
|
|
4
|
+
|
|
5
|
+
Audit-driven CLI + adapter polish. No breaking changes; pure additions to the operator surface and a hardening of the host-header parser.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **CLI flag coverage for 8 Config DSL settings.** Pre-1.5.0 these settings could only be reached by writing a `config/hyperion.rb` file; operators who don't keep one in their repo had no way to flip them without authoring one. They now flow through the same CLI > config-file > default precedence as the rest of the flags:
|
|
9
|
+
- `--max-body-bytes BYTES` (Integer, default 16 MiB)
|
|
10
|
+
- `--max-header-bytes BYTES` (Integer, default 64 KiB)
|
|
11
|
+
- `--max-pending COUNT` (Integer, default unbounded)
|
|
12
|
+
- `--max-request-read-seconds SECONDS` (Float, default 60)
|
|
13
|
+
- `--admin-token TOKEN` (String, default unset) — gates `POST /-/quit` and `GET /-/metrics`
|
|
14
|
+
- `--admin-token-file PATH` — sibling that reads the token from disk; refuses to load if the file is missing, unreadable, world-readable (perms must mask `0o007`), or empty. Production deployments should prefer this over `--admin-token` because argv is visible via `ps`.
|
|
15
|
+
- `--worker-max-rss-mb MB` (Integer, default unset) — RSS-based worker recycling
|
|
16
|
+
- `--idle-keepalive SECONDS` (Float, default 5)
|
|
17
|
+
- `--graceful-timeout SECONDS` (Integer, default 30)
|
|
18
|
+
- **`Hyperion::CLI.parse_argv!` extracted as a public class method** so the flag-to-`cli_opts` mapping is unit-testable without booting a server. `CLI.run` is now a thin wrapper around it.
|
|
19
|
+
- **README CLI flags table** extended with the 8 new flags plus `--[no-]yjit` / `--[no-]async-io` (already wired but previously undocumented in the table).
|
|
20
|
+
- **17 new specs**:
|
|
21
|
+
- 14 in `spec/hyperion/cli_flags_spec.rb` cover per-flag parsing, the `merge_cli!` handoff for all 8 new flags, the CLI-wins precedence rule, and the four `--admin-token-file` abort paths (missing / unreadable / world-readable / empty).
|
|
22
|
+
- 3 in `spec/hyperion/adapter/rack_spec.rb` cover plain IPv4-with-port, bare hostname (no port), and the malformed-bracket regression below.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **`Hyperion::Adapter::Rack#split_host` accepted malformed bracketed IPv6.** Pre-1.5.0 a `Host: [::1` header (no closing bracket) was returned as-is in `SERVER_NAME`, leaking attacker-controlled bytes into Rack env where downstream URL generators / SSRF allow-lists / audit logs would trust them. The adapter now fails closed to `localhost:80` and bumps a `:malformed_host_header` counter so operators can alert on attack-pattern volume. No raise — Rack apps don't expect a server adapter to throw on header-parse failures, so we degrade gracefully instead.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
- `--admin-token` help text warns that argv is visible via `ps` and points operators at `--admin-token-file` for production. The token value is never echoed back in any log line.
|
|
29
|
+
|
|
3
30
|
## [1.4.2] - 2026-04-27
|
|
4
31
|
|
|
5
32
|
Audit-driven cleanup. No behaviour changes; fiber-correctness + docs polish.
|
data/README.md
CHANGED
|
@@ -255,6 +255,17 @@ Three layers, in precedence order: explicit CLI flag > environment variable > `c
|
|
|
255
255
|
| `--log-format FORMAT` | `auto` | `text` / `json` / `auto`. Auto: JSON when `RAILS_ENV`/`RACK_ENV` is `production`/`staging`, colored text on TTY, JSON otherwise. |
|
|
256
256
|
| `--[no-]log-requests` | ON | Per-request access log. |
|
|
257
257
|
| `--fiber-local-shim` | off | Patches `Thread#thread_variable_*` to fiber storage for older Rails idioms. |
|
|
258
|
+
| `--[no-]yjit` | auto | Force YJIT on/off. Default: auto-on under `RAILS_ENV`/`RACK_ENV` = `production`/`staging`. |
|
|
259
|
+
| `--[no-]async-io` | off | Run plain HTTP/1.1 connections under `Async::Scheduler`. Required for `hyperion-async-pg` on plain HTTP. TLS h1 / HTTP/2 always run under the scheduler regardless. |
|
|
260
|
+
| `--max-body-bytes BYTES` | `16777216` (16 MiB) | Maximum request body size. |
|
|
261
|
+
| `--max-header-bytes BYTES` | `65536` (64 KiB) | Maximum total request-header size. |
|
|
262
|
+
| `--max-pending COUNT` | unbounded | Per-worker accept-queue cap before new connections are rejected with HTTP 503 + `Retry-After: 1`. |
|
|
263
|
+
| `--max-request-read-seconds SECONDS` | `60` | Total wallclock budget for reading request line + headers + body for ONE request. Slowloris defence. |
|
|
264
|
+
| `--admin-token TOKEN` | unset | Bearer token for `POST /-/quit` and `GET /-/metrics`. **Production: prefer `--admin-token-file` — argv is visible via `ps`.** |
|
|
265
|
+
| `--admin-token-file PATH` | unset | Read the admin token from a file. Refuses to load if the file is missing or world-readable (mode must mask `0o007`). |
|
|
266
|
+
| `--worker-max-rss-mb MB` | unset | Master gracefully recycles a worker once its RSS exceeds this many megabytes. nil = disabled. |
|
|
267
|
+
| `--idle-keepalive SECONDS` | `5` | Keep-alive idle timeout. Connection closes after this many seconds of inactivity. |
|
|
268
|
+
| `--graceful-timeout SECONDS` | `30` | Shutdown deadline before SIGKILL is delivered to a worker that hasn't drained. |
|
|
258
269
|
|
|
259
270
|
### Environment variables
|
|
260
271
|
|
|
@@ -138,7 +138,17 @@ module Hyperion
|
|
|
138
138
|
|
|
139
139
|
if host_header.start_with?('[')
|
|
140
140
|
close = host_header.index(']')
|
|
141
|
-
|
|
141
|
+
# Malformed bracketed IPv6 (no closing bracket): we used to return
|
|
142
|
+
# the raw garbage as SERVER_NAME, which then leaked into Rack env
|
|
143
|
+
# where downstream URL generators / loggers / SSRF allow-lists
|
|
144
|
+
# would trust attacker-controlled bytes. Fail closed to a safe
|
|
145
|
+
# default and bump a counter so operators can alert on volume.
|
|
146
|
+
# No raise — Rack apps don't expect Hyperion's adapter to throw
|
|
147
|
+
# on header-parse failures, so we degrade gracefully instead.
|
|
148
|
+
unless close
|
|
149
|
+
Hyperion.metrics.increment(:malformed_host_header)
|
|
150
|
+
return %w[localhost 80]
|
|
151
|
+
end
|
|
142
152
|
|
|
143
153
|
name = host_header[0..close]
|
|
144
154
|
rest = host_header[(close + 1)..]
|
data/lib/hyperion/cli.rb
CHANGED
|
@@ -11,6 +11,55 @@ module Hyperion
|
|
|
11
11
|
DEFAULT_CONFIG_PATH = 'config/hyperion.rb'
|
|
12
12
|
|
|
13
13
|
def self.run(argv)
|
|
14
|
+
cli_opts, config_path = parse_argv!(argv)
|
|
15
|
+
|
|
16
|
+
# Precedence: CLI > config file > built-in default. We auto-load
|
|
17
|
+
# config/hyperion.rb if present so operators can drop a file in their
|
|
18
|
+
# repo and have it take effect without having to remember -C.
|
|
19
|
+
config_path ||= DEFAULT_CONFIG_PATH if File.exist?(DEFAULT_CONFIG_PATH)
|
|
20
|
+
config = config_path ? Hyperion::Config.load(config_path) : Hyperion::Config.new
|
|
21
|
+
config.merge_cli!(cli_opts)
|
|
22
|
+
|
|
23
|
+
# Install logger early so every subsequent log call honours the operator's
|
|
24
|
+
# chosen format/level (config file or CLI) before anything else logs.
|
|
25
|
+
if config.log_level || config.log_format
|
|
26
|
+
Hyperion.logger = Hyperion::Logger.new(level: config.log_level, format: config.log_format)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Propagate log_requests so every Connection picks it up via
|
|
30
|
+
# `Hyperion.log_requests?` without needing to thread it through
|
|
31
|
+
# Server/ThreadPool/Master plumbing. Default is ON; nil means "don't
|
|
32
|
+
# touch — fall through to the env/default chain in Hyperion.log_requests?".
|
|
33
|
+
Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
|
|
34
|
+
|
|
35
|
+
# Enable YJIT before workers fork / connections start. Auto-on in
|
|
36
|
+
# production/staging gives operators the perf bump for free; explicit
|
|
37
|
+
# config.yjit (true/false) overrides the env-based default.
|
|
38
|
+
maybe_enable_yjit(config)
|
|
39
|
+
|
|
40
|
+
rackup = argv.first || 'config.ru'
|
|
41
|
+
abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
|
|
42
|
+
|
|
43
|
+
if config.fiber_local_shim
|
|
44
|
+
Hyperion::FiberLocal.install!
|
|
45
|
+
Hyperion.logger.info { { message: 'FiberLocal shim installed' } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
app = load_rack_app(rackup)
|
|
49
|
+
app = wrap_admin_middleware(app, config)
|
|
50
|
+
workers = config.workers.zero? ? Etc.nprocessors : config.workers
|
|
51
|
+
|
|
52
|
+
if workers <= 1
|
|
53
|
+
run_single(config, app)
|
|
54
|
+
else
|
|
55
|
+
run_cluster(config, app, workers)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extracted from #run so the flag-to-cli_opts mapping can be unit-tested
|
|
60
|
+
# without booting a server. Returns [cli_opts, config_path]. Mutates argv
|
|
61
|
+
# in place (consumes flags, leaves the rackup path for the caller).
|
|
62
|
+
def self.parse_argv!(argv)
|
|
14
63
|
cli_opts = {}
|
|
15
64
|
config_path = nil
|
|
16
65
|
|
|
@@ -61,6 +110,46 @@ module Hyperion
|
|
|
61
110
|
'Run plain HTTP/1.1 connections under Async::Scheduler (required for hyperion-async-pg and other fiber-cooperative I/O; default off)') do |v|
|
|
62
111
|
cli_opts[:async_io] = v
|
|
63
112
|
end
|
|
113
|
+
o.on('--max-body-bytes BYTES', Integer,
|
|
114
|
+
'Maximum request body size in bytes (default 16777216 = 16 MiB)') do |n|
|
|
115
|
+
cli_opts[:max_body_bytes] = n
|
|
116
|
+
end
|
|
117
|
+
o.on('--max-header-bytes BYTES', Integer,
|
|
118
|
+
'Maximum total request-header size in bytes (default 65536 = 64 KiB)') do |n|
|
|
119
|
+
cli_opts[:max_header_bytes] = n
|
|
120
|
+
end
|
|
121
|
+
o.on('--max-pending COUNT', Integer,
|
|
122
|
+
'Maximum queued connections per worker before new accepts are rejected with 503 (default unbounded)') do |n|
|
|
123
|
+
cli_opts[:max_pending] = n
|
|
124
|
+
end
|
|
125
|
+
o.on('--max-request-read-seconds SECONDS', Float,
|
|
126
|
+
'Total wallclock budget for reading request line + headers + body (default 60.0; 0 disables)') do |n|
|
|
127
|
+
cli_opts[:max_request_read_seconds] = n
|
|
128
|
+
end
|
|
129
|
+
# Security-sensitive: read the token verbatim and never echo it back
|
|
130
|
+
# in any subsequent log/help line. argv is visible via `ps` on most
|
|
131
|
+
# systems; production deployments should prefer --admin-token-file.
|
|
132
|
+
o.on('--admin-token TOKEN',
|
|
133
|
+
"Bearer token for the /-/quit and /-/metrics admin endpoints. \
|
|
134
|
+
WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production.") do |t|
|
|
135
|
+
cli_opts[:admin_token] = t
|
|
136
|
+
end
|
|
137
|
+
o.on('--admin-token-file PATH',
|
|
138
|
+
'Read the admin token from a file. File must NOT be world-readable (perms must mask 0o007).') do |p|
|
|
139
|
+
cli_opts[:admin_token] = read_admin_token_file(p)
|
|
140
|
+
end
|
|
141
|
+
o.on('--worker-max-rss-mb MB', Integer,
|
|
142
|
+
'Recycle a worker when its RSS exceeds MB megabytes (default unset; nil disables)') do |n|
|
|
143
|
+
cli_opts[:worker_max_rss_mb] = n
|
|
144
|
+
end
|
|
145
|
+
o.on('--idle-keepalive SECONDS', Float,
|
|
146
|
+
'Idle keep-alive timeout in seconds (default 5.0)') do |n|
|
|
147
|
+
cli_opts[:idle_keepalive] = n
|
|
148
|
+
end
|
|
149
|
+
o.on('--graceful-timeout SECONDS', Integer,
|
|
150
|
+
'Graceful shutdown deadline in seconds before SIGKILL (default 30)') do |n|
|
|
151
|
+
cli_opts[:graceful_timeout] = n
|
|
152
|
+
end
|
|
64
153
|
o.on('-h', '--help', 'show help') do
|
|
65
154
|
puts o
|
|
66
155
|
exit 0
|
|
@@ -68,47 +157,7 @@ module Hyperion
|
|
|
68
157
|
end
|
|
69
158
|
parser.parse!(argv)
|
|
70
159
|
|
|
71
|
-
|
|
72
|
-
# config/hyperion.rb if present so operators can drop a file in their
|
|
73
|
-
# repo and have it take effect without having to remember -C.
|
|
74
|
-
config_path ||= DEFAULT_CONFIG_PATH if File.exist?(DEFAULT_CONFIG_PATH)
|
|
75
|
-
config = config_path ? Hyperion::Config.load(config_path) : Hyperion::Config.new
|
|
76
|
-
config.merge_cli!(cli_opts)
|
|
77
|
-
|
|
78
|
-
# Install logger early so every subsequent log call honours the operator's
|
|
79
|
-
# chosen format/level (config file or CLI) before anything else logs.
|
|
80
|
-
if config.log_level || config.log_format
|
|
81
|
-
Hyperion.logger = Hyperion::Logger.new(level: config.log_level, format: config.log_format)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Propagate log_requests so every Connection picks it up via
|
|
85
|
-
# `Hyperion.log_requests?` without needing to thread it through
|
|
86
|
-
# Server/ThreadPool/Master plumbing. Default is ON; nil means "don't
|
|
87
|
-
# touch — fall through to the env/default chain in Hyperion.log_requests?".
|
|
88
|
-
Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
|
|
89
|
-
|
|
90
|
-
# Enable YJIT before workers fork / connections start. Auto-on in
|
|
91
|
-
# production/staging gives operators the perf bump for free; explicit
|
|
92
|
-
# config.yjit (true/false) overrides the env-based default.
|
|
93
|
-
maybe_enable_yjit(config)
|
|
94
|
-
|
|
95
|
-
rackup = argv.first || 'config.ru'
|
|
96
|
-
abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
|
|
97
|
-
|
|
98
|
-
if config.fiber_local_shim
|
|
99
|
-
Hyperion::FiberLocal.install!
|
|
100
|
-
Hyperion.logger.info { { message: 'FiberLocal shim installed' } }
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
app = load_rack_app(rackup)
|
|
104
|
-
app = wrap_admin_middleware(app, config)
|
|
105
|
-
workers = config.workers.zero? ? Etc.nprocessors : config.workers
|
|
106
|
-
|
|
107
|
-
if workers <= 1
|
|
108
|
-
run_single(config, app)
|
|
109
|
-
else
|
|
110
|
-
run_cluster(config, app, workers)
|
|
111
|
-
end
|
|
160
|
+
[cli_opts, config_path]
|
|
112
161
|
end
|
|
113
162
|
|
|
114
163
|
def self.run_single(config, app)
|
|
@@ -227,6 +276,28 @@ module Hyperion
|
|
|
227
276
|
end
|
|
228
277
|
private_class_method :wrap_admin_middleware
|
|
229
278
|
|
|
279
|
+
# Read the admin token from a file on disk. Refuses to load if the file
|
|
280
|
+
# is missing, unreadable, or world-readable — the whole point of using a
|
|
281
|
+
# file instead of `--admin-token` is to keep the token off argv (which
|
|
282
|
+
# `ps` exposes) and off other-user-readable storage. Trailing whitespace
|
|
283
|
+
# is stripped so operators can use `echo "$TOKEN" > /etc/hyperion-token`
|
|
284
|
+
# without inadvertently embedding a newline. Empty files abort.
|
|
285
|
+
def self.read_admin_token_file(path)
|
|
286
|
+
abort("[hyperion] admin token file not found: #{path}") unless File.file?(path)
|
|
287
|
+
abort("[hyperion] admin token file not readable: #{path}") unless File.readable?(path)
|
|
288
|
+
|
|
289
|
+
mode = File.stat(path).mode & 0o777
|
|
290
|
+
if (mode & 0o007).positive?
|
|
291
|
+
abort("[hyperion] admin token file #{path} is world-readable (mode #{format('%04o', mode)}); chmod 600")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
token = File.read(path).strip
|
|
295
|
+
abort("[hyperion] admin token file is empty: #{path}") if token.empty?
|
|
296
|
+
|
|
297
|
+
token
|
|
298
|
+
end
|
|
299
|
+
private_class_method :read_admin_token_file
|
|
300
|
+
|
|
230
301
|
# Warn loudly at boot if the C parser didn't load — operators running
|
|
231
302
|
# production with the pure-Ruby fallback are paying ~2× CPU on parse-heavy
|
|
232
303
|
# workloads and probably don't know it.
|
data/lib/hyperion/version.rb
CHANGED