ace-runtime 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -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" => "s", "ControlGroup" => "s").freeze
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, "prerequisites" => inspect_prerequisites(service)}
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 = "/org/freedesktop/systemd1/unit/" + unit.bytes.map { |byte|
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: 5)
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 = JSON.parse(line)
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)"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Runtime
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
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