hotcell-server 0.0.0 → 0.2.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.
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # Every untrusted byte is touched here and nowhere else.
5
+ #
6
+ # The worker applies the cell's limits before it touches the socket, narrows to the operation's limits
7
+ # before it reads an untrusted byte, and calls exit! on the way out so that no finalizer and no library
8
+ # teardown ever runs. Limits go on in two passes because the worker has to parse the request before it
9
+ # can know which operation's limits to use, and parsing is the first thing it does with
10
+ # attacker-influenced bytes — so the cell's maximums go on at a point that needs no parsing at all.
11
+ #
12
+ # There is deliberately no shutdown hook. The exit! is the point, and a hook would invite cleanup code
13
+ # that is then skipped.
14
+ class Worker
15
+ DISPATCH_BYTES = 1024
16
+
17
+ def initialize(slot:, configuration:, control:, log:)
18
+ @slot = slot
19
+ @configuration = configuration
20
+ @control = control
21
+ @log = log
22
+ @booted = nil
23
+ @effective = {}
24
+ end
25
+
26
+ # exit! rather than exit, so that no finalizer and no library teardown ever runs. There is deliberately no
27
+ # shutdown hook: the exit! is the point, and a hook would invite cleanup code that is then skipped.
28
+ #
29
+ # A non-zero status for anything unexpected, because the supervisor holds the connection and is the only
30
+ # thing that can answer for a worker that died mid-request. Exiting zero here would leave a caller reading
31
+ # a closed socket with no verdict at all.
32
+ #
33
+ # **The one deliberate `rescue Exception` in this repository.** Not for the exit status — Ruby's own handler
34
+ # would also exit non-zero — but for Failure.sanitize. Left to Ruby, a NoMemoryError or a SystemStackError
35
+ # prints its message and backtrace to stderr unsanitized, and in this process that message can carry bytes
36
+ # derived from a hostile file. sanitize forces UTF-8, scrubs invalid sequences and truncates; stderr is the
37
+ # one path out of a cell that would otherwise skip it, which is how an unscrubbed byte sequence reaches a
38
+ # log row and poisons it.
39
+ #
40
+ # It swallows nothing: `exit! 1` runs whatever was caught.
41
+ def run
42
+ configuration.limits.apply
43
+ disarm_file_size_signal
44
+
45
+ while (dispatch = await_dispatch)
46
+ serve(*dispatch)
47
+ end
48
+
49
+ exit! 0
50
+ rescue Exception => error
51
+ log.write "worker.crashed", pid: Process.pid, slot: slot.number, error: error.class.name,
52
+ message: Failure.sanitize(error.message)
53
+ exit! 1
54
+ end
55
+
56
+ private
57
+ attr_reader :slot, :configuration, :control, :log
58
+
59
+ # **The handler carries nothing, and that is the whole point.**
60
+ #
61
+ # RLIMIT_FSIZE is enforced by SIGXFSZ, and the supervisor used to read that signal off a wait status
62
+ # and answer `fsize`, permanently, against whatever input the worker was holding. Workers share a uid,
63
+ # so a sibling sends SIGXFSZ as easily as the kernel does and a wait status cannot tell them apart —
64
+ # nor can a handler, because Ruby hands one only the signal number and no siginfo, so SI_KERNEL and
65
+ # SI_USER are not reachable from here.
66
+ #
67
+ # Catching it makes the kernel fail the offending write with EFBIG rather than killing the process,
68
+ # and that error return is what a signal is not: it is raised by a write this process made, and no
69
+ # signal any sibling sends produces one. So the verdict below keys on Errno::EFBIG and this handler
70
+ # does nothing at all. A handler that set so much as a flag the verdict consulted would hand the
71
+ # forgery straight back.
72
+ #
73
+ # EFBIG is evidence of this request's own write and not proof of which limit stopped it. A filesystem
74
+ # maximum, or the caller's own file, answers the same errno — so this says the bytes did not go, by
75
+ # something this request did, rather than naming RLIMIT_FSIZE. Narrowing it further means checking the
76
+ # written file against the effective limit at each write site, which is not what this changes.
77
+ #
78
+ # A block rather than "IGNORE", because an ignored disposition survives execve and a handled one does
79
+ # not. A tool must keep dying on its own RLIMIT_FSIZE rather than writing past it, and a tool that
80
+ # died by signal is `crashed` and transient — its wait status is no more trustworthy than a worker's,
81
+ # since a sibling can signal a tool too.
82
+ def disarm_file_size_signal
83
+ Signal.trap("XFSZ") { nil }
84
+ end
85
+
86
+ # Returns [connection, queued_ms], or nil once the supervisor has retired this worker by closing the
87
+ # control socket. The connection arrives as a descriptor: the supervisor accepted it and never
88
+ # called recvmsg, so the caller's own descriptors are still queued on it and this worker's recvmsg
89
+ # is what installs them.
90
+ def await_dispatch
91
+ line, descriptors = control.receive_message(limit: DISPATCH_BYTES)
92
+ return nil if line.nil?
93
+
94
+ socket = descriptors.first
95
+ return nil if socket.nil?
96
+
97
+ [ Connection.new(socket), Payload.parse(line).fetch(:queued_ms, 0) ]
98
+ end
99
+
100
+ def serve(connection, queued_ms)
101
+ timing = Timing.new(queued_ms)
102
+ received = []
103
+ response = nil
104
+
105
+ begin
106
+ # Before the request rather than at boot, and one directory rather than two. A tool reads its
107
+ # configuration from $HOME and that configuration is executable, so a home that outlived the
108
+ # request let one compromised conversion reconfigure every later one on this slot. adr/0003.
109
+ #
110
+ # Inside the begin, because a home that cannot be created is a broken deployment and answers
111
+ # `failed`, which is transient. Outside it the raise skipped every response path and reached
112
+ # `run`, which exits the worker — after this ensure had already reported idle `"ok"`, counting a
113
+ # success for a request that never ran.
114
+ ENV["HOME"] = slot.make_home
115
+
116
+ line, received = connection.receive_message
117
+ response = if line.nil?
118
+ # The caller closed before sending a request. Transient, so it is never written against a blob,
119
+ # and named rather than left nil — a nil response reported idle `"ok"`, counting a success nobody
120
+ # received.
121
+ refuse("unavailable", "the connection closed before a request arrived", timing)
122
+ else
123
+ handle(line, received, timing)
124
+ end
125
+ rescue MessageError, AccessModeError => error
126
+ response = refuse("invalid", error, timing)
127
+ # NoMemoryError and MemoryExhausted are this input driving this worker past its own memory, which is
128
+ # permanent. Errno::ENOMEM is deliberately not here: a fork or mmap that cannot get memory is host
129
+ # pressure the input did not cause, so it falls through to `failed` with EMFILE and ENOSPC and is
130
+ # transient. Adding it back would condemn a blob for the cell's own bad moment.
131
+ rescue NoMemoryError, MemoryExhausted => error
132
+ response = refuse(Codes::KILLED, error, timing, cause: Codes::MEMORY)
133
+ # The one place a file-size verdict can be earned. EFBIG comes back from a write this worker made
134
+ # past its own RLIMIT_FSIZE, so unlike the signal it cannot arrive from anywhere else.
135
+ rescue Errno::EFBIG => error
136
+ response = refuse(Codes::KILLED, error, timing, cause: Codes::FSIZE)
137
+ rescue StandardError => error
138
+ response = refuse("failed", error, timing)
139
+ end
140
+
141
+ deliver connection, response
142
+ record response, timing
143
+ ensure
144
+ received.each(&:close)
145
+ connection.close
146
+ home = slot.home
147
+ swept = slot.remove_home
148
+
149
+ # After the answer and before reporting idle, which is the only window where this costs nobody. How
150
+ # long it takes is chosen by whatever filled the directory, so it must not run where somebody is
151
+ # waiting: not in the supervisor, whose loop enforces every other request's deadline, and not during
152
+ # staging, where it would spend the next request's deadline on the previous request's mess. Here the
153
+ # caller already has its response, and `report_idle` is what makes this worker available — so the
154
+ # supervisor will not dispatch into a worker that is still sweeping.
155
+ report_uncleaned home unless swept
156
+ report_unswept unless slot.sweep
157
+ report_idle response&.failure
158
+ end
159
+
160
+ # A removal that failed is the one thing here nobody else can see. The bytes stay on the shared tmpfs
161
+ # after the caller has been told the request is over, and a sibling worker can cause it by writing into
162
+ # the tree while remove_entry walks it. It cannot raise from an ensure, so it says so instead. One line
163
+ # per request, from the ensure, because that is the attempt that knows the final state.
164
+ def report_uncleaned(home)
165
+ log.write "slot.uncleaned", pid: Process.pid, slot: slot.number, home: home
166
+ end
167
+
168
+ # The other half of the same fact. A sweep removes what the supervisor renamed out of the way after a
169
+ # killed request, and its failure was the one cleanup outcome nobody said anything about — so a slot
170
+ # accumulating one tree per request looked exactly like a slot that was clean.
171
+ def report_unswept
172
+ log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
173
+ end
174
+
175
+ def handle(line, received, timing)
176
+ request = Request.parse(line)
177
+
178
+ unless request.current_version?
179
+ return refuse("protocol", request.version_mismatch, timing)
180
+ end
181
+
182
+ operation = Registry.lookup(request.op)
183
+ return refuse("unsupported", "no operation named #{request.op.inspect}", timing) if operation.nil?
184
+
185
+ inputs, outputs = wrap(request, received)
186
+ boot operation
187
+ narrow operation
188
+ report_deadline operation
189
+
190
+ perform operation, inputs, outputs, request.payload, timing
191
+ end
192
+
193
+ def perform(operation, inputs, outputs, payload, timing)
194
+ timing.performing
195
+
196
+ result = timing.measure(:operation_ms) { operation.new.perform(inputs, outputs, **payload) }
197
+ written = timing.measure(:writeback_ms) { outputs.map(&:post) }
198
+
199
+ return unwritten(outputs, written, timing) if written.any?(&:zero?)
200
+
201
+ # Read before the scratch goes, so perform_ms measures performing and not the cleanup after it.
202
+ Payload.validate! result, "result"
203
+ response = Response.ok(result: result, timing: timing.to_h)
204
+
205
+ # Before answering rather than after, so the window in which a sibling worker could read this
206
+ # request's bytes off the shared tmpfs closes before the caller is told anything. Files are not
207
+ # isolated between concurrent workers and cannot be, so the window's size is the whole control.
208
+ # Not reported here. The ensure below runs after every path through this method and tries again, so
209
+ # it is the one that knows whether the directory is still there when the request is over.
210
+ slot.remove_home
211
+
212
+ response
213
+ rescue *operation.unreadable => error
214
+ refuse "unreadable", error, timing
215
+ end
216
+
217
+ # `post` returns what each output received and the worker used to throw all of it away, leaving the
218
+ # client to check the total size of the outputs it had handed over. A total hides the case the
219
+ # multiple-output API exists for: writing the first and skipping the second is a positive total and
220
+ # reads as success. This is the side that knows which one is empty, so it is the side that says so.
221
+ #
222
+ # Transient, for the reason the client's own check is: the commonest way to write nothing is a full
223
+ # tmpfs, and a full filesystem must never be recorded as a verdict on the document.
224
+ def unwritten(outputs, written, timing)
225
+ empty = written.each_index.select { |index| written[index].zero? }
226
+
227
+ refuse "unavailable",
228
+ "#{empty.size} of #{outputs.size} outputs received no bytes (#{empty.join(", ")})",
229
+ timing
230
+ end
231
+
232
+ def wrap(request, received)
233
+ unless received.size == request.descriptor_count
234
+ raise MessageError, "#{request.op} wants #{request.descriptor_count} descriptors and " \
235
+ "#{received.size} arrived"
236
+ end
237
+
238
+ [ received.first(request.inputs).map.with_index { |io, index| Input.new(io, scratch: scratch("input-#{index}")) },
239
+ received.last(request.outputs).map.with_index { |io, index| Output.new(io, scratch: scratch("output-#{index}")) } ]
240
+ end
241
+
242
+ # A name inside this request's own `$HOME`, which `serve` has already created. Deferred rather than
243
+ # computed up front because a descriptor only asks when the operation reaches for a path, and an
244
+ # operation that reads its descriptors directly never asks at all.
245
+ def scratch(name)
246
+ -> { File.join(slot.home, name) }
247
+ end
248
+
249
+ # Tracks the last operation configured for rather than every one ever seen. Above `max_requests_per_worker: 1`
250
+ # a worker can serve A, then B, then A — and a set-shaped memo skipped A's hooks the second time, leaving it
251
+ # running under whatever B had set the shared library to. What these hooks configure is global and singular,
252
+ # so the question is not "has this ever run" but "is this what the library is set up for".
253
+ def boot(operation)
254
+ return if @booted == operation
255
+
256
+ operation.before_worker_boot.each(&:call)
257
+ @booted = operation
258
+ end
259
+
260
+ def narrow(operation)
261
+ effective(operation).apply ceiling: configuration.limits
262
+ end
263
+
264
+ # The supervisor enforces the deadline and never reads a request, so it cannot know that this
265
+ # operation asked for less than the cell's maximum. The worker is the only thing that knows, and it
266
+ # says so before it touches an untrusted byte.
267
+ def report_deadline(operation)
268
+ tell deadline: effective(operation).deadline
269
+ end
270
+
271
+ # The cause travels with the code so the supervisor can still count a kill by cause. It used to read
272
+ # that off a wait status; it reads the worker's own report now, and only when there is a cause to send,
273
+ # so an ordinary idle report is the two keys it always was.
274
+ #
275
+ # **This is a metric and not a verdict.** The verdict went to the caller on the work connection before
276
+ # this line runs. A compromised worker can report a cause its request never had, or withhold one it
277
+ # did, so `killed_by` is what workers said rather than what happened — which is why the supervisor
278
+ # checks the value against the known causes before interning it, and why nothing downstream may treat
279
+ # it as evidence about a blob.
280
+ def report_idle(failure)
281
+ return tell(idle: true, code: "ok") if failure.nil?
282
+ return tell(idle: true, code: failure.code) if failure.cause.nil?
283
+
284
+ tell idle: true, code: failure.code, cause: failure.cause
285
+ end
286
+
287
+ def tell(**message)
288
+ control.write_line JSON.generate(message) << "\n"
289
+ rescue SystemCallError, IOError
290
+ nil
291
+ end
292
+
293
+ def effective(operation)
294
+ @effective[operation] ||= operation.limits.clamped_to(configuration.limits)
295
+ end
296
+
297
+ def deliver(connection, response)
298
+ return if response.nil?
299
+
300
+ connection.write_line line_for(response)
301
+ rescue SystemCallError, IOError
302
+ log.write "request.abandoned", pid: Process.pid, slot: slot.number
303
+ end
304
+
305
+ def line_for(response)
306
+ response.to_line
307
+ rescue SerializationError, MessageError => error
308
+ Response.failed(Failure.for("failed", error), timing: response.timing).to_line
309
+ end
310
+
311
+ def record(response, timing)
312
+ return if response.nil?
313
+
314
+ log.write "request", pid: Process.pid, slot: slot.number, code: response.failure&.code || "ok",
315
+ permanent: response.failure&.permanent?,
316
+ outcome: response.failure ? "failure" : "success",
317
+ duration_ms: timing.elapsed_ms, timing: response.timing
318
+ end
319
+
320
+ def refuse(code, detail, timing, cause: nil)
321
+ Response.failed Failure.for(code, detail, cause: cause), timing: timing.to_h
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires a gem named "hotcell-server" as "hotcell-server", then as "hotcell/server". This
4
+ # gem uses neither path, because hot_cell/ is what yields the HotCell constant under the default
5
+ # inflection. Without this file, `gem "hotcell-server"` in a Gemfile silently loads nothing at all.
6
+ require "hot_cell/server"
metadata CHANGED
@@ -1,24 +1,70 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: To be released soon, secure sidecar for Rails
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: hotcell-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.2.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.2.0
26
+ description: |
27
+ Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
28
+ and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
29
+ HotCell::Operation.
13
30
  email:
14
31
  - mike@37signals.com
15
- executables: []
32
+ executables:
33
+ - hotcell
34
+ - hotcell-health
16
35
  extensions: []
17
36
  extra_rdoc_files: []
18
- files: []
37
+ files:
38
+ - MIT-LICENSE
39
+ - README.md
40
+ - exe/hotcell
41
+ - exe/hotcell-health
42
+ - lib/hot_cell/configuration.rb
43
+ - lib/hot_cell/control.rb
44
+ - lib/hot_cell/counters.rb
45
+ - lib/hot_cell/limits.rb
46
+ - lib/hot_cell/log.rb
47
+ - lib/hot_cell/operation.rb
48
+ - lib/hot_cell/registry.rb
49
+ - lib/hot_cell/server.rb
50
+ - lib/hot_cell/server/errors.rb
51
+ - lib/hot_cell/server/version.rb
52
+ - lib/hot_cell/slot.rb
53
+ - lib/hot_cell/supervisor.rb
54
+ - lib/hot_cell/test_cell.rb
55
+ - lib/hot_cell/test_operations.rb
56
+ - lib/hot_cell/timing.rb
57
+ - lib/hot_cell/worker.rb
58
+ - lib/hotcell-server.rb
59
+ homepage: https://github.com/basecamp/hotcell
19
60
  licenses:
20
61
  - MIT
21
- metadata: {}
62
+ metadata:
63
+ homepage_uri: https://github.com/basecamp/hotcell
64
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.2.0/hotcell-server
65
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.2.0/CHANGELOG.md
66
+ bug_tracker_uri: https://github.com/basecamp/hotcell/issues
67
+ rubygems_mfa_required: 'true'
22
68
  rdoc_options: []
23
69
  require_paths:
24
70
  - lib
@@ -26,7 +72,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
26
72
  requirements:
27
73
  - - ">="
28
74
  - !ruby/object:Gem::Version
29
- version: '0'
75
+ version: '3.3'
30
76
  required_rubygems_version: !ruby/object:Gem::Requirement
31
77
  requirements:
32
78
  - - ">="
@@ -35,5 +81,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
35
81
  requirements: []
36
82
  rubygems_version: 4.0.16
37
83
  specification_version: 4
38
- summary: To be released soon, secure sidecar for Rails
84
+ summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
39
85
  test_files: []