ace-runtime 0.2.0 → 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 +40 -0
- data/docs/usage.md +55 -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 +375 -21
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +74 -9
- data/lib/ace/runtime/molecules/protected_socket.rb +24 -2
- 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 +103 -15
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +1 -0
- data/native/README.md +61 -6
- data/native/build-worker-gate +4 -2
- data/native/worker_gate.c +206 -20
- metadata +11 -2
|
@@ -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
|
|
@@ -23,25 +23,31 @@ module Ace
|
|
|
23
23
|
PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf RequiresMountsFor WantsMountsFor].to_h { |key| [key, "as"] }
|
|
24
24
|
}.freeze
|
|
25
25
|
SERVICE_EXEC_SIGNATURES = {
|
|
26
|
-
**%w[Type User Group Restart KillMode ProtectSystem RootDirectory RootImage NetworkNamespacePath Slice StandardOutput StandardError WorkingDirectory RuntimeDirectoryPreserve].to_h { |key| [key, "s"] },
|
|
26
|
+
**%w[Type User Group Restart KillMode ProtectSystem RootDirectory RootImage NetworkNamespacePath Slice StandardInput TTYPath StandardOutput StandardError WorkingDirectory RuntimeDirectoryPreserve DevicePolicy PAMName].to_h { |key| [key, "s"] },
|
|
27
27
|
**%w[SupplementaryGroups ReadWritePaths ReadOnlyPaths Environment PassEnvironment UnsetEnvironment Sockets InaccessiblePaths ExtensionDirectories RuntimeDirectory].to_h { |key| [key, "as"] },
|
|
28
|
-
**%w[SendSIGKILL Delegate ProtectControlGroups NoNewPrivileges PrivateIPC PrivateDevices DynamicUser RootEphemeral MountAPIVFS].to_h { |key| [key, "b"] },
|
|
28
|
+
**%w[SendSIGKILL Delegate ProtectControlGroups NoNewPrivileges PrivateIPC PrivateDevices DynamicUser RootEphemeral MountAPIVFS PrivateTmp ProtectKernelTunables BindLogSockets].to_h { |key| [key, "b"] },
|
|
29
29
|
**%w[CapabilityBoundingSet AmbientCapabilities RestrictNamespaces].to_h { |key| [key, "t"] },
|
|
30
30
|
"RuntimeDirectoryMode" => "u", "UMask" => "u", "RootImageOptions" => "a(ss)", "TemporaryFileSystem" => "a(ss)", "MountImages" => "a(ssba(ss))",
|
|
31
31
|
"ExtensionImages" => "a(sba(ss))", "EnvironmentFiles" => "a(sb)", "RestartForceExitStatus" => "(aiai)", "RestrictAddressFamilies" => "(bas)",
|
|
32
|
-
"BindPaths" => "a(ssbt)", "BindReadOnlyPaths" => "a(ssbt)",
|
|
32
|
+
"DeviceAllow" => "a(ss)", "BindPaths" => "a(ssbt)", "BindReadOnlyPaths" => "a(ssbt)",
|
|
33
33
|
**%w[ExecConditionEx ExecStartPreEx ExecStartEx ExecStartPostEx ExecReloadEx ExecStopEx ExecStopPostEx].to_h { |key| [key, "a(sasasttttuii)"] }
|
|
34
34
|
}.freeze
|
|
35
35
|
|
|
36
36
|
UNIT_STATE_SIGNATURES = {"Id" => "s", "LoadState" => "s", "ActiveState" => "s", "SubState" => "s", "Job" => "(uo)"}.freeze
|
|
37
37
|
MOUNT_SIGNATURES = {"Where" => "s"}.freeze
|
|
38
|
-
ACTIVATION_UNIT_SIGNATURES = UNIT_STATE_SIGNATURES.merge("InvocationID" => "
|
|
38
|
+
ACTIVATION_UNIT_SIGNATURES = UNIT_STATE_SIGNATURES.merge("InvocationID" => "ay", "ControlGroup" => "s").freeze
|
|
39
39
|
ACTIVATION_SERVICE_SIGNATURES = {"MainPID" => "u", "ControlPID" => "u", "Slice" => "s"}.freeze
|
|
40
|
+
INBOX_CONTEXT_SIGNATURES = {"TimeoutStopUSec" => "t"}.freeze
|
|
41
|
+
|
|
42
|
+
PIDFD_ARGV = [BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
|
|
43
|
+
"--allow-interactive-authorization=no", "--timeout=5", "call", "org.freedesktop.systemd1",
|
|
44
|
+
"/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "GetUnitByPIDFD", "h", "3"].freeze
|
|
40
45
|
|
|
41
46
|
class Command
|
|
42
47
|
LIMIT = 65_536
|
|
43
48
|
|
|
44
|
-
def call(argv, timeout:)
|
|
49
|
+
def call(argv, timeout:, pidfd: nil)
|
|
50
|
+
inherited = inherited_descriptor_options(argv, pidfd)
|
|
45
51
|
unless RUBY_PLATFORM.include?("linux") && File.directory?("/run/systemd/system")
|
|
46
52
|
raise RuntimeUnavailableError, "execution scope requires the Linux system systemd manager"
|
|
47
53
|
end
|
|
@@ -56,7 +62,7 @@ module Ace
|
|
|
56
62
|
output_reader, output_writer = IO.pipe
|
|
57
63
|
error_reader, error_writer = IO.pipe
|
|
58
64
|
pid = Process.spawn({"PATH" => "/usr/bin:/bin", "LANG" => "C", "LC_ALL" => "C"}, *argv,
|
|
59
|
-
in: File::NULL, out: output_writer, err: error_writer, unsetenv_others: true, close_others: true)
|
|
65
|
+
in: File::NULL, out: output_writer, err: error_writer, unsetenv_others: true, close_others: true, **inherited)
|
|
60
66
|
output_writer.close
|
|
61
67
|
error_writer.close
|
|
62
68
|
deadline = ProtectedSocket.deadline(timeout)
|
|
@@ -112,6 +118,15 @@ module Ace
|
|
|
112
118
|
end
|
|
113
119
|
end
|
|
114
120
|
end
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def inherited_descriptor_options(argv, pidfd)
|
|
124
|
+
return {} unless pidfd || argv == PIDFD_ARGV
|
|
125
|
+
unless argv == PIDFD_ARGV && pidfd.is_a?(IO) && !pidfd.closed? && pidfd.fileno >= 3
|
|
126
|
+
raise ArgumentError, "pidfd transport requires the fixed manager method and held IO"
|
|
127
|
+
end
|
|
128
|
+
{3 => pidfd}
|
|
129
|
+
end
|
|
115
130
|
end
|
|
116
131
|
|
|
117
132
|
def initialize(slice_unit:, service_unit:, command: Command.new)
|
|
@@ -132,6 +147,25 @@ module Ace
|
|
|
132
147
|
end
|
|
133
148
|
end
|
|
134
149
|
|
|
150
|
+
# The root identity owner supplies its already policy-checked pinned
|
|
151
|
+
# lifetime. A wire descriptor number never selects this capability.
|
|
152
|
+
def unit_for_pidfd(handle:, timeout: 5)
|
|
153
|
+
unless handle.is_a?(IO) && !handle.closed?
|
|
154
|
+
raise ArgumentError, "manager identity requires a held process lifetime"
|
|
155
|
+
end
|
|
156
|
+
bytes = @command.call(PIDFD_ARGV, timeout: bounded_timeout(timeout), pidfd: handle)
|
|
157
|
+
value = strict_typed_json(bytes)
|
|
158
|
+
data = value["data"]
|
|
159
|
+
unless value.keys.sort == %w[data type] && value["type"] == "osay" &&
|
|
160
|
+
data.is_a?(Array) && data.size == 3 && data[0] == unit_object(@service_unit) &&
|
|
161
|
+
data[1] == @service_unit && invocation_bytes?(data[2]) && data[2].any?(&:positive?)
|
|
162
|
+
raise RuntimeUnavailableError, "manager lifetime belongs to another unit or invocation"
|
|
163
|
+
end
|
|
164
|
+
{"unit" => @service_unit, "invocation_id" => data[2].pack("C*").unpack1("H*")}.freeze
|
|
165
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
166
|
+
raise RuntimeUnavailableError, "typed manager lifetime response is malformed"
|
|
167
|
+
end
|
|
168
|
+
|
|
135
169
|
def inspect_units
|
|
136
170
|
slice = show(@slice_unit)
|
|
137
171
|
service = show(@service_unit, properties: SERVICE_PROPERTIES)
|
|
@@ -168,7 +202,34 @@ module Ace
|
|
|
168
202
|
show(unit)
|
|
169
203
|
[unit, typed_properties(unit: unit, interface: "Unit", signatures: UNIT_GRAPH_SIGNATURES)]
|
|
170
204
|
end
|
|
171
|
-
{"slice" => slice, "service" => service, "ancestors" => ancestors, "unit_paths" => unit_paths,
|
|
205
|
+
{"slice" => slice, "service" => service, "ancestors" => ancestors, "unit_paths" => unit_paths,
|
|
206
|
+
"manager_environment" => manager_environment, "prerequisites" => inspect_prerequisites(service)}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def inspect_inbox_context_profile
|
|
210
|
+
profile = inspect_profile
|
|
211
|
+
profile.fetch("service").merge!(typed_properties(unit: @service_unit, interface: "Service",
|
|
212
|
+
signatures: INBOX_CONTEXT_SIGNATURES))
|
|
213
|
+
profile
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def manager_environment(timeout: 5)
|
|
217
|
+
bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
|
|
218
|
+
"--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1",
|
|
219
|
+
"/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", "Environment"], timeout: bounded_timeout(timeout))
|
|
220
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536) && bytes.lines.size == 1
|
|
221
|
+
raise RuntimeUnavailableError, "effective manager environment is unavailable"
|
|
222
|
+
end
|
|
223
|
+
value = JSON.parse(bytes, create_additions: false, allow_duplicate_key: false, allow_comments: false, max_nesting: 4)
|
|
224
|
+
unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == "as" &&
|
|
225
|
+
value["data"].is_a?(Array) && value["data"].size <= 256 &&
|
|
226
|
+
value["data"].all? { |entry| entry.is_a?(String) && entry.bytesize.between?(1, 4096) && !entry.include?("\0") && entry.match?(/\A[A-Za-z_][A-Za-z_0-9]*=.*\z/) } &&
|
|
227
|
+
value["data"].map { |entry| entry.split("=", 2).first }.uniq.size == value["data"].size
|
|
228
|
+
raise RuntimeUnavailableError, "effective manager environment differs"
|
|
229
|
+
end
|
|
230
|
+
value.fetch("data")
|
|
231
|
+
rescue JSON::ParserError, ArgumentError
|
|
232
|
+
raise RuntimeUnavailableError, "effective manager environment is malformed"
|
|
172
233
|
end
|
|
173
234
|
|
|
174
235
|
def unit_paths
|
|
@@ -191,25 +252,23 @@ module Ace
|
|
|
191
252
|
|
|
192
253
|
# Typed reads only, against the existing system manager. Property sets
|
|
193
254
|
# and unit identities are owner-selected, never request-controlled.
|
|
194
|
-
def typed_properties(unit:, interface:, signatures:)
|
|
255
|
+
def typed_properties(unit:, interface:, signatures:, timeout: 5)
|
|
195
256
|
unless [@slice_unit, @service_unit, *@slice_ancestors, *@prerequisite_units.to_a].include?(unit) &&
|
|
196
257
|
%w[Unit Service Mount].include?(interface) && (interface == "Unit" || interface == "Service" && unit == @service_unit || interface == "Mount" && @prerequisite_units.to_a.include?(unit) && unit.end_with?(".mount")) && signatures.is_a?(Hash) &&
|
|
197
258
|
signatures.all? { |key, value|
|
|
198
259
|
allowed = case interface
|
|
199
260
|
when "Unit" then UNIT_GRAPH_SIGNATURES.merge(ACTIVATION_UNIT_SIGNATURES)
|
|
200
|
-
when "Service" then SERVICE_EXEC_SIGNATURES.merge(ACTIVATION_SERVICE_SIGNATURES)
|
|
261
|
+
when "Service" then SERVICE_EXEC_SIGNATURES.merge(ACTIVATION_SERVICE_SIGNATURES).merge(INBOX_CONTEXT_SIGNATURES)
|
|
201
262
|
when "Mount" then MOUNT_SIGNATURES
|
|
202
263
|
end
|
|
203
264
|
allowed[key] == value
|
|
204
265
|
} && !signatures.empty?
|
|
205
266
|
raise ArgumentError, "typed inspection requires fixed unit properties"
|
|
206
267
|
end
|
|
207
|
-
object =
|
|
208
|
-
((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57)) ? byte.chr : "_%02x" % byte
|
|
209
|
-
}.join
|
|
268
|
+
object = unit_object(unit)
|
|
210
269
|
bytes = @command.call([BUSCTL, "--system", "--no-pager", "--json=short", "--auto-start=no",
|
|
211
270
|
"--allow-interactive-authorization=no", "get-property", "org.freedesktop.systemd1", object,
|
|
212
|
-
"org.freedesktop.systemd1.#{interface}", *signatures.keys], timeout:
|
|
271
|
+
"org.freedesktop.systemd1.#{interface}", *signatures.keys], timeout: bounded_timeout(timeout))
|
|
213
272
|
unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT)
|
|
214
273
|
raise RuntimeUnavailableError, "typed manager properties are unavailable"
|
|
215
274
|
end
|
|
@@ -218,11 +277,11 @@ module Ace
|
|
|
218
277
|
raise RuntimeUnavailableError, "typed manager property set is incomplete"
|
|
219
278
|
end
|
|
220
279
|
signatures.to_a.zip(lines).to_h do |(key, signature), line|
|
|
221
|
-
value =
|
|
280
|
+
value = strict_typed_json(line)
|
|
222
281
|
unless value.is_a?(Hash) && value.keys.sort == %w[data type] && value["type"] == signature && typed_value?(signature, value["data"])
|
|
223
282
|
raise RuntimeUnavailableError, "typed manager property signature differs"
|
|
224
283
|
end
|
|
225
|
-
[key, value.fetch("data")]
|
|
284
|
+
[key, key == "InvocationID" ? value.fetch("data").pack("C*").unpack1("H*") : value.fetch("data")]
|
|
226
285
|
end
|
|
227
286
|
rescue JSON::ParserError, KeyError
|
|
228
287
|
raise RuntimeUnavailableError, "typed manager properties are malformed"
|
|
@@ -255,12 +314,41 @@ module Ace
|
|
|
255
314
|
end
|
|
256
315
|
end
|
|
257
316
|
|
|
317
|
+
def bounded_timeout(value)
|
|
318
|
+
unless value.is_a?(Numeric) && value.finite? && value.positive? && value <= 5
|
|
319
|
+
raise ArgumentError, "manager observation deadline exceeds its fixed budget"
|
|
320
|
+
end
|
|
321
|
+
value
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def unit_object(unit)
|
|
325
|
+
"/org/freedesktop/systemd1/unit/" + unit.bytes.map { |byte|
|
|
326
|
+
((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57)) ? byte.chr : "_%02x" % byte
|
|
327
|
+
}.join
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def strict_typed_json(bytes)
|
|
331
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, Command::LIMIT) &&
|
|
332
|
+
bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
333
|
+
raise RuntimeUnavailableError, "typed manager response is unavailable"
|
|
334
|
+
end
|
|
335
|
+
value = JSON.parse(bytes, create_additions: false, max_nesting: 32,
|
|
336
|
+
allow_duplicate_key: false, allow_comments: false)
|
|
337
|
+
raise RuntimeUnavailableError, "typed manager response is malformed" unless value.is_a?(Hash)
|
|
338
|
+
value
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def invocation_bytes?(value)
|
|
342
|
+
value.is_a?(Array) && value.size == 16 && value.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) }
|
|
343
|
+
end
|
|
344
|
+
|
|
258
345
|
def typed_value?(signature, value)
|
|
259
346
|
string = ->(item) { item.is_a?(String) && !item.include?("\0") }
|
|
260
347
|
unsigned = ->(item, bits) { item.is_a?(Integer) && item >= 0 && item < (1 << bits) }
|
|
261
348
|
signed = ->(item) { item.is_a?(Integer) && item >= -(1 << 31) && item < (1 << 31) }
|
|
262
349
|
strings = ->(items) { items.is_a?(Array) && items.all? { |item| string.call(item) } }
|
|
263
350
|
case signature
|
|
351
|
+
when "ay" then invocation_bytes?(value)
|
|
264
352
|
when "s" then string.call(value)
|
|
265
353
|
when "(uo)" then value.is_a?(Array) && value.size == 2 && unsigned.call(value.first, 32) && string.call(value.last) && value.last.start_with?("/")
|
|
266
354
|
when "a(ss)"
|
data/lib/ace/runtime/version.rb
CHANGED
data/lib/ace/runtime.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "runtime/registry"
|
|
|
13
13
|
require_relative "runtime/molecules/runtime_selector"
|
|
14
14
|
require_relative "runtime/molecules/process_identity"
|
|
15
15
|
require_relative "runtime/molecules/network_installation_evidence"
|
|
16
|
+
require_relative "runtime/molecules/execution_network_selection"
|
|
16
17
|
|
|
17
18
|
module Ace
|
|
18
19
|
module Runtime
|
data/native/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
The `ace-runtime` gem ships `native/worker_gate.c` and `native/build-worker-gate`.
|
|
4
4
|
This is a Linux executable, not a Ruby interpreter wrapper. Build on the target
|
|
5
|
-
architecture from the installed gem with the system compiler
|
|
5
|
+
architecture from the installed gem with the system compiler, json-c parser and OpenSSL 3 libcrypto.
|
|
6
6
|
Supported initial architectures are Linux x86_64 and aarch64 with little-endian
|
|
7
7
|
ELF64. A bootstrap built on another architecture fails deployment preflight.
|
|
8
8
|
|
|
9
9
|
On Debian bookworm install the distribution's `build-essential`, `pkg-config`
|
|
10
|
-
|
|
11
|
-
`libjson-c5`, libc and ELF loader at runtime. Use the same distribution/architecture
|
|
10
|
+
`libjson-c-dev` and `libssl-dev` build packages. The executable requires the matching system
|
|
11
|
+
`libjson-c5`, OpenSSL 3 `libcrypto.so.3` (`libssl3`), libc and ELF loader at runtime. Use the same distribution/architecture
|
|
12
12
|
for build and deployment; retain package versions, source SHA, compiler version,
|
|
13
13
|
ELF machine, `readelf -d` dependencies and output SHA256 in installer evidence.
|
|
14
14
|
These libraries and their loader search directories must remain root-owned and
|
|
@@ -33,8 +33,9 @@ All installation ancestors are root-owned and non-worker-writable. The fixed
|
|
|
33
33
|
root-owned `/etc/ace/assignment-authorities.json` records `bootstrap` and its exact
|
|
34
34
|
`bootstrap_sha256`. ACE verifies digest, ELF architecture, executable permissions,
|
|
35
35
|
ownership and ancestry before reservation. The native layout uses only this
|
|
36
|
-
absolute executable, installed mapping ID and correlation ticket;
|
|
37
|
-
|
|
36
|
+
absolute executable, installed mapping ID and correlation ticket; cwd and environment come from the root-controlled mapping after authority release.
|
|
37
|
+
The mandatory closed `worker_entry` pins interpreter and wrapper references; arbitrary
|
|
38
|
+
worker argv and shebang/PATH execution are not admitted.
|
|
38
39
|
The map grants no UID switching and the binary is never privileged.
|
|
39
40
|
|
|
40
41
|
The root-installed authority, launcher and native server units require:
|
|
@@ -55,15 +56,69 @@ mandatory; an empty capability set alone does not stop same-UID injection.
|
|
|
55
56
|
|
|
56
57
|
The bootstrap sets non-dumpable before connecting, requires enforced Yama
|
|
57
58
|
`ptrace_scope=2`, requires empty Inh/Prm/Eff/Bnd/Amb capability sets and NoNewPrivs=1, verifies
|
|
58
|
-
worker IDs/groups and
|
|
59
|
+
worker IDs/groups and its retained actual kernel parent birth, authenticates authority
|
|
59
60
|
SO_PEERCRED and protected socket ancestry, and waits at most 30 seconds on one
|
|
60
61
|
stream. It never reconnects or executes after pre-release EOF, malformed input
|
|
61
62
|
or timeout. A committed release whose response is lost remains potentially
|
|
62
63
|
executed; public inspection must retain ownership rather than resend release.
|
|
63
64
|
|
|
65
|
+
The gate accepts only the maintained `ace.assign.authorities/v2` mapping. It
|
|
66
|
+
captures its actual parent PID and kernel boot/start-tick birth before reading
|
|
67
|
+
the map, checks that same birth before and after parent credentials, and repeats
|
|
68
|
+
the check before authority admission and immediately before exec. The canonical
|
|
69
|
+
`gate_ready` authority owner associates the actual gate peer and its parent with
|
|
70
|
+
the original recorded child/server; the map supplies no `server_identity`.
|
|
71
|
+
Changed parent birth, reparenting, inaccessible credentials or changed policy
|
|
72
|
+
refuses. The original retained mapping supplies `worker_entry={interpreter:REF,wrapper:REF}`
|
|
73
|
+
in the authenticated release, rather than selecting code from the current map.
|
|
74
|
+
Interpreter references are bounded to 32MiB and wrapper references to 1MiB.
|
|
75
|
+
Both root-protected regular held files are hashed and their metadata rechecked.
|
|
76
|
+
The interpreter must be an executable native ELF. The verified wrapper is copied
|
|
77
|
+
into a memfd sealed against writes/growth/shrinkage, reopened read-only, and the
|
|
78
|
+
writable descriptor dropped. Selected held interpreter FD4 and code FD5 survive
|
|
79
|
+
`fexecve(4, ["/proc/self/fd/4", "-I", "-S", "-B", "/proc/self/fd/5", "authority", "worker"], env)`.
|
|
80
|
+
Inherited stdin/stdout/stderr remain untouched; no provider lifetime timeout is added.
|
|
81
|
+
The retained original source owner inside the wrapper separately validates and
|
|
82
|
+
executes the accepted Ruby snapshot and its package closure.
|
|
83
|
+
|
|
84
|
+
Release is one compact JSON record containing the original ticket/assignment/
|
|
85
|
+
attempt/generation/commit plus `prepared_input`. That closed object pins the
|
|
86
|
+
original registration generation/commit, definition and original binding digest,
|
|
87
|
+
prepared-work reference, original worker entry pair and exact journal bundle reference/length/SHA256. The
|
|
88
|
+
authority verifies the original bundle before durable issue; subsequent prepared
|
|
89
|
+
fetch authenticates the accepted release pin before returning body bytes. The
|
|
90
|
+
gate validates the closed ten-field pin, including the original workspace exclusion,
|
|
91
|
+
digests and unchanged bounds before exec. The exclusion has five closed fields:
|
|
92
|
+
key, authority UID/GID, readonly root resource and original cwd resource. Each
|
|
93
|
+
resource has the existing eight identity fields. The gate checks integer/path/
|
|
94
|
+
filesystem bounds and exact mapping/view/key association; the retained Ruby owner
|
|
95
|
+
verifies the canonical cwd digest and held filesystem identity before acquiring
|
|
96
|
+
the lifetime reader. This metadata does not itself grant filesystem access.
|
|
97
|
+
The complete encoded release is bounded to16KiB before durable issue and at the gate. It
|
|
98
|
+
does not receive inline work or propagate an input FD to providers. The worker
|
|
99
|
+
uses the existing authenticated original prepared fetch after release.
|
|
100
|
+
|
|
64
101
|
An ordinary Docker kernel with Yama=0 can verify compilation and fail-closed
|
|
65
102
|
policy rejection. It cannot demonstrate protected launch acceptance. A real
|
|
66
103
|
isolated Linux deployment enforcing the complete policy is required for that
|
|
67
104
|
positive proof; installing this gem does not change host sysctls or install OS
|
|
68
105
|
service/account policy. Downstream gad.8 provisions those requirements and
|
|
69
106
|
consumes this shipped build mechanism.
|
|
107
|
+
|
|
108
|
+
## Fixed digest initialization and acceptance limits
|
|
109
|
+
|
|
110
|
+
Before any OpenSSL API call the gate selects `OPENSSL_INIT_NO_LOAD_CONFIG`.
|
|
111
|
+
It creates a private library context, fixes its provider search path to `/dev/null`
|
|
112
|
+
(a non-directory), explicitly loads only `default`, and fetches `SHA256` with
|
|
113
|
+
`provider=default`. Failure refuses execution; no config/engine/module fallback
|
|
114
|
+
is provided. [Initialization semantics](https://docs.openssl.org/3.0/man3/OPENSSL_init_crypto/),
|
|
115
|
+
[provider search path](https://docs.openssl.org/3.0/man3/OSSL_PROVIDER/), and
|
|
116
|
+
[fixed provider property](https://docs.openssl.org/3.0/man7/OSSL_PROVIDER-default/) and
|
|
117
|
+
[libcrypto built-in default provider](https://docs.openssl.org/3.0/man7/crypto/#default-provider)
|
|
118
|
+
are the maintained API basis. The system libcrypto/loader remain in the accepted
|
|
119
|
+
root-controlled OS artifact closure; this does not attest an arbitrary library build.
|
|
120
|
+
|
|
121
|
+
This source checkpoint does not compile or execute native code. Real ELF,
|
|
122
|
+
OpenSSL linkage/provider availability, memfd seals, retained FD execution and
|
|
123
|
+
Python script/stdlib behavior require installed Linux acceptance. The trusted
|
|
124
|
+
provisioned OS/Python premise remains; no separate Python runtime tree is introduced.
|
data/native/build-worker-gate
CHANGED
|
@@ -8,8 +8,10 @@ case "$1" in /*) ;; *) echo 'output must be absolute' >&2; exit 2;; esac
|
|
|
8
8
|
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
9
9
|
build_dir=$(mktemp -d)
|
|
10
10
|
trap 'rm -rf -- "$build_dir"' EXIT HUP INT TERM
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --atleast-version=3.0.0 libcrypto
|
|
12
|
+
env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --atleast-version=0.15 json-c
|
|
13
|
+
cflags=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --cflags json-c libcrypto)
|
|
14
|
+
libs=$(env -i PATH=/usr/bin:/bin /usr/bin/pkg-config --libs json-c libcrypto)
|
|
13
15
|
# pkg-config values come only from the installed system development package.
|
|
14
16
|
# shellcheck disable=SC2086
|
|
15
17
|
env -i PATH=/usr/bin:/bin /usr/bin/cc -std=c11 -O2 -D_FORTIFY_SOURCE=3 \
|