ace-runtime 0.1.1 → 0.3.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 +58 -0
- data/README.md +9 -0
- data/docs/usage.md +123 -0
- data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
- data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
- data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
- data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +663 -0
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
- data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
- data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
- data/lib/ace/runtime/molecules/process_identity.rb +123 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +211 -0
- data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
- data/lib/ace/runtime/molecules/protected_socket.rb +119 -0
- data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
- data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
- data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
- data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
- data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +411 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +3 -0
- data/native/README.md +124 -0
- data/native/build-worker-gate +20 -0
- data/native/worker_gate.c +385 -0
- metadata +63 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "protected_artifact_set"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
# Ref-only installed discovery. Domain publication owns the projection;
|
|
9
|
+
# the selected child remains the original scope/admission authority.
|
|
10
|
+
class ProtectedTaskContextEntry
|
|
11
|
+
PATH = "/etc/ace/task-context-entry.json"
|
|
12
|
+
PRESENCE_PATHS = %w[/etc/ace/assignment-authorities.json /etc/ace/assignment-deployment-history.json /usr/local/lib/lab/herdr.installation.json].freeze
|
|
13
|
+
SCHEMA = "ace.protected-task-context-entry-selection/v1"
|
|
14
|
+
MANIFEST_SCHEMA = "lab.protected-command-entry/v1"
|
|
15
|
+
ROLE = "ace-assign-task-context"
|
|
16
|
+
METADATA_LIMIT = 65_536
|
|
17
|
+
FILE_LIMIT = 33_554_432
|
|
18
|
+
TOTAL_LIMIT = 268_435_456
|
|
19
|
+
COUNT_LIMIT = 4_096
|
|
20
|
+
Entry = Struct.new(:pin, :manifest, :body, :interpreter, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
def initialize(artifacts_factory: -> { ProtectedArtifactSet.new(file_limit: FILE_LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT) },
|
|
23
|
+
stat: File.method(:lstat))
|
|
24
|
+
@factory, @stat = artifacts_factory, stat
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pure shape validation, never publication/original admission authority.
|
|
28
|
+
def validate_pin!(pin)
|
|
29
|
+
pin!(pin)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def with
|
|
33
|
+
unless present?(PATH)
|
|
34
|
+
refuse!("installed discovery is absent") if PRESENCE_PATHS.any? { |path| present?(path) }
|
|
35
|
+
return yield nil
|
|
36
|
+
end
|
|
37
|
+
@factory.call.with do |held|
|
|
38
|
+
@held = held
|
|
39
|
+
bytes, = held.read_path!(PATH, limit: METADATA_LIMIT)
|
|
40
|
+
projection = json!(bytes)
|
|
41
|
+
object!(projection, %w[schema task_context_entry])
|
|
42
|
+
refuse!("discovery schema") unless projection.fetch("schema") == SCHEMA
|
|
43
|
+
pin = pin!(projection.fetch("task_context_entry"))
|
|
44
|
+
held.verify_unchanged!
|
|
45
|
+
result = yield pin
|
|
46
|
+
held.verify_unchanged!
|
|
47
|
+
result
|
|
48
|
+
ensure
|
|
49
|
+
@held = nil
|
|
50
|
+
end
|
|
51
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
|
|
52
|
+
refuse!("installed discovery is unavailable")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Live descriptor lifetime is this callback only. Exact protected bytes
|
|
56
|
+
# go to stdin; callers never execute/reopen a manifest-selected pathname.
|
|
57
|
+
def with_entry(pin)
|
|
58
|
+
refuse!("entry requires held discovery") unless @held
|
|
59
|
+
pin = pin!(pin)
|
|
60
|
+
manifest = json!(@held.read!(pin.fetch("manifest")))
|
|
61
|
+
object!(manifest, %w[schema role wrapper interpreter bootstrap])
|
|
62
|
+
unless manifest.fetch("schema") == MANIFEST_SCHEMA && manifest.fetch("role") == ROLE && manifest.fetch("wrapper") == pin.fetch("wrapper")
|
|
63
|
+
refuse!("selected fixed role differs")
|
|
64
|
+
end
|
|
65
|
+
reference!(manifest.fetch("interpreter"))
|
|
66
|
+
bootstrap = manifest.fetch("bootstrap")
|
|
67
|
+
object!(bootstrap, %w[original_source owner preparation startup_entries])
|
|
68
|
+
%w[original_source owner preparation].each do |key|
|
|
69
|
+
reference!(bootstrap.fetch(key))
|
|
70
|
+
@held.read!(bootstrap.fetch(key))
|
|
71
|
+
end
|
|
72
|
+
entries = bootstrap.fetch("startup_entries")
|
|
73
|
+
refuse!("bootstrap entry references") unless entries.is_a?(Hash)
|
|
74
|
+
reference!(entries.fetch("ace-assign"))
|
|
75
|
+
read_references!(entries)
|
|
76
|
+
body = @held.read!(pin.fetch("wrapper"))
|
|
77
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
78
|
+
refuse!("wrapper source encoding") unless text.valid_encoding? && !text.include?("\0")
|
|
79
|
+
@held.with_readonly_handle!(manifest.fetch("interpreter")) do |interpreter|
|
|
80
|
+
stat = interpreter.stat
|
|
81
|
+
refuse!("selected interpreter is not executable") unless stat.file? && stat.executable? && (stat.mode & 0o6000).zero?
|
|
82
|
+
@held.verify_unchanged!
|
|
83
|
+
result = yield Entry.new(pin: pin, manifest: immutable(manifest), body: body, interpreter: interpreter).freeze
|
|
84
|
+
@held.verify_unchanged!
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
|
|
88
|
+
refuse!("selected protected entry is unavailable")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def present?(path)
|
|
94
|
+
@stat.call(path)
|
|
95
|
+
true
|
|
96
|
+
rescue Errno::ENOENT
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pin!(pin)
|
|
101
|
+
object!(pin, %w[manifest wrapper])
|
|
102
|
+
reference!(pin.fetch("manifest"), limit: METADATA_LIMIT)
|
|
103
|
+
reference!(pin.fetch("wrapper"))
|
|
104
|
+
immutable(pin)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reference!(reference, limit: FILE_LIMIT)
|
|
108
|
+
object!(reference, %w[bytes path sha256])
|
|
109
|
+
path = reference.fetch("path")
|
|
110
|
+
unless path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? &&
|
|
111
|
+
path.bytesize.between?(2, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path &&
|
|
112
|
+
reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, limit) &&
|
|
113
|
+
reference.fetch("sha256").is_a?(String) && reference.fetch("sha256").match?(/\A[0-9a-f]{64}\z/)
|
|
114
|
+
refuse!("protected entry reference")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_references!(root)
|
|
119
|
+
pending = [root]
|
|
120
|
+
until pending.empty?
|
|
121
|
+
value = pending.pop
|
|
122
|
+
if value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256]
|
|
123
|
+
reference!(value)
|
|
124
|
+
@held.read!(value)
|
|
125
|
+
elsif value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
|
|
126
|
+
pending.concat(value.values)
|
|
127
|
+
elsif value.is_a?(Array)
|
|
128
|
+
pending.concat(value)
|
|
129
|
+
else
|
|
130
|
+
refuse!("bootstrap reference graph")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def json!(bytes)
|
|
136
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
137
|
+
refuse!("entry metadata encoding or bound") unless text.valid_encoding? && text.bytesize.between?(1, METADATA_LIMIT)
|
|
138
|
+
JSON.parse(text, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
refuse!("entry metadata JSON")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def object!(value, fields)
|
|
144
|
+
refuse!("entry fields") unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def immutable(value)
|
|
148
|
+
case value
|
|
149
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
150
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
151
|
+
when String then value.dup.freeze
|
|
152
|
+
else value.freeze
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def refuse!(detail)
|
|
157
|
+
raise RuntimeUnavailableError, "protected task-context entry unavailable: #{detail}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Runtime
|
|
5
|
+
module Molecules
|
|
6
|
+
# Structural admission of source-owned pins. Artifact authentication belongs
|
|
7
|
+
# to the selected entry owner; these references are never an execution grant.
|
|
8
|
+
module ProtectedWorkerEntry
|
|
9
|
+
LIMITS = {"interpreter" => 33_554_432, "wrapper" => 1_048_576}.freeze
|
|
10
|
+
|
|
11
|
+
def self.validate!(value)
|
|
12
|
+
unless value.is_a?(Hash) && value.keys.sort == LIMITS.keys.sort
|
|
13
|
+
raise ArgumentError, "worker entry fields differ"
|
|
14
|
+
end
|
|
15
|
+
LIMITS.each do |key, limit|
|
|
16
|
+
reference = value[key]
|
|
17
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
|
|
18
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, limit) &&
|
|
19
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
20
|
+
reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
|
|
21
|
+
reference["path"].valid_encoding? && reference["path"].bytesize.between?(2, 4096) &&
|
|
22
|
+
!reference["path"].include?("\0") && reference["path"].start_with?("/") &&
|
|
23
|
+
File.expand_path(reference["path"]) == reference["path"]
|
|
24
|
+
raise ArgumentError, "worker entry reference differs"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "protected_artifact_set"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Runtime
|
|
9
|
+
module Molecules
|
|
10
|
+
# Immutable local installer input. This contains selections only; live
|
|
11
|
+
# process and resource observations must come from their owning readers.
|
|
12
|
+
class ReadinessConfiguration
|
|
13
|
+
LIMIT = 65_536
|
|
14
|
+
SCHEMA = "ace.assign.readiness/v1"
|
|
15
|
+
FIELDS = %w[authority boundary_manifest mapping_id native project_id runtime schema slot_id worker].freeze
|
|
16
|
+
attr_reader :data
|
|
17
|
+
|
|
18
|
+
def self.path(slot)
|
|
19
|
+
unless slot.is_a?(String) && slot.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
|
|
20
|
+
raise RuntimeUnavailableError, "invalid readiness slot"
|
|
21
|
+
end
|
|
22
|
+
"/etc/ace/execution-slots/#{slot}/readiness.json"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.decode(bytes, slot:)
|
|
26
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
27
|
+
unless text.bytesize.between?(1, LIMIT) && text.valid_encoding?
|
|
28
|
+
raise RuntimeUnavailableError, "readiness configuration is not bounded UTF-8"
|
|
29
|
+
end
|
|
30
|
+
new(JSON.parse(text, create_additions: false, max_nesting: 8,
|
|
31
|
+
allow_duplicate_key: false, allow_comments: false), slot: slot)
|
|
32
|
+
rescue JSON::ParserError, EncodingError
|
|
33
|
+
raise RuntimeUnavailableError, "readiness configuration is not strict JSON"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.load(slot:)
|
|
37
|
+
ProtectedArtifactSet.new.with do |artifacts|
|
|
38
|
+
bytes, = artifacts.read_path!(path(slot), limit: LIMIT)
|
|
39
|
+
value = decode(bytes, slot: slot)
|
|
40
|
+
artifacts.verify_unchanged!
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(data, slot:)
|
|
46
|
+
object!(data, FIELDS)
|
|
47
|
+
unless data["schema"] == SCHEMA && data["slot_id"] == slot
|
|
48
|
+
refuse!("readiness configuration selection differs")
|
|
49
|
+
end
|
|
50
|
+
%w[mapping_id project_id].each do |key|
|
|
51
|
+
refuse!("invalid readiness identifier") unless data[key].is_a?(String) &&
|
|
52
|
+
data[key].match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
|
|
53
|
+
end
|
|
54
|
+
object!(data.fetch("authority"), %w[gid groups socket_path uid])
|
|
55
|
+
object!(data.fetch("worker"), %w[gid groups uid])
|
|
56
|
+
%w[authority worker].each do |kind|
|
|
57
|
+
principal = data.fetch(kind)
|
|
58
|
+
unless %w[uid gid].all? { |key| principal[key].is_a?(Integer) && principal[key].positive? } &&
|
|
59
|
+
principal["groups"].is_a?(Array) && principal["groups"].all? { |gid| gid.is_a?(Integer) && gid.positive? } &&
|
|
60
|
+
principal["groups"].sort.uniq == principal["groups"]
|
|
61
|
+
refuse!("readiness principal differs")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
native = data.fetch("native")
|
|
65
|
+
object!(native, %w[executable executable_sha256 protocol socket_path version workspace_id])
|
|
66
|
+
unless native["protocol"] == 22 && native["version"] == "0.9.3" &&
|
|
67
|
+
native["workspace_id"].is_a?(String) && native["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/)
|
|
68
|
+
refuse!("readiness native protocol differs")
|
|
69
|
+
end
|
|
70
|
+
digest!(native["executable_sha256"])
|
|
71
|
+
boundary = data.fetch("boundary_manifest")
|
|
72
|
+
object!(boundary, %w[path sha256])
|
|
73
|
+
unless boundary["path"] == "/etc/ace/execution-slots/#{slot}/boundary-manifest.json"
|
|
74
|
+
refuse!("readiness boundary path differs")
|
|
75
|
+
end
|
|
76
|
+
digest!(boundary["sha256"])
|
|
77
|
+
[data.dig("authority", "socket_path"), native["socket_path"], native["executable"]].each { |path| path!(path) }
|
|
78
|
+
runtime = data.fetch("runtime")
|
|
79
|
+
object!(runtime, %w[dependencies interpreter_path load_paths])
|
|
80
|
+
path!(runtime.fetch("interpreter_path"))
|
|
81
|
+
paths = runtime.fetch("load_paths")
|
|
82
|
+
unless paths.is_a?(Array) && paths.size.between?(1, 16) && paths.uniq == paths
|
|
83
|
+
refuse!("readiness load paths differ")
|
|
84
|
+
end
|
|
85
|
+
paths.each { |path| path!(path) }
|
|
86
|
+
dependencies = runtime.fetch("dependencies")
|
|
87
|
+
unless dependencies.is_a?(Array) && dependencies.size.between?(1, 256) &&
|
|
88
|
+
dependencies.map { |ref| ref.fetch("path") }.uniq.size == dependencies.size
|
|
89
|
+
refuse!("readiness dependency closure differs")
|
|
90
|
+
end
|
|
91
|
+
dependencies.each do |ref|
|
|
92
|
+
object!(ref, %w[bytes path sha256])
|
|
93
|
+
path!(ref.fetch("path"))
|
|
94
|
+
digest!(ref.fetch("sha256"))
|
|
95
|
+
unless ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, ProtectedArtifactSet::LIMIT)
|
|
96
|
+
refuse!("readiness dependency size differs")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
@data = immutable(data)
|
|
100
|
+
freeze
|
|
101
|
+
rescue KeyError, TypeError, ArgumentError
|
|
102
|
+
refuse!("readiness configuration fields differ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def verify_selection!(mapping_id:, mapping:, authority:, installation:)
|
|
106
|
+
expected = {"schema" => SCHEMA, "slot_id" => mapping.fetch("execution_scope").fetch("slot_id"),
|
|
107
|
+
"mapping_id" => mapping_id, "project_id" => mapping.fetch("project_id"),
|
|
108
|
+
"authority" => authority.slice("socket_path", "uid", "gid", "groups"),
|
|
109
|
+
"worker" => {"uid" => mapping.fetch("worker_uid"), "gid" => mapping.fetch("worker_gid"),
|
|
110
|
+
"groups" => mapping.fetch("worker_groups")}, "native" => mapping.fetch("native"),
|
|
111
|
+
"boundary_manifest" => {"path" => "/etc/ace/execution-slots/#{data.fetch('slot_id')}/boundary-manifest.json",
|
|
112
|
+
"sha256" => mapping.fetch("execution_scope").fetch("boundary_manifest_sha256")}, "runtime" => data.fetch("runtime")}
|
|
113
|
+
refuse!("readiness configuration does not join protected deployment") unless data == expected
|
|
114
|
+
artifacts = installation.fetch("artifacts")
|
|
115
|
+
closure = data.fetch("runtime").fetch("dependencies")
|
|
116
|
+
unless closure.all? { |ref| artifacts.any? { |artifact|
|
|
117
|
+
%w[runtime_dependency readiness_executable].include?(artifact.fetch("role")) &&
|
|
118
|
+
artifact.fetch("view_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") } } &&
|
|
119
|
+
artifacts.any? { |artifact| artifact.fetch("role") == "runtime_dependency" &&
|
|
120
|
+
artifact.fetch("view_path") == data.dig("runtime", "interpreter_path") }
|
|
121
|
+
refuse!("readiness dependency closure does not join installation")
|
|
122
|
+
end
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def object!(value, keys)
|
|
129
|
+
refuse!("readiness object fields differ") unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def path!(value)
|
|
133
|
+
refuse!("readiness path differs") unless value.is_a?(String) && value.bytesize.between?(2, 4096) &&
|
|
134
|
+
value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def digest!(value)
|
|
138
|
+
refuse!("readiness digest differs") unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def immutable(value)
|
|
142
|
+
case value
|
|
143
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
144
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
145
|
+
when String then value.dup.freeze
|
|
146
|
+
else value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def refuse!(message) = raise(RuntimeUnavailableError, message)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "protected_artifact_set"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
# The private hook installs this guard before loading its ACE dependencies.
|
|
9
|
+
# Every executable file is verified before require can evaluate it.
|
|
10
|
+
class ReadinessRuntime
|
|
11
|
+
BUILTINS = %w[enumerator.so thread.rb fiber.so rational.so complex.so ruby2_keywords.rb].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(configuration:, artifacts: ProtectedArtifactSet.new)
|
|
14
|
+
@runtime, @artifacts = configuration.fetch("runtime"), artifacts
|
|
15
|
+
@references = @runtime.fetch("dependencies").to_h { |ref| [ref.fetch("path"), ref] }.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def activate!(features: $LOADED_FEATURES, load_paths: $LOAD_PATH)
|
|
19
|
+
verify_loaded!(features)
|
|
20
|
+
@runtime.fetch("load_paths").each do |path|
|
|
21
|
+
ProtectedSocket.root_path!(path, directory: true)
|
|
22
|
+
end
|
|
23
|
+
load_paths.replace(@runtime.fetch("load_paths"))
|
|
24
|
+
@initialized_builtins ||= BUILTINS.select { |feature| features.include?(feature) }.freeze
|
|
25
|
+
owner = self
|
|
26
|
+
guard = Module.new do
|
|
27
|
+
define_method(:require) do |feature|
|
|
28
|
+
return false if owner.builtin_loaded?(feature)
|
|
29
|
+
path = owner.resolve!(feature)
|
|
30
|
+
owner.verify_file!(path)
|
|
31
|
+
super(path)
|
|
32
|
+
end
|
|
33
|
+
define_method(:require_relative) do |feature|
|
|
34
|
+
caller_file = caller_locations(1, 1).first.absolute_path
|
|
35
|
+
path = owner.resolve!(File.expand_path(feature, File.dirname(caller_file)))
|
|
36
|
+
owner.verify_file!(path)
|
|
37
|
+
require(path)
|
|
38
|
+
end
|
|
39
|
+
private :require, :require_relative
|
|
40
|
+
end
|
|
41
|
+
Kernel.prepend(guard)
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def verify_loaded!(features)
|
|
46
|
+
features.each do |feature|
|
|
47
|
+
next if BUILTINS.include?(feature)
|
|
48
|
+
unless feature.is_a?(String) && feature.start_with?("/") && @references.key?(feature)
|
|
49
|
+
raise RuntimeUnavailableError, "readiness startup loaded undeclared code"
|
|
50
|
+
end
|
|
51
|
+
verify_file!(feature)
|
|
52
|
+
end
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def builtin_loaded?(feature)
|
|
57
|
+
return false unless feature.is_a?(String) && @initialized_builtins
|
|
58
|
+
@initialized_builtins.any? do |builtin|
|
|
59
|
+
feature == builtin || feature == builtin.sub(/\.(?:rb|so)\z/, "")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve!(feature)
|
|
64
|
+
unless feature.is_a?(String) && !feature.include?("\0")
|
|
65
|
+
raise RuntimeUnavailableError, "readiness require selection differs"
|
|
66
|
+
end
|
|
67
|
+
bases = feature.start_with?("/") ? [feature] : @runtime.fetch("load_paths").map { |path| File.join(path, feature) }
|
|
68
|
+
extension = "." + RbConfig::CONFIG.fetch("DLEXT")
|
|
69
|
+
candidates = bases.flat_map do |path|
|
|
70
|
+
# Ruby maps explicit .so/.o requests to its own platform extension.
|
|
71
|
+
if path.end_with?(".so", ".o")
|
|
72
|
+
[path.sub(/\.(?:so|o)\z/, extension)]
|
|
73
|
+
elsif path.end_with?(".rb", extension)
|
|
74
|
+
[path]
|
|
75
|
+
else
|
|
76
|
+
[path, path + ".rb", path + extension]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
selected = candidates.find { |path| @references.key?(path) }
|
|
80
|
+
raise RuntimeUnavailableError, "readiness require is outside protected closure" unless selected
|
|
81
|
+
selected
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verify_file!(path)
|
|
85
|
+
@artifacts.with do |reader|
|
|
86
|
+
reader.read!(@references.fetch(path))
|
|
87
|
+
reader.verify_unchanged!
|
|
88
|
+
end
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "linux_mount_info"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Runtime
|
|
10
|
+
module Molecules
|
|
11
|
+
# Observes declared reachable objects in a pinned server root. This does
|
|
12
|
+
# not enter or modify a namespace and carries no admission authority.
|
|
13
|
+
class ServerResourceObservation
|
|
14
|
+
VIEW_PATHS = %w[/proc /proc/sys /sys /sys/fs/cgroup /dev /dev/pts /dev/mqueue /dev/shm /tmp /var/tmp].freeze
|
|
15
|
+
MOUNT_FIELDS = %w[mount_id parent_id major_minor root mountpoint options filesystem_type].freeze
|
|
16
|
+
class Files
|
|
17
|
+
OPENAT2 = 437
|
|
18
|
+
RESOLVE = 0x10 | 0x04 # IN_ROOT | NO_SYMLINKS; bind mounts are allowed.
|
|
19
|
+
|
|
20
|
+
def supported!
|
|
21
|
+
unless RUBY_PLATFORM.include?("linux") && %w[x86_64 aarch64 arm64].include?(RbConfig::CONFIG.fetch("host_cpu"))
|
|
22
|
+
raise RuntimeUnavailableError, "contained server-root observation is unsupported"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def namespace(pid, kind = "mnt")
|
|
27
|
+
File.open("/proc/#{pid}/ns/#{kind}", File::RDONLY) { |file| identity(file.stat) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def observe(pid, entries, authority_socket:)
|
|
31
|
+
supported!
|
|
32
|
+
root = File.open("/proc/#{pid}/root", File::RDONLY)
|
|
33
|
+
namespace = File.open("/proc/#{pid}/ns/mnt", File::RDONLY)
|
|
34
|
+
network = File.open("/proc/#{pid}/ns/net", File::RDONLY)
|
|
35
|
+
network_identity = network_identity!(network)
|
|
36
|
+
ipc = File.open("/proc/#{pid}/ns/ipc", File::RDONLY)
|
|
37
|
+
hook_ipc = File.open("/proc/self/ns/ipc", File::RDONLY)
|
|
38
|
+
root_identity, namespace_identity = identity(root.stat), identity(namespace.stat)
|
|
39
|
+
ipc_identity, hook_ipc_identity = identity(ipc.stat), identity(hook_ipc.stat)
|
|
40
|
+
table = LinuxMountInfo.new(File.read("/proc/#{pid}/mountinfo", LinuxMountInfo::LIMIT + 1))
|
|
41
|
+
raise RuntimeUnavailableError, "server mount inventory exceeds bound" if table.records.size > 256
|
|
42
|
+
topology = []
|
|
43
|
+
result = entries.map do |entry|
|
|
44
|
+
handle = open_inside(root, entry.fetch("view_path"))
|
|
45
|
+
begin
|
|
46
|
+
stat = handle.stat
|
|
47
|
+
raise RuntimeUnavailableError, "declared server resource is not a directory" unless stat.directory?
|
|
48
|
+
rows = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385).lines.filter_map do |line|
|
|
49
|
+
/\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1)
|
|
50
|
+
end
|
|
51
|
+
raise RuntimeUnavailableError, "server resource mount is ambiguous" unless rows.size == 1
|
|
52
|
+
mount = table.by_id(Integer(rows.first, 10))
|
|
53
|
+
topology << entry.slice("host_path", "view_path").merge(mount.slice("mountpoint", "root", "major_minor", "options"))
|
|
54
|
+
entry.slice("host_path", "view_path").merge("device" => stat.dev, "inode" => stat.ino,
|
|
55
|
+
"uid" => stat.uid, "gid" => stat.gid, "filesystem_type" => mount.fetch("filesystem_type"),
|
|
56
|
+
"mount_id" => mount.fetch("mount_id"))
|
|
57
|
+
ensure
|
|
58
|
+
handle.close
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
views = VIEW_PATHS.map { |path| pinned_view(root, path, table: table) }
|
|
62
|
+
socket_identity = pinned_socket(root, authority_socket)
|
|
63
|
+
ptmx_identity, ptmx_link = pinned_ptmx(root, table: table)
|
|
64
|
+
repeated = LinuxMountInfo.new(File.read("/proc/#{pid}/mountinfo", LinuxMountInfo::LIMIT + 1))
|
|
65
|
+
unless namespace_identity == self.namespace(pid) && root_identity == identity(File.stat("/proc/#{pid}/root")) &&
|
|
66
|
+
network_identity == self.namespace(pid, "net") &&
|
|
67
|
+
ipc_identity == self.namespace(pid, "ipc") && hook_ipc_identity == self.namespace("self", "ipc") &&
|
|
68
|
+
table.records == repeated.records &&
|
|
69
|
+
views == VIEW_PATHS.map { |path| pinned_view(root, path, table: repeated) } &&
|
|
70
|
+
socket_identity == pinned_socket(root, authority_socket) &&
|
|
71
|
+
[ptmx_identity, ptmx_link] == pinned_ptmx(root, table: repeated)
|
|
72
|
+
raise RuntimeUnavailableError, "server root or namespace changed during observation"
|
|
73
|
+
end
|
|
74
|
+
{"mount_namespace_identity" => namespace_identity, "network_namespace_identity" => network_identity, "resource_identities" => result, "resource_topology" => topology,
|
|
75
|
+
"kernel_view_topology" => {"ipc_namespace_identity" => ipc_identity, "hook_ipc_namespace_identity" => hook_ipc_identity,
|
|
76
|
+
"mounts" => table.records.map { |row| row.slice(*MOUNT_FIELDS) }, "views" => views, "authority_socket_identity" => socket_identity,
|
|
77
|
+
"ptmx_identity" => ptmx_identity, "ptmx_link" => ptmx_link}}
|
|
78
|
+
ensure
|
|
79
|
+
root&.close
|
|
80
|
+
namespace&.close
|
|
81
|
+
network&.close
|
|
82
|
+
ipc&.close
|
|
83
|
+
hook_ipc&.close
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# NS_GET_NSTYPE is an nsfs ioctl. Pin the original server's object,
|
|
89
|
+
# require CLONE_NEWNET, and retain it across the repeated path read.
|
|
90
|
+
def network_identity!(handle)
|
|
91
|
+
unless handle.ioctl(0xb703) == 0x40000000
|
|
92
|
+
raise RuntimeUnavailableError, "server object is not a network namespace"
|
|
93
|
+
end
|
|
94
|
+
identity(handle.stat)
|
|
95
|
+
rescue SystemCallError, IOError
|
|
96
|
+
raise RuntimeUnavailableError, "server network namespace type is unavailable"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def identity(stat) = {"device" => stat.dev, "inode" => stat.ino}
|
|
100
|
+
|
|
101
|
+
def pinned_view(root, path, table:)
|
|
102
|
+
handle = open_inside(root, path, flags: 0x200000 | File::NOFOLLOW) # O_PATH, no data access.
|
|
103
|
+
stat = handle.stat
|
|
104
|
+
raise RuntimeUnavailableError, "fixed server view is not a directory" unless stat.directory?
|
|
105
|
+
rows = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385).lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
106
|
+
raise RuntimeUnavailableError, "fixed view mount is ambiguous" unless rows.size == 1
|
|
107
|
+
mount = table.by_id(Integer(rows.first, 10))
|
|
108
|
+
{"path" => path, "mount_id" => mount.fetch("mount_id"), "device" => stat.dev, "inode" => stat.ino, "type" => "directory"}
|
|
109
|
+
ensure
|
|
110
|
+
handle&.close
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pinned_socket(root, path)
|
|
114
|
+
handle = open_inside(root, path, flags: 0x200000 | File::NOFOLLOW)
|
|
115
|
+
stat = handle.stat
|
|
116
|
+
raise RuntimeUnavailableError, "authority projection is not a socket" unless stat.socket?
|
|
117
|
+
[stat.dev, stat.ino, stat.uid]
|
|
118
|
+
ensure
|
|
119
|
+
handle&.close
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def pinned_ptmx(root, table:)
|
|
123
|
+
dev = open_inside(root, "/dev", flags: 0x200000 | File::NOFOLLOW)
|
|
124
|
+
link = read_ptmx_link(dev)
|
|
125
|
+
raise RuntimeUnavailableError, "selected ptmx link differs" unless link == "pts/ptmx"
|
|
126
|
+
node = open_inside(root, "/dev/pts/ptmx", flags: 0x200000 | File::NOFOLLOW)
|
|
127
|
+
stat = node.stat
|
|
128
|
+
raise RuntimeUnavailableError, "selected ptmx is not a character device" unless stat.chardev? && stat.rdev_major == 5 && stat.rdev_minor == 2
|
|
129
|
+
rows = File.read("/proc/self/fdinfo/#{node.fileno}", 16_385).lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
130
|
+
raise RuntimeUnavailableError, "selected ptmx mount is ambiguous" unless rows.size == 1
|
|
131
|
+
mount = table.by_id(Integer(rows.first, 10))
|
|
132
|
+
[{"device" => stat.dev, "inode" => stat.ino, "mount_id" => mount.fetch("mount_id"), "type" => "character",
|
|
133
|
+
"rdev_major" => stat.rdev_major, "rdev_minor" => stat.rdev_minor}, link]
|
|
134
|
+
ensure
|
|
135
|
+
dev&.close
|
|
136
|
+
node&.close
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def read_ptmx_link(dev)
|
|
140
|
+
call = Fiddle::Function.new(Fiddle::Handle::DEFAULT["readlinkat"],
|
|
141
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
|
|
142
|
+
buffer = Fiddle::Pointer.malloc(64)
|
|
143
|
+
count = call.call(dev.fileno, "ptmx\0", buffer, 64)
|
|
144
|
+
raise RuntimeUnavailableError, "selected ptmx link cannot be observed" unless count.between?(1, 63)
|
|
145
|
+
buffer.to_s(count)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def open_inside(root, path, flags: File::RDONLY | File::NONBLOCK | File::NOFOLLOW)
|
|
149
|
+
unless path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
|
|
150
|
+
!path.include?("\0") && File.expand_path(path) == path
|
|
151
|
+
raise RuntimeUnavailableError, "server resource path differs"
|
|
152
|
+
end
|
|
153
|
+
syscall = Fiddle::Function.new(Fiddle::Handle::DEFAULT["syscall"],
|
|
154
|
+
[Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
|
|
155
|
+
Fiddle::TYPE_LONG)
|
|
156
|
+
how = [flags, 0, RESOLVE].pack("Q<Q<Q<")
|
|
157
|
+
descriptor = syscall.call(OPENAT2, root.fileno, path + "\0", how, how.bytesize)
|
|
158
|
+
raise RuntimeUnavailableError, "contained server resource cannot be opened" if descriptor.negative?
|
|
159
|
+
IO.for_fd(descriptor, autoclose: true)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def initialize(kernel:, files: Files.new)
|
|
164
|
+
@kernel, @files = kernel, files
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def observe!(server:, entries:, authority_socket:)
|
|
168
|
+
@kernel.live!(server)
|
|
169
|
+
before = @kernel.capture(server.fetch("pid"))
|
|
170
|
+
unless @kernel.same?(before, server)
|
|
171
|
+
raise RuntimeUnavailableError, "server incarnation differs"
|
|
172
|
+
end
|
|
173
|
+
result = @files.observe(server.fetch("pid"), entries, authority_socket: authority_socket)
|
|
174
|
+
@kernel.live!(server)
|
|
175
|
+
unless @kernel.same?(@kernel.capture(server.fetch("pid")), before)
|
|
176
|
+
raise RuntimeUnavailableError, "server credentials changed during resource observation"
|
|
177
|
+
end
|
|
178
|
+
result
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|