ace-lab 0.3.1 → 0.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 +25 -0
- data/docs/usage.md +10 -0
- data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
- data/lib/ace/lab/atoms/service_input.rb +20 -8
- data/lib/ace/lab/authorization_path.rb +20 -0
- data/lib/ace/lab/cli/commands/service.rb +45 -0
- data/lib/ace/lab/errors.rb +11 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +5 -1
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
- data/lib/ace/lab/molecules/protected_service_handler.rb +43 -0
- data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
- data/lib/ace/lab/molecules/service_policy.rb +36 -1
- data/lib/ace/lab/organisms/authority_composition.rb +87 -0
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
- data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
- data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
- data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
- data/lib/ace/lab/organisms/protected_service_receiver.rb +267 -0
- data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
- data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
- data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
- data/lib/ace/lab/organisms/service_request_service.rb +4 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +2 -22
- metadata +52 -4
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_socket"
|
|
4
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
5
|
+
require "ace/assign/authority/transfer_codec"
|
|
6
|
+
require_relative "../molecules/protected_cleanup_owner_admission"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Organisms
|
|
11
|
+
# The immutable direct root entry supplies all collaborators. Durable
|
|
12
|
+
# request/outcome authority stays in its original canonical journal and
|
|
13
|
+
# Installer's retained physical result, never this transport owner.
|
|
14
|
+
class ProtectedCleanupOwner
|
|
15
|
+
Wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
16
|
+
Client = Molecules::ProtectedCleanupOwnerClient
|
|
17
|
+
MAX_RETAINED = 256
|
|
18
|
+
MAX_CONNECTIONS = 16
|
|
19
|
+
|
|
20
|
+
def initialize(observer:, admission:, snapshots:, journals:, kernel:, installer:, scratch_root:,
|
|
21
|
+
protection: Ace::Runtime::Molecules::ProtectedArtifactSet::Protection.new)
|
|
22
|
+
@observer, @admission, @snapshots, @journals = observer, admission, snapshots, journals
|
|
23
|
+
@kernel, @installer = kernel, installer
|
|
24
|
+
@protection = protection
|
|
25
|
+
@codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
|
|
26
|
+
@lock, @consumed = Mutex.new, {}
|
|
27
|
+
@connections, @changed = [], ConditionVariable.new
|
|
28
|
+
@stopping = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Only the trusted fixed entry supplies the installed connection group.
|
|
32
|
+
# Endpoint and lock paths remain source constants, never request inputs.
|
|
33
|
+
def serve(connect_gid:)
|
|
34
|
+
@admission.connection_group!(connect_gid)
|
|
35
|
+
@observer.observe_self!(deadline: Wire.deadline(5))
|
|
36
|
+
path = Client::PATH
|
|
37
|
+
parent = File.dirname(path)
|
|
38
|
+
Wire.root_path!(parent, directory: true)
|
|
39
|
+
parents = hold_parent!(parent)
|
|
40
|
+
parent_stat = File.lstat(parent)
|
|
41
|
+
unless parent_stat.directory? && parent_stat.uid == Process.uid && parent_stat.gid == connect_gid && (parent_stat.mode & 0o7777) == 0o2750
|
|
42
|
+
raise SecurityError, "cleanup listener parent differs"
|
|
43
|
+
end
|
|
44
|
+
parent_identity = [parent_stat.dev, parent_stat.ino]
|
|
45
|
+
lifetime = File.open("#{path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
46
|
+
lifetime.close_on_exec = true
|
|
47
|
+
stat = lifetime.stat
|
|
48
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
49
|
+
raise SecurityError, "cleanup lifetime lock differs"
|
|
50
|
+
end
|
|
51
|
+
@protection.verify!("#{path}.lock", lifetime, directory: false)
|
|
52
|
+
raise SecurityError, "cleanup lifetime lock path changed" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
|
|
53
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup listener already active" unless lifetime.flock(File::LOCK_EX | File::LOCK_NB)
|
|
54
|
+
raise SecurityError, "cleanup lifetime lock path changed after flock" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
|
|
55
|
+
verify_parents!(parents)
|
|
56
|
+
begin
|
|
57
|
+
stale = File.lstat(path)
|
|
58
|
+
unless stale.socket? && stale.uid == Process.uid && stale.gid == connect_gid && (stale.mode & 0o7777) == 0o660
|
|
59
|
+
raise SecurityError, "cleanup stale endpoint differs"
|
|
60
|
+
end
|
|
61
|
+
File.unlink(path)
|
|
62
|
+
rescue Errno::ENOENT
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
@listener = UNIXServer.new(path)
|
|
66
|
+
@listener.close_on_exec = true
|
|
67
|
+
created = File.lstat(path)
|
|
68
|
+
unless created.socket? && created.uid == Process.uid && created.gid == connect_gid &&
|
|
69
|
+
[File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
|
|
70
|
+
raise SecurityError, "cleanup endpoint creation differs"
|
|
71
|
+
end
|
|
72
|
+
endpoint = [created.dev, created.ino, created.uid, created.gid]
|
|
73
|
+
File.chmod(0o660, path)
|
|
74
|
+
own = File.lstat(path)
|
|
75
|
+
unless own.socket? && (own.mode & 0o7777) == 0o660 && [own.dev, own.ino, own.uid, own.gid] == endpoint && [File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
|
|
76
|
+
raise SecurityError, "cleanup endpoint publication differs"
|
|
77
|
+
end
|
|
78
|
+
published = true
|
|
79
|
+
until @stopping
|
|
80
|
+
socket = @listener.accept
|
|
81
|
+
accepted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
socket.close_on_exec = true
|
|
83
|
+
admitted = @lock.synchronize do
|
|
84
|
+
@connections.reject! { |thread| !thread.alive? }
|
|
85
|
+
if @connections.size >= MAX_CONNECTIONS
|
|
86
|
+
false
|
|
87
|
+
else
|
|
88
|
+
worker = Thread.new(socket, accepted_at) do |owned_socket, original_acceptance|
|
|
89
|
+
begin
|
|
90
|
+
verify_parents!(parents)
|
|
91
|
+
current = File.lstat(path)
|
|
92
|
+
unless current.socket? && (current.mode & 0o7777) == 0o660 && [current.dev, current.ino, current.uid, current.gid] == endpoint
|
|
93
|
+
raise SecurityError, "cleanup endpoint changed"
|
|
94
|
+
end
|
|
95
|
+
handle(owned_socket, accepted_at: original_acceptance)
|
|
96
|
+
rescue SecurityError, Ace::Runtime::RuntimeUnavailableError, Ace::Assign::Error, IOError, SystemCallError => error
|
|
97
|
+
# Refusal/transport failure closes only this connection.
|
|
98
|
+
# Its consumed state, including uncertainty, is preserved.
|
|
99
|
+
code = error.is_a?(SecurityError) ? "unauthorized_or_changed_selection" : "unavailable"
|
|
100
|
+
warn("cleanup connection refused: #{code}")
|
|
101
|
+
ensure
|
|
102
|
+
owned_socket.close unless owned_socket.closed?
|
|
103
|
+
@lock.synchronize { @changed.broadcast }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
@connections << worker
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
socket.close unless admitted
|
|
111
|
+
end
|
|
112
|
+
rescue IOError, Errno::EBADF
|
|
113
|
+
raise unless @stopping
|
|
114
|
+
ensure
|
|
115
|
+
@listener.close if @listener && !@listener.closed?
|
|
116
|
+
@lock.synchronize do
|
|
117
|
+
# Wait for actual callbacks, never mistake a dead callback's
|
|
118
|
+
# unconfirmed outcome for activity or physical settlement.
|
|
119
|
+
while @connections.any?(&:alive?) || @consumed.values.any? { |entry| entry[:state] == :invoked }
|
|
120
|
+
@changed.wait(@lock, 0.1)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
unconfirmed = @lock.synchronize { @consumed.values.any? { |entry| entry[:state] == :unconfirmed } }
|
|
124
|
+
if endpoint
|
|
125
|
+
begin
|
|
126
|
+
verify_parents!(parents)
|
|
127
|
+
current = File.lstat(path)
|
|
128
|
+
File.unlink(path) if current.socket? && (!published || (current.mode & 0o7777) == 0o660) && [current.dev, current.ino, current.uid, current.gid] == endpoint
|
|
129
|
+
rescue Errno::ENOENT
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
lifetime&.close
|
|
134
|
+
parents&.each { |entry| entry.fetch(:handle).close }
|
|
135
|
+
if unconfirmed && $!.nil?
|
|
136
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup invocation outcome remains unconfirmed after callback termination"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stop
|
|
141
|
+
@stopping = true
|
|
142
|
+
@listener.close if @listener && !@listener.closed?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Listener construction and fixed socket ownership are separately
|
|
146
|
+
# provisioned; every accepted connection still authenticates its peer.
|
|
147
|
+
def handle(socket, accepted_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
148
|
+
admission_deadline = accepted_at + 5
|
|
149
|
+
peer = @kernel.peer(socket)
|
|
150
|
+
frame = Wire.read(Wire::Ingress.new(socket), deadline: admission_deadline, limit: 65_536)
|
|
151
|
+
eof!(socket, admission_deadline)
|
|
152
|
+
original = @observer.observe_self!(deadline: admission_deadline)
|
|
153
|
+
if frame == {"schema" => Client::SCHEMA, "kind" => "identity"}
|
|
154
|
+
@admission.identity_reader!(peer)
|
|
155
|
+
Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "identity", "operation_owner_binding" => original},
|
|
156
|
+
deadline: admission_deadline, limit: Client::LIMIT)
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
@admission.receiver!(peer)
|
|
160
|
+
receiver_peer = Atoms::ProtectedWorkspacePruneInput.freeze_value(JSON.parse(JSON.generate(peer)))
|
|
161
|
+
deadline = frame["kind"] == "preview" ? admission_deadline : accepted_at + 300
|
|
162
|
+
context = nil
|
|
163
|
+
@snapshots.call do |snapshot|
|
|
164
|
+
# Reuse exact immutable event bytes only during this held read.
|
|
165
|
+
# Peer admission remains fresh; neither the view nor its memo
|
|
166
|
+
# survives into Installer effects or the next connection.
|
|
167
|
+
Ace::Assign::Molecules::EvidenceJournal.with_event_read_operation do
|
|
168
|
+
snapshot.with(deadline: deadline) do |view|
|
|
169
|
+
context = if frame["kind"] == "preview"
|
|
170
|
+
@admission.preview!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
|
|
171
|
+
elsif frame["kind"] == "inspect"
|
|
172
|
+
@admission.inspect!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
|
|
173
|
+
else
|
|
174
|
+
@admission.admit!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
if frame["kind"] == "preview"
|
|
180
|
+
unless @installer.respond_to?(:preview_cleanup!)
|
|
181
|
+
raise SecurityError, "same Installer preview producer unavailable"
|
|
182
|
+
end
|
|
183
|
+
result = @installer.preview_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
|
|
184
|
+
unless Ace::Assign::Atoms::EvidenceDigest.digest(@observer.observe_self!(deadline: deadline)) ==
|
|
185
|
+
Ace::Assign::Atoms::EvidenceDigest.digest(original)
|
|
186
|
+
raise SecurityError, "cleanup original owner changed during preview"
|
|
187
|
+
end
|
|
188
|
+
result = Ace::Lab::Atoms::ProtectedWorkspacePrunePreview.result!(result,
|
|
189
|
+
intent: frame.fetch("intent"), context: frame.fetch("maintenance_context"))
|
|
190
|
+
Wire.write(socket, result, deadline: deadline, limit: Client::LIMIT)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
if frame["kind"] == "inspect"
|
|
194
|
+
inspection(socket, frame, context, original, deadline, receiver_peer: receiver_peer)
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
key = Ace::Assign::Atoms::EvidenceDigest.digest(frame)
|
|
198
|
+
retained = @lock.synchronize do
|
|
199
|
+
if (entry = @consumed[context.fetch("record").fetch("request_id")])
|
|
200
|
+
raise SecurityError, "cleanup original invocation input differs" unless entry.fetch(:key) == key
|
|
201
|
+
entry
|
|
202
|
+
else
|
|
203
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
|
|
204
|
+
@consumed[context.fetch("record").fetch("request_id")] = {key: key, state: :invoking}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
result = @lock.synchronize { retained[:result] }
|
|
208
|
+
unless result
|
|
209
|
+
authorized = @lock.synchronize do
|
|
210
|
+
next false unless retained[:state] == :invoking && !retained[:inhibited]
|
|
211
|
+
retained[:state] = :invoked
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup original result unavailable" unless authorized
|
|
215
|
+
begin
|
|
216
|
+
# No canonical/source lock is held over fixed privileged work.
|
|
217
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
218
|
+
raise SecurityError, "cleanup original owner changed before invocation"
|
|
219
|
+
end
|
|
220
|
+
result = @installer.execute_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
|
|
221
|
+
result = result!(result, frame)
|
|
222
|
+
@lock.synchronize { retained[:result] = result; retained[:state] = :completed }
|
|
223
|
+
ensure
|
|
224
|
+
@lock.synchronize do
|
|
225
|
+
if retained[:state] == :invoked
|
|
226
|
+
retained[:state] = :unconfirmed
|
|
227
|
+
retained[:inhibited] = true
|
|
228
|
+
end
|
|
229
|
+
@changed.broadcast
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
234
|
+
raise SecurityError, "cleanup original owner changed before result"
|
|
235
|
+
end
|
|
236
|
+
ref = result.fetch(:receipt_ref)
|
|
237
|
+
Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "result", "request_id" => frame.fetch("request_id"),
|
|
238
|
+
"input_digest" => frame.fetch("input_digest"), "receipt_ref" => ref}, deadline: deadline, limit: Client::LIMIT)
|
|
239
|
+
descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
|
|
240
|
+
@codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
|
|
241
|
+
ensure
|
|
242
|
+
socket.close unless socket.closed?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def inspection(socket, frame, context, original, deadline, receiver_peer:)
|
|
248
|
+
original_owner = Ace::Assign::Atoms::EvidenceDigest.digest(context.fetch("record").fetch("operation_owner_binding")) ==
|
|
249
|
+
Ace::Assign::Atoms::EvidenceDigest.digest(original)
|
|
250
|
+
if original_owner
|
|
251
|
+
executable = frame.except("challenge_ref").merge("kind" => "execute")
|
|
252
|
+
key = Ace::Assign::Atoms::EvidenceDigest.digest(executable)
|
|
253
|
+
@lock.synchronize do
|
|
254
|
+
request = context.fetch("record").fetch("request_id")
|
|
255
|
+
retained = @consumed[request]
|
|
256
|
+
if retained
|
|
257
|
+
raise SecurityError, "cleanup original inspection input differs" unless retained.fetch(:key) == key
|
|
258
|
+
else
|
|
259
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
|
|
260
|
+
retained = @consumed[request] = {key: key, state: :inhibited}
|
|
261
|
+
end
|
|
262
|
+
retained[:inhibited] = true
|
|
263
|
+
retained[:state] = :inhibited if retained[:state] == :invoking
|
|
264
|
+
while retained[:state] == :invoked
|
|
265
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
266
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup original effect remains unconfirmed" unless remaining.positive?
|
|
267
|
+
@changed.wait(@lock, remaining)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
# This is callback inhibition only. Child/writer/physical absence
|
|
271
|
+
# still requires the actual domain inspector's independent proof.
|
|
272
|
+
inhibition = {"operation_owner_binding_digest" => frame.fetch("operation_owner_binding_digest"),
|
|
273
|
+
"dispatch_event_digest" => frame.fetch("dispatch_event_digest"), "state" => "inhibited", "pending_effects" => 0}
|
|
274
|
+
context = Atoms::ProtectedWorkspacePruneInput.freeze_value(context.merge("input_inhibition" => inhibition))
|
|
275
|
+
end
|
|
276
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
277
|
+
raise SecurityError, "cleanup readback owner changed before inspection"
|
|
278
|
+
end
|
|
279
|
+
inspected = @installer.inspect_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
|
|
280
|
+
unless inspected.is_a?(Hash) && inspected.keys.sort == %i[bytes kind receipt_ref] &&
|
|
281
|
+
%w[succeeded failed-no-effect].include?(inspected[:kind])
|
|
282
|
+
raise SecurityError, "cleanup inspection result unavailable"
|
|
283
|
+
end
|
|
284
|
+
kind = inspected.fetch(:kind).dup.freeze
|
|
285
|
+
unless original_owner || kind == "succeeded"
|
|
286
|
+
raise Ace::Runtime::RuntimeUnavailableError, "original cleanup lifetime exclusion unavailable"
|
|
287
|
+
end
|
|
288
|
+
result = result!(inspected.except(:kind), frame)
|
|
289
|
+
unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
|
|
290
|
+
raise SecurityError, "cleanup readback owner changed before inspection result"
|
|
291
|
+
end
|
|
292
|
+
success = kind == "succeeded"
|
|
293
|
+
Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => success ? "completed-result" : "inspection",
|
|
294
|
+
"request_id" => frame.fetch("request_id"), "input_digest" => frame.fetch("input_digest"),
|
|
295
|
+
(success ? "receipt_ref" : "inspection_ref") => result.fetch(:receipt_ref)}, deadline: deadline, limit: Client::LIMIT)
|
|
296
|
+
descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
|
|
297
|
+
@codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def hold_parent!(parent)
|
|
301
|
+
entries = []
|
|
302
|
+
paths = []
|
|
303
|
+
cursor = parent
|
|
304
|
+
loop do
|
|
305
|
+
paths << cursor
|
|
306
|
+
break if cursor == "/"
|
|
307
|
+
cursor = File.dirname(cursor)
|
|
308
|
+
end
|
|
309
|
+
paths.reverse_each do |path|
|
|
310
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
311
|
+
handle.close_on_exec = true
|
|
312
|
+
entries << {path: path, handle: handle, identity: file_identity(handle.stat)}
|
|
313
|
+
@protection.verify!(path, handle, directory: true)
|
|
314
|
+
raise SecurityError, "cleanup parent changed while pinned" unless file_identity(File.lstat(path)) == entries.last.fetch(:identity)
|
|
315
|
+
end
|
|
316
|
+
complete = true
|
|
317
|
+
entries
|
|
318
|
+
ensure
|
|
319
|
+
entries&.each { |entry| entry.fetch(:handle).close } unless complete
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def verify_parents!(entries)
|
|
323
|
+
entries.each do |entry|
|
|
324
|
+
unless file_identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
|
|
325
|
+
file_identity(File.lstat(entry.fetch(:path))) == entry.fetch(:identity)
|
|
326
|
+
raise SecurityError, "cleanup protected ancestry changed"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def file_identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
332
|
+
|
|
333
|
+
def result!(result, frame)
|
|
334
|
+
unless result.is_a?(Hash) && result.keys.sort == %i[bytes receipt_ref] && result.fetch(:bytes).is_a?(String)
|
|
335
|
+
raise SecurityError, "cleanup fixed result unavailable"
|
|
336
|
+
end
|
|
337
|
+
ref = result.fetch(:receipt_ref)
|
|
338
|
+
Atoms::ProtectedWorkspacePruneInput.reference!(ref)
|
|
339
|
+
bytes = result.fetch(:bytes)
|
|
340
|
+
unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
|
|
341
|
+
raise SecurityError, "cleanup fixed result bytes differ"
|
|
342
|
+
end
|
|
343
|
+
result = {receipt_ref: JSON.parse(JSON.generate(ref)), bytes: bytes.dup}
|
|
344
|
+
Atoms::ProtectedWorkspacePruneInput.freeze_value(result)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def eof!(socket, deadline)
|
|
348
|
+
guarded = Wire::Ingress.new(socket)
|
|
349
|
+
loop do
|
|
350
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
351
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup request deadline expired" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
352
|
+
value = guarded.read_nonblock(1, exception: false)
|
|
353
|
+
next if value == :wait_readable
|
|
354
|
+
raise SecurityError, "cleanup request contains trailing bytes" unless value.nil?
|
|
355
|
+
return
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_service_listener"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Organisms
|
|
8
|
+
# Worker-side short claim exchange. No local journal/executor fallback;
|
|
9
|
+
# outcome/recovery remains the existing protected authority service API.
|
|
10
|
+
class ProtectedServiceClient
|
|
11
|
+
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
12
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
13
|
+
@deployment, @kernel, @wire, @mapping_id = deployment, kernel, wire, mapping_id
|
|
14
|
+
@map = deployment.mapping(mapping_id)
|
|
15
|
+
project = deployment.project(@map.fetch("project_id"))
|
|
16
|
+
@selection = project.fetch("service_receivers").fetch(service_id)
|
|
17
|
+
@credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
|
|
18
|
+
@scratch = project.fetch("peer_credentials").fetch(@map.fetch("worker_uid").to_s).fetch("scratch_root")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def preview_workspace_prune(intent:)
|
|
22
|
+
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
|
|
23
|
+
bytes = JSON.generate(intent)
|
|
24
|
+
deadline = @wire.deadline(5)
|
|
25
|
+
@deployment.verify!(@mapping_id, kernel: @kernel)
|
|
26
|
+
caller = @kernel.capture(Process.pid)
|
|
27
|
+
unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
28
|
+
raise SecurityError, "preview caller differs"
|
|
29
|
+
end
|
|
30
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
|
|
31
|
+
descriptor = codec.descriptor([bytes], purpose: :service_input)
|
|
32
|
+
path = @selection.fetch("socket_path")
|
|
33
|
+
before = @wire.socket_identity(path)
|
|
34
|
+
raise SecurityError, "preview receiver owner differs" unless before.last == @selection.fetch("executor_uid")
|
|
35
|
+
@wire.connect(path, deadline: deadline) do |socket|
|
|
36
|
+
peer = @kernel.peer(socket)
|
|
37
|
+
unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
|
|
38
|
+
@wire.socket_identity(path) == before
|
|
39
|
+
raise SecurityError, "preview receiver peer changed"
|
|
40
|
+
end
|
|
41
|
+
@wire.write(socket, {"version" => 1, "kind" => "preview_workspace_prune", "transfer" => descriptor},
|
|
42
|
+
deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
43
|
+
codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
|
|
44
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
45
|
+
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
46
|
+
result = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
47
|
+
eof!(guarded, deadline)
|
|
48
|
+
raise SecurityError, "preview result unavailable" unless result.is_a?(Hash) && result.key?("maintenance_context")
|
|
49
|
+
Atoms::ProtectedWorkspacePrunePreview.context!(result.fetch("maintenance_context"))
|
|
50
|
+
Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: result.fetch("maintenance_context"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def submit(submission:, input_bytes:, mutation_id:)
|
|
55
|
+
unless submission.is_a?(Hash) && submission.keys.sort == ProtectedServiceReceiver::SUBMISSION.sort &&
|
|
56
|
+
input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
57
|
+
raise ArgumentError, "receiver submission is invalid"
|
|
58
|
+
end
|
|
59
|
+
submission = JSON.parse(JSON.generate(submission))
|
|
60
|
+
bytes = input_bytes.dup.freeze
|
|
61
|
+
mutation_id = mutation_id.dup.freeze
|
|
62
|
+
deadline = @wire.deadline(5)
|
|
63
|
+
@deployment.verify!(@mapping_id, kernel: @kernel)
|
|
64
|
+
caller = @kernel.capture(Process.pid)
|
|
65
|
+
unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
66
|
+
raise SecurityError, "receiver caller differs"
|
|
67
|
+
end
|
|
68
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
|
|
69
|
+
descriptor = codec.descriptor([bytes], purpose: :service_input)
|
|
70
|
+
path = @selection.fetch("socket_path")
|
|
71
|
+
before = @wire.socket_identity(path)
|
|
72
|
+
raise SecurityError, "receiver endpoint owner differs" unless before.last == @selection.fetch("executor_uid")
|
|
73
|
+
@wire.connect(path, deadline: deadline) do |socket|
|
|
74
|
+
peer = @kernel.peer(socket)
|
|
75
|
+
unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
|
|
76
|
+
@wire.socket_identity(path) == before
|
|
77
|
+
raise SecurityError, "receiver endpoint peer changed"
|
|
78
|
+
end
|
|
79
|
+
@wire.write(socket, {"version" => 1, "submission" => submission,
|
|
80
|
+
"mutation_id" => mutation_id, "transfer" => descriptor}, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
81
|
+
codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
|
|
82
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
83
|
+
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
84
|
+
reply = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
85
|
+
eof!(guarded, deadline)
|
|
86
|
+
validate_reply!(reply, submission.fetch("request_id"))
|
|
87
|
+
reply
|
|
88
|
+
end
|
|
89
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
|
|
90
|
+
{"version" => 1, "type" => "service_claim_unconfirmed", "required_action" => "inspect_canonical_service_status"}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def eof!(socket, deadline)
|
|
96
|
+
loop do
|
|
97
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
99
|
+
raise Ace::Runtime::RuntimeUnavailableError, "receiver reply EOF deadline expired"
|
|
100
|
+
end
|
|
101
|
+
byte = socket.read_nonblock(1, exception: false)
|
|
102
|
+
next if byte == :wait_readable
|
|
103
|
+
raise SecurityError, "receiver reply has trailing bytes" unless byte.nil?
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_reply!(reply, request_id)
|
|
109
|
+
unless reply.is_a?(Hash) && reply["version"].is_a?(Integer) && reply["version"] == 1
|
|
110
|
+
raise SecurityError, "receiver claim reply is invalid"
|
|
111
|
+
end
|
|
112
|
+
if reply["type"] == "service_claim_refused"
|
|
113
|
+
raise SecurityError, "receiver claim refusal is invalid" unless reply.keys.sort == %w[code type version] && %w[busy unavailable].include?(reply["code"])
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
data = reply["data"]
|
|
117
|
+
unless reply.keys.sort == %w[data type version] && reply["type"] == "service_claim_accepted" &&
|
|
118
|
+
data.is_a?(Hash) && (data.keys - %w[request_id generation journal_commit state claim replayed]).empty? &&
|
|
119
|
+
data["request_id"] == request_id && data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
120
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
121
|
+
%w[uncertain succeeded failed failed-settled].include?(data["state"]) && [true, false].include?(data["replayed"]) &&
|
|
122
|
+
(!data.key?("claim") || %w[created retained].include?(data["claim"]))
|
|
123
|
+
raise SecurityError, "receiver canonical claim identity differs"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require_relative "protected_service_worker"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Organisms
|
|
9
|
+
# Fixed receiver ingress. The short connection observes canonical claim
|
|
10
|
+
# acceptance; it does not own or cancel the admitted execution lifetime.
|
|
11
|
+
class ProtectedServiceListener
|
|
12
|
+
LIMIT = 16_384
|
|
13
|
+
REQUEST_KEYS = %w[mutation_id submission transfer version].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
16
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, receiver: nil,
|
|
17
|
+
wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
18
|
+
@deployment, @kernel, @wire = deployment, kernel, wire
|
|
19
|
+
@map = deployment.mapping(mapping_id)
|
|
20
|
+
project = deployment.project(@map.fetch("project_id"))
|
|
21
|
+
@selection = project.fetch("service_receivers").fetch(service_id)
|
|
22
|
+
@credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
|
|
23
|
+
@path = @selection.fetch("socket_path")
|
|
24
|
+
@receiver = receiver || ProtectedServiceReceiver.new(
|
|
25
|
+
mapping_id: mapping_id, service_id: service_id, deployment: deployment, kernel: kernel)
|
|
26
|
+
@worker = ProtectedServiceWorker.new(receiver: @receiver)
|
|
27
|
+
@stopping = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def serve
|
|
31
|
+
own_principal!
|
|
32
|
+
parent = File.dirname(@path)
|
|
33
|
+
@wire.root_path!(parent, directory: true, owner: @selection.fetch("executor_uid"))
|
|
34
|
+
# The installed receiver must actually be able to publish a socket
|
|
35
|
+
# accessible to its mapped worker. No ambient ACL/root fixup exists.
|
|
36
|
+
unless @credentials.fetch("gid") == @map.fetch("worker_gid") || @credentials.fetch("groups").include?(@map.fetch("worker_gid"))
|
|
37
|
+
raise SecurityError, "receiver socket group is unavailable"
|
|
38
|
+
end
|
|
39
|
+
@lock = File.open("#{@path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
40
|
+
stat = @lock.stat
|
|
41
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
42
|
+
raise SecurityError, "receiver listener lock differs"
|
|
43
|
+
end
|
|
44
|
+
unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
45
|
+
raise Ace::Assign::AttemptErrors::Conflict, "receiver listener already active"
|
|
46
|
+
end
|
|
47
|
+
remove_stale_endpoint!
|
|
48
|
+
@listener = UNIXServer.new(@path)
|
|
49
|
+
File.chown(nil, @map.fetch("worker_gid"), @path)
|
|
50
|
+
File.chmod(0o660, @path)
|
|
51
|
+
@identity = @wire.socket_identity(@path)
|
|
52
|
+
until @stopping
|
|
53
|
+
begin
|
|
54
|
+
socket = @listener.accept
|
|
55
|
+
receive(socket)
|
|
56
|
+
ensure
|
|
57
|
+
socket&.close
|
|
58
|
+
socket = nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
rescue IOError, Errno::EBADF
|
|
62
|
+
raise unless @stopping
|
|
63
|
+
ensure
|
|
64
|
+
# Never release listener exclusion while an admitted worker exists.
|
|
65
|
+
# Actual operation transport supplies its separate bounded lifetime.
|
|
66
|
+
@worker.close(timeout: 1) until @worker.close(timeout: 0)
|
|
67
|
+
@listener&.close
|
|
68
|
+
remove_owned_endpoint!
|
|
69
|
+
@lock&.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop
|
|
73
|
+
@stopping = true
|
|
74
|
+
@worker.close(timeout: 0)
|
|
75
|
+
@listener&.close
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def receive(socket)
|
|
81
|
+
deadline = @wire.deadline(5)
|
|
82
|
+
peer = @kernel.peer(socket)
|
|
83
|
+
unless peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
84
|
+
raise SecurityError, "receiver worker peer differs"
|
|
85
|
+
end
|
|
86
|
+
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
87
|
+
request = @wire.read(guarded, deadline: deadline, limit: LIMIT)
|
|
88
|
+
if request.is_a?(Hash) && request["kind"] == "preview_workspace_prune"
|
|
89
|
+
unless request.keys.sort == %w[kind transfer version] && request["version"].is_a?(Integer) && request["version"] == 1
|
|
90
|
+
raise ArgumentError, "receiver preview header differs"
|
|
91
|
+
end
|
|
92
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
|
|
93
|
+
bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) do |input|
|
|
94
|
+
raise ArgumentError, "preview requires one intent part" unless input.count == 1
|
|
95
|
+
input.bytes
|
|
96
|
+
end
|
|
97
|
+
intent = JSON.parse(bytes, create_additions: false, max_nesting: 16, allow_nan: false,
|
|
98
|
+
allow_comments: false, allow_duplicate_key: false)
|
|
99
|
+
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(intent)
|
|
100
|
+
result = @receiver.preview_workspace_prune(intent: intent, peer: peer, deadline: deadline)
|
|
101
|
+
@wire.write(socket, result, deadline: deadline, limit: LIMIT)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
strict_request!(request)
|
|
105
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
|
|
106
|
+
bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) { |input| input.bytes }
|
|
107
|
+
completed = Queue.new
|
|
108
|
+
@worker.start(submission: request.fetch("submission"), peer: peer,
|
|
109
|
+
input_bytes: bytes, mutation_id: request.fetch("mutation_id"), deadline: deadline,
|
|
110
|
+
on_claim: lambda do |identity|
|
|
111
|
+
begin
|
|
112
|
+
@wire.write(socket, {"version" => 1, "type" => "service_claim_accepted", "data" => identity},
|
|
113
|
+
deadline: deadline, limit: LIMIT)
|
|
114
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
|
|
115
|
+
# Only bounded transport failure is swallowed. Original work
|
|
116
|
+
# continues and its accepted identity is observed via status.
|
|
117
|
+
nil
|
|
118
|
+
ensure
|
|
119
|
+
completed << true
|
|
120
|
+
end
|
|
121
|
+
end)
|
|
122
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
completed.pop(timeout: remaining) if remaining.positive?
|
|
124
|
+
rescue Ace::Assign::AttemptErrors::Conflict
|
|
125
|
+
refuse(socket, "busy", deadline)
|
|
126
|
+
rescue SecurityError, ArgumentError, KeyError, TypeError, Ace::Assign::Error,
|
|
127
|
+
Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
|
|
128
|
+
refuse(socket, "unavailable", deadline)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def strict_request!(request)
|
|
132
|
+
unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS &&
|
|
133
|
+
request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
134
|
+
request["transfer"].is_a?(Hash) && request["transfer"]["version"].is_a?(Integer) && request["transfer"]["version"] == 1 &&
|
|
135
|
+
request["mutation_id"].is_a?(String) && request["mutation_id"].match?(Ace::Assign::Molecules::JournalMutation::ID) &&
|
|
136
|
+
request["submission"].is_a?(Hash) && request["submission"].keys.sort == ProtectedServiceReceiver::SUBMISSION.sort
|
|
137
|
+
raise ArgumentError, "receiver request is invalid"
|
|
138
|
+
end
|
|
139
|
+
walk = [[request, 0]]
|
|
140
|
+
until walk.empty?
|
|
141
|
+
value, depth = walk.pop
|
|
142
|
+
raise ArgumentError, "receiver request depth exceeds bound" if depth > 16
|
|
143
|
+
children = value.is_a?(Hash) ? value.values : (value.is_a?(Array) ? value : [])
|
|
144
|
+
children.each { |child| walk << [child, depth + 1] }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def refuse(socket, code, deadline)
|
|
149
|
+
@wire.write(socket, {"version" => 1, "type" => "service_claim_refused", "code" => code}, deadline: deadline, limit: LIMIT)
|
|
150
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def own_principal!
|
|
155
|
+
me = @kernel.capture(Process.pid)
|
|
156
|
+
unless me.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")]
|
|
157
|
+
raise SecurityError, "receiver listener principal differs"
|
|
158
|
+
end
|
|
159
|
+
@kernel.live!(me)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def remove_stale_endpoint!
|
|
163
|
+
stat = File.lstat(@path)
|
|
164
|
+
unless stat.socket? && stat.uid == Process.uid
|
|
165
|
+
raise SecurityError, "receiver endpoint differs"
|
|
166
|
+
end
|
|
167
|
+
File.unlink(@path)
|
|
168
|
+
rescue Errno::ENOENT
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def remove_owned_endpoint!
|
|
173
|
+
File.unlink(@path) if @identity && @wire.socket_identity(@path) == @identity
|
|
174
|
+
rescue Errno::ENOENT
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|