hotcell-server 0.0.0 → 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.
@@ -0,0 +1,268 @@
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
+ ENV["HOME"] = slot.home
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
+ # Returns [connection, queued_ms], or nil once the supervisor has retired this worker by closing the
60
+ # control socket. The connection arrives as a descriptor: the supervisor accepted it and never
61
+ # called recvmsg, so the caller's own descriptors are still queued on it and this worker's recvmsg
62
+ # is what installs them.
63
+ def await_dispatch
64
+ line, descriptors = control.receive_message(limit: DISPATCH_BYTES)
65
+ return nil if line.nil?
66
+
67
+ socket = descriptors.first
68
+ return nil if socket.nil?
69
+
70
+ [ Connection.new(socket), Payload.parse(line).fetch(:queued_ms, 0) ]
71
+ end
72
+
73
+ def serve(connection, queued_ms)
74
+ timing = Timing.new(queued_ms)
75
+ received = []
76
+ response = nil
77
+
78
+ # Before the request rather than at boot, and one directory rather than two. A tool reads its
79
+ # configuration from $HOME and that configuration is executable, so a home that outlived the request
80
+ # let one compromised conversion reconfigure every later one on this slot. adr/0003.
81
+ slot.make_home
82
+
83
+ begin
84
+ line, received = connection.receive_message
85
+ response = if line.nil?
86
+ # The caller closed before sending a request. Transient, so it is never written against a blob,
87
+ # and named rather than left nil — a nil response reported idle `"ok"`, counting a success nobody
88
+ # received.
89
+ refuse("unavailable", "the connection closed before a request arrived", timing)
90
+ else
91
+ handle(line, received, timing)
92
+ end
93
+ rescue MessageError, AccessModeError => error
94
+ response = refuse("invalid", error, timing)
95
+ # NoMemoryError and MemoryExhausted are this input driving this worker past its own memory, which is
96
+ # permanent. Errno::ENOMEM is deliberately not here: a fork or mmap that cannot get memory is host
97
+ # pressure the input did not cause, so it falls through to `failed` with EMFILE and ENOSPC and is
98
+ # transient. Adding it back would condemn a blob for the cell's own bad moment.
99
+ rescue NoMemoryError, MemoryExhausted => error
100
+ response = refuse(Codes::KILLED, error, timing, cause: Codes::MEMORY)
101
+ rescue StandardError => error
102
+ response = refuse("failed", error, timing)
103
+ end
104
+
105
+ deliver connection, response
106
+ record response, timing
107
+ ensure
108
+ received.each(&:close)
109
+ connection.close
110
+ swept = slot.remove_home
111
+
112
+ # After the answer and before reporting idle, which is the only window where this costs nobody. How
113
+ # long it takes is chosen by whatever filled the directory, so it must not run where somebody is
114
+ # waiting: not in the supervisor, whose loop enforces every other request's deadline, and not during
115
+ # staging, where it would spend the next request's deadline on the previous request's mess. Here the
116
+ # caller already has its response, and `report_idle` is what makes this worker available — so the
117
+ # supervisor will not dispatch into a worker that is still sweeping.
118
+ slot.sweep
119
+ report_uncleaned unless swept
120
+ report_idle response&.failure&.code
121
+ end
122
+
123
+ # A removal that failed is the one thing here nobody else can see. The bytes stay on the shared tmpfs
124
+ # after the caller has been told the request is over, and a sibling worker can cause it by writing into
125
+ # the tree while remove_entry walks it. It cannot raise from an ensure, so it says so instead. One line
126
+ # per request, from the ensure, because that is the attempt that knows the final state.
127
+ def report_uncleaned
128
+ log.write "slot.uncleaned", pid: Process.pid, slot: slot.number, home: slot.home
129
+ end
130
+
131
+ def handle(line, received, timing)
132
+ request = Request.parse(line)
133
+
134
+ unless request.current_version?
135
+ return refuse("protocol", request.version_mismatch, timing)
136
+ end
137
+
138
+ operation = Registry.lookup(request.op)
139
+ return refuse("unsupported", "no operation named #{request.op.inspect}", timing) if operation.nil?
140
+
141
+ inputs, outputs = wrap(request, received)
142
+ boot operation
143
+ narrow operation
144
+ report_deadline operation
145
+
146
+ perform operation, inputs, outputs, request.payload, timing
147
+ end
148
+
149
+ def perform(operation, inputs, outputs, payload, timing)
150
+ timing.performing
151
+
152
+ result = timing.measure(:operation_ms) { operation.new.perform(inputs, outputs, **payload) }
153
+ written = timing.measure(:writeback_ms) { outputs.map(&:post) }
154
+
155
+ return unwritten(outputs, written, timing) if written.any?(&:zero?)
156
+
157
+ # Read before the scratch goes, so perform_ms measures performing and not the cleanup after it.
158
+ Payload.validate! result, "result"
159
+ response = Response.ok(result: result, timing: timing.to_h)
160
+
161
+ # Before answering rather than after, so the window in which a sibling worker could read this
162
+ # request's bytes off the shared tmpfs closes before the caller is told anything. Files are not
163
+ # isolated between concurrent workers and cannot be, so the window's size is the whole control.
164
+ # Not reported here. The ensure below runs after every path through this method and tries again, so
165
+ # it is the one that knows whether the directory is still there when the request is over.
166
+ slot.remove_home
167
+
168
+ response
169
+ rescue *operation.unreadable => error
170
+ refuse "unreadable", error, timing
171
+ end
172
+
173
+ # `post` returns what each output received and the worker used to throw all of it away, leaving the
174
+ # client to check the total size of the outputs it had handed over. A total hides the case the
175
+ # multiple-output API exists for: writing the first and skipping the second is a positive total and
176
+ # reads as success. This is the side that knows which one is empty, so it is the side that says so.
177
+ #
178
+ # Transient, for the reason the client's own check is: the commonest way to write nothing is a full
179
+ # tmpfs, and a full filesystem must never be recorded as a verdict on the document.
180
+ def unwritten(outputs, written, timing)
181
+ empty = written.each_index.select { |index| written[index].zero? }
182
+
183
+ refuse "unavailable",
184
+ "#{empty.size} of #{outputs.size} outputs received no bytes (#{empty.join(", ")})",
185
+ timing
186
+ end
187
+
188
+ def wrap(request, received)
189
+ unless received.size == request.descriptor_count
190
+ raise MessageError, "#{request.op} wants #{request.descriptor_count} descriptors and " \
191
+ "#{received.size} arrived"
192
+ end
193
+
194
+ [ received.first(request.inputs).map.with_index { |io, index| Input.new(io, scratch: scratch("input-#{index}")) },
195
+ received.last(request.outputs).map.with_index { |io, index| Output.new(io, scratch: scratch("output-#{index}")) } ]
196
+ end
197
+
198
+ # A name inside this request's own `$HOME`, which `serve` has already created. Deferred rather than
199
+ # computed up front because a descriptor only asks when the operation reaches for a path, and an
200
+ # operation that reads its descriptors directly never asks at all.
201
+ def scratch(name)
202
+ -> { File.join(slot.home, name) }
203
+ end
204
+
205
+ # Tracks the last operation configured for rather than every one ever seen. Above `max_requests_per_worker: 1`
206
+ # a worker can serve A, then B, then A — and a set-shaped memo skipped A's hooks the second time, leaving it
207
+ # running under whatever B had set the shared library to. What these hooks configure is global and singular,
208
+ # so the question is not "has this ever run" but "is this what the library is set up for".
209
+ def boot(operation)
210
+ return if @booted == operation
211
+
212
+ operation.before_worker_boot.each(&:call)
213
+ @booted = operation
214
+ end
215
+
216
+ def narrow(operation)
217
+ effective(operation).apply ceiling: configuration.limits
218
+ end
219
+
220
+ # The supervisor enforces the deadline and never reads a request, so it cannot know that this
221
+ # operation asked for less than the cell's maximum. The worker is the only thing that knows, and it
222
+ # says so before it touches an untrusted byte.
223
+ def report_deadline(operation)
224
+ tell deadline: effective(operation).deadline
225
+ end
226
+
227
+ def report_idle(code)
228
+ tell idle: true, code: code || "ok"
229
+ end
230
+
231
+ def tell(**message)
232
+ control.write_line JSON.generate(message) << "\n"
233
+ rescue SystemCallError, IOError
234
+ nil
235
+ end
236
+
237
+ def effective(operation)
238
+ @effective[operation] ||= operation.limits.clamped_to(configuration.limits)
239
+ end
240
+
241
+ def deliver(connection, response)
242
+ return if response.nil?
243
+
244
+ connection.write_line line_for(response)
245
+ rescue SystemCallError, IOError
246
+ log.write "request.abandoned", pid: Process.pid, slot: slot.number
247
+ end
248
+
249
+ def line_for(response)
250
+ response.to_line
251
+ rescue SerializationError, MessageError => error
252
+ Response.failed(Failure.for("failed", error), timing: response.timing).to_line
253
+ end
254
+
255
+ def record(response, timing)
256
+ return if response.nil?
257
+
258
+ log.write "request", pid: Process.pid, slot: slot.number, code: response.failure&.code || "ok",
259
+ permanent: response.failure&.permanent?,
260
+ outcome: response.failure ? "failure" : "success",
261
+ duration_ms: timing.elapsed_ms, timing: response.timing
262
+ end
263
+
264
+ def refuse(code, detail, timing, cause: nil)
265
+ Response.failed Failure.for(code, detail, cause: cause), timing: timing.to_h
266
+ end
267
+ end
268
+ 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,74 @@
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.1.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.1.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.1.0
26
+ description: |
27
+ The cell side of HotCell. A supervisor listens on two Unix sockets, forks a worker per request,
28
+ holds each worker to a wall-clock deadline and a set of resource limits, and answers for one that
29
+ dies without reporting.
30
+
31
+ An operation subclasses HotCell::Operation, declares its limits, and implements perform. This gem
32
+ deliberately depends on no application framework: nothing about it loads ActiveSupport, because a
33
+ sandbox should carry only what the conversion needs.
13
34
  email:
14
35
  - mike@37signals.com
15
- executables: []
36
+ executables:
37
+ - hotcell
38
+ - hotcell-health
16
39
  extensions: []
17
40
  extra_rdoc_files: []
18
- files: []
41
+ files:
42
+ - MIT-LICENSE
43
+ - README.md
44
+ - exe/hotcell
45
+ - exe/hotcell-health
46
+ - lib/hot_cell/configuration.rb
47
+ - lib/hot_cell/control.rb
48
+ - lib/hot_cell/counters.rb
49
+ - lib/hot_cell/limits.rb
50
+ - lib/hot_cell/log.rb
51
+ - lib/hot_cell/operation.rb
52
+ - lib/hot_cell/registry.rb
53
+ - lib/hot_cell/server.rb
54
+ - lib/hot_cell/server/errors.rb
55
+ - lib/hot_cell/server/version.rb
56
+ - lib/hot_cell/slot.rb
57
+ - lib/hot_cell/supervisor.rb
58
+ - lib/hot_cell/test_cell.rb
59
+ - lib/hot_cell/test_operations.rb
60
+ - lib/hot_cell/timing.rb
61
+ - lib/hot_cell/worker.rb
62
+ - lib/hotcell-server.rb
63
+ homepage: https://github.com/basecamp/hotcell
19
64
  licenses:
20
65
  - MIT
21
- metadata: {}
66
+ metadata:
67
+ homepage_uri: https://github.com/basecamp/hotcell
68
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.1.0/hotcell-server
69
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.1.0/CHANGELOG.md
70
+ bug_tracker_uri: https://github.com/basecamp/hotcell/issues
71
+ rubygems_mfa_required: 'true'
22
72
  rdoc_options: []
23
73
  require_paths:
24
74
  - lib
@@ -26,7 +76,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
26
76
  requirements:
27
77
  - - ">="
28
78
  - !ruby/object:Gem::Version
29
- version: '0'
79
+ version: '3.3'
30
80
  required_rubygems_version: !ruby/object:Gem::Requirement
31
81
  requirements:
32
82
  - - ">="
@@ -35,5 +85,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
35
85
  requirements: []
36
86
  rubygems_version: 4.0.16
37
87
  specification_version: 4
38
- summary: To be released soon, secure sidecar for Rails
88
+ summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
39
89
  test_files: []