ace-lab 0.8.0 → 0.9.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 +12 -0
- data/docs/usage.md +188 -0
- data/lib/ace/lab/cli/commands/assignment.rb +48 -1
- data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
- data/lib/ace/lab/cli/commands/run.rb +65 -0
- data/lib/ace/lab/cli.rb +10 -0
- data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
- data/lib/ace/lab/molecules/command_policy.rb +158 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +13 -0
- data/lib/ace/lab/organisms/command_executor.rb +220 -0
- data/lib/ace/lab/organisms/command_run_client.rb +131 -0
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +12 -41
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +35 -5
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +9 -18
- data/lib/ace/lab/version.rb +1 -1
- metadata +7 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "ace/assign/atoms/evidence_digest"
|
|
7
|
+
require_relative "grant_resolver"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Molecules
|
|
12
|
+
# One deployment-owned policy, independent of repository preferences.
|
|
13
|
+
# The selected grant covers the WHOLE service combination and exact argv.
|
|
14
|
+
class CommandPolicy
|
|
15
|
+
PATH = "/etc/lab/ace-lab/project-commands.yml"
|
|
16
|
+
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
|
|
17
|
+
ENV_KEY = /\A[A-Z][A-Z0-9_]{0,63}\z/
|
|
18
|
+
RESERVED = %w[HOME USER LOGNAME SHELL PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE GEM_HOME GEM_PATH BASH_ENV ENV PYTHONPATH PYTHONHOME PERL5LIB LD_PRELOAD LD_LIBRARY_PATH].freeze
|
|
19
|
+
attr_reader :project, :launcher
|
|
20
|
+
|
|
21
|
+
def self.load(project:)
|
|
22
|
+
bytes = GrantResolver.read_verified(PATH)
|
|
23
|
+
raise InvalidConfigurationError, "project commands are not installed" unless bytes && bytes.bytesize <= 65_536
|
|
24
|
+
new(YAML.safe_load(bytes, aliases: false), project: project)
|
|
25
|
+
rescue Psych::Exception
|
|
26
|
+
raise InvalidConfigurationError, "invalid project command policy"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(document, project:)
|
|
30
|
+
strict!(document, %w[version projects])
|
|
31
|
+
invalid! unless document["version"] == 1 && document["projects"].is_a?(Hash) &&
|
|
32
|
+
document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } && project.is_a?(String) && ID.match?(project)
|
|
33
|
+
@project = project
|
|
34
|
+
@document = JSON.parse(JSON.generate(document))
|
|
35
|
+
@entry = @document.fetch("projects").fetch(project) { invalid! }
|
|
36
|
+
strict!(@entry, %w[launcher default_executor default_services executors services commands grants])
|
|
37
|
+
@launcher = @entry.fetch("launcher")
|
|
38
|
+
strict!(@launcher, %w[socket_path identity_path])
|
|
39
|
+
invalid! unless @launcher.values.all? { |value| absolute?(value) }
|
|
40
|
+
%w[executors services commands].each do |key|
|
|
41
|
+
value = @entry.fetch(key)
|
|
42
|
+
invalid! unless value.is_a?(Hash) && value.size <= 64 && value.keys.all? { |id| id.is_a?(String) && ID.match?(id) }
|
|
43
|
+
end
|
|
44
|
+
@entry.fetch("executors").each_value do |value|
|
|
45
|
+
strict!(value, %w[uid gid groups home cwd path environment])
|
|
46
|
+
invalid! unless %w[uid gid].all? { |key| uid?(value[key]) } &&
|
|
47
|
+
value["groups"].is_a?(Array) && value["groups"].all? { |gid| uid?(gid) } &&
|
|
48
|
+
value["groups"].uniq == value["groups"] &&
|
|
49
|
+
%w[home cwd].all? { |key| absolute?(value[key]) } &&
|
|
50
|
+
value["path"].is_a?(Array) && !value["path"].empty? && value["path"].all? { |item| absolute?(item) }
|
|
51
|
+
environment!(value.fetch("environment"))
|
|
52
|
+
end
|
|
53
|
+
@entry.fetch("services").each_value do |value|
|
|
54
|
+
strict!(value, %w[environment secret_references])
|
|
55
|
+
environment!(value.fetch("environment"))
|
|
56
|
+
references = value.fetch("secret_references")
|
|
57
|
+
invalid! unless references.is_a?(Hash) && references.all? { |key, path| env_key?(key) && absolute?(path) }
|
|
58
|
+
end
|
|
59
|
+
@entry.fetch("commands").each_value do |value|
|
|
60
|
+
strict!(value, %w[argv executable_sha256 source purpose])
|
|
61
|
+
invalid! unless %w[command test].include?(value["purpose"])
|
|
62
|
+
argv!(value.fetch("argv"))
|
|
63
|
+
invalid! unless sha?(value.fetch("executable_sha256"))
|
|
64
|
+
source = value.fetch("source")
|
|
65
|
+
strict!(source, %w[head tree])
|
|
66
|
+
invalid! unless source.values.all? { |sha| sha.nil? || (sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/)) } &&
|
|
67
|
+
source.values.all?(&:nil?) == source.values.any?(&:nil?)
|
|
68
|
+
invalid! if value["purpose"] == "test" && source.values.any?(&:nil?)
|
|
69
|
+
end
|
|
70
|
+
invalid! unless @entry.fetch("executors").key?(@entry["default_executor"])
|
|
71
|
+
services!(@entry.fetch("default_services"))
|
|
72
|
+
invalid! unless @entry["grants"].is_a?(Array) && @entry["grants"].size <= 256
|
|
73
|
+
@entry["grants"].each do |grant|
|
|
74
|
+
strict!(grant, %w[kind uid command executor services])
|
|
75
|
+
invalid! unless %w[operator agent].include?(grant["kind"]) && uid?(grant["uid"]) &&
|
|
76
|
+
@entry.fetch("commands").key?(grant["command"]) && @entry.fetch("executors").key?(grant["executor"])
|
|
77
|
+
services!(grant.fetch("services"))
|
|
78
|
+
end
|
|
79
|
+
@fingerprint = Ace::Assign::Atoms::EvidenceDigest.digest(@entry)
|
|
80
|
+
freeze_value(@document)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reader!(uid)
|
|
84
|
+
deny! unless @entry.fetch("grants").any? { |grant| grant.fetch("uid") == uid }
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def select(selection:, peer:)
|
|
89
|
+
strict!(selection, %w[argv executor services pane])
|
|
90
|
+
argv!(selection.fetch("argv"))
|
|
91
|
+
executor_id = selection["executor"] || @entry.fetch("default_executor")
|
|
92
|
+
executor = @entry.fetch("executors")[executor_id]
|
|
93
|
+
deny! unless executor
|
|
94
|
+
requested = selection["services"].nil? ? @entry.fetch("default_services") : selection["services"]
|
|
95
|
+
services!(requested)
|
|
96
|
+
selected = requested.uniq.sort
|
|
97
|
+
matches = @entry.fetch("commands").select { |_name, value| value.fetch("argv") == selection["argv"] }
|
|
98
|
+
deny! unless matches.size == 1
|
|
99
|
+
command_id, command = matches.first
|
|
100
|
+
kind = selection["pane"].nil? ? "operator" : "agent"
|
|
101
|
+
deny! unless @entry.fetch("grants").any? { |grant|
|
|
102
|
+
grant.values_at("kind", "uid", "command", "executor") == [kind, peer.fetch("uid"), command_id, executor_id] &&
|
|
103
|
+
grant.fetch("services").sort == selected }
|
|
104
|
+
environment = executor.fetch("environment").dup
|
|
105
|
+
references = {}
|
|
106
|
+
selected.each do |id|
|
|
107
|
+
service = @entry.fetch("services").fetch(id)
|
|
108
|
+
additions = service.fetch("environment")
|
|
109
|
+
secrets = service.fetch("secret_references")
|
|
110
|
+
deny! unless (environment.keys & (additions.keys + secrets.keys)).empty? &&
|
|
111
|
+
(references.keys & (additions.keys + secrets.keys)).empty? && (additions.keys & secrets.keys).empty?
|
|
112
|
+
environment.merge!(additions)
|
|
113
|
+
references.merge!(secrets)
|
|
114
|
+
end
|
|
115
|
+
definition = {"policy_sha256" => @fingerprint, "project_id" => @project, "command_id" => command_id,
|
|
116
|
+
"argv" => command.fetch("argv"), "executable_sha256" => command.fetch("executable_sha256"),
|
|
117
|
+
"source" => command.fetch("source"), "purpose" => command.fetch("purpose"), "executor_id" => executor_id,
|
|
118
|
+
"executor" => executor.merge("environment" => environment, "groups" => executor.fetch("groups").sort), "services" => selected,
|
|
119
|
+
"secret_references" => references, "requester" => peer, "requester_kind" => kind,
|
|
120
|
+
"selection" => selection}
|
|
121
|
+
invalid! if JSON.generate(definition).bytesize > 8192
|
|
122
|
+
definition
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def strict!(value, keys)
|
|
128
|
+
invalid! unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
129
|
+
end
|
|
130
|
+
def argv!(value)
|
|
131
|
+
invalid! unless value.is_a?(Array) && value.size.between?(1, 128) &&
|
|
132
|
+
value.all? { |item| item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") } &&
|
|
133
|
+
!value.first.empty? && value.sum(&:bytesize) <= 8192
|
|
134
|
+
end
|
|
135
|
+
def services!(value)
|
|
136
|
+
invalid! unless value.is_a?(Array) && value.size <= 64 && value.all? { |id| @entry.fetch("services").key?(id) }
|
|
137
|
+
end
|
|
138
|
+
def environment!(value)
|
|
139
|
+
invalid! unless value.is_a?(Hash) && value.size <= 64 && value.all? { |key, item|
|
|
140
|
+
env_key?(key) && item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") }
|
|
141
|
+
end
|
|
142
|
+
def env_key?(key) = key.is_a?(String) && ENV_KEY.match?(key) && !RESERVED.include?(key) && !key.start_with?("GIT_CONFIG_", "BUNDLE_", "LD_", "DYLD_")
|
|
143
|
+
def absolute?(value) = value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
144
|
+
def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
|
|
145
|
+
def sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
146
|
+
def invalid! = raise(InvalidConfigurationError, "invalid project command policy")
|
|
147
|
+
def deny! = raise(SecurityError, "project command selection is not authorized")
|
|
148
|
+
def freeze_value(value)
|
|
149
|
+
case value
|
|
150
|
+
when Hash then value.each { |key, child| key.freeze; freeze_value(child) }
|
|
151
|
+
when Array then value.each { |child| freeze_value(child) }
|
|
152
|
+
end
|
|
153
|
+
value.freeze
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -43,6 +43,21 @@ module Ace
|
|
|
43
43
|
"invalid lab configuration: trusted authorization file could not be parsed"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Validate an installed directory through the same original-path
|
|
47
|
+
# traversal as files, including root-owned system symlink aliases.
|
|
48
|
+
def trusted_directory!(path)
|
|
49
|
+
candidate = verified_resolve(path)
|
|
50
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable" unless candidate
|
|
51
|
+
File.open(candidate, File::RDONLY | File::NOFOLLOW) do |handle|
|
|
52
|
+
unless handle.stat.directory? && secure_file_stat?(handle.stat)
|
|
53
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted directory ownership or mode differs"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
true
|
|
57
|
+
rescue SystemCallError
|
|
58
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable"
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
# @param documents [Array<Hash>] {path:, document:, defaults:}
|
|
47
62
|
# from Ace::Lab.cascade_documents
|
|
48
63
|
# @param topology [Hash] normalized topology (project IDs)
|
|
@@ -44,6 +44,19 @@ module Ace
|
|
|
44
44
|
observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: false).first
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def supervisor_ended!(binding:, deadline: default_deadline)
|
|
48
|
+
@config.verify_selection!
|
|
49
|
+
process = binding.fetch("process_identity")
|
|
50
|
+
@config.selected_principal!(process.fetch("uid"))
|
|
51
|
+
observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
|
|
52
|
+
result = Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation).generation_status(process)
|
|
53
|
+
unless result.fetch("liveness") == "ended"
|
|
54
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original supervisor process generation is live or unknown"
|
|
55
|
+
end
|
|
56
|
+
@config.verify_selection!
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
# The caller's claimed head is only a comparison. The accepted start
|
|
48
61
|
# uses the observed worker's checkout and process, never daemon cwd.
|
|
49
62
|
def execution!(peer:, pane:, head:, deadline: default_deadline)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pty"
|
|
5
|
+
require "etc"
|
|
6
|
+
require "fiddle"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "io/console"
|
|
9
|
+
require "ace/runtime/molecules/owned_child_scope"
|
|
10
|
+
require_relative "command_run_client"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Lab
|
|
14
|
+
module Organisms
|
|
15
|
+
# Short-lived execution child of the installed root labd, not a listener
|
|
16
|
+
# or another journal owner. A single Ruby thread forks the gated executor.
|
|
17
|
+
class CommandExecutor
|
|
18
|
+
def initialize(project:, owner: nil)
|
|
19
|
+
@project = project
|
|
20
|
+
@owner = owner || CommandRunClient.new(project: project).owner
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(socket:, request:)
|
|
24
|
+
raise SecurityError, "command executor requires Linux root" unless RUBY_PLATFORM.include?("linux") && Process.uid.zero? && Process.euid.zero?
|
|
25
|
+
strict!(request, %w[execution_id caller tty rows cols])
|
|
26
|
+
id = request.fetch("execution_id")
|
|
27
|
+
raise ArgumentError, "invalid command reference" unless id.is_a?(String) && Molecules::CommandPolicy::ID.match?(id)
|
|
28
|
+
interactive = request.fetch("tty")
|
|
29
|
+
raise ArgumentError, "invalid terminal mode" unless [true, false].include?(interactive)
|
|
30
|
+
rows, cols = geometry(request)
|
|
31
|
+
params = {"execution_id" => id}
|
|
32
|
+
initial = @owner.call("command_status", params).data
|
|
33
|
+
unless initial["phase"] == "admitted" && initial.dig("definition", "requester") == request.fetch("caller")
|
|
34
|
+
raise SecurityError, "original command already claimed or requester differs"
|
|
35
|
+
end
|
|
36
|
+
definition = initial.fetch("definition")
|
|
37
|
+
unless definition.fetch("secret_references").empty?
|
|
38
|
+
raise SecurityError, "secret command execution capability is not installed"
|
|
39
|
+
end
|
|
40
|
+
claimed = @owner.call("command_claim", params.merge("caller" => request.fetch("caller")), mutation_id: "command-claim.#{id}")
|
|
41
|
+
raise SecurityError, "claim replay cannot execute" if claimed.replayed
|
|
42
|
+
launch(socket, id, definition, interactive, rows, cols)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def launch(socket, id, definition, interactive, rows, cols)
|
|
48
|
+
raise SecurityError, "command fork requires a single-thread helper" unless Thread.list.count(&:alive?) == 1
|
|
49
|
+
scope = Ace::Runtime::Molecules::OwnedChildScope.new
|
|
50
|
+
executor = definition.fetch("executor")
|
|
51
|
+
user = Etc.getpwuid(executor.fetch("uid"))
|
|
52
|
+
environment = {"HOME" => executor.fetch("home"), "USER" => user.name, "LOGNAME" => user.name,
|
|
53
|
+
"PATH" => executor.fetch("path").join(":")}.merge(executor.fetch("environment"))
|
|
54
|
+
if interactive
|
|
55
|
+
parent_input, child_output = PTY.open
|
|
56
|
+
parent_output, child_input = parent_input, child_output
|
|
57
|
+
child_output.winsize = [rows, cols]
|
|
58
|
+
else
|
|
59
|
+
child_input, parent_input = IO.pipe
|
|
60
|
+
parent_output, child_output = IO.pipe
|
|
61
|
+
parent_error, child_error = IO.pipe
|
|
62
|
+
end
|
|
63
|
+
gate_read, gate_write = IO.pipe
|
|
64
|
+
ready_read, ready_write = IO.pipe
|
|
65
|
+
child = fork do
|
|
66
|
+
begin
|
|
67
|
+
socket.close
|
|
68
|
+
[parent_input, parent_output, parent_error, gate_write, ready_read].compact.uniq.each(&:close)
|
|
69
|
+
Process.setsid
|
|
70
|
+
child_input.ioctl(0x540E, 0) if interactive # Linux TIOCSCTTY
|
|
71
|
+
$stdin.reopen(child_input)
|
|
72
|
+
$stdout.reopen(child_output)
|
|
73
|
+
$stderr.reopen(child_error || child_output)
|
|
74
|
+
[child_input, child_output, child_error].compact.uniq.each(&:close)
|
|
75
|
+
Process.groups = executor.fetch("groups")
|
|
76
|
+
Process::GID.change_privilege(executor.fetch("gid"))
|
|
77
|
+
Process::UID.change_privilege(executor.fetch("uid"))
|
|
78
|
+
File.umask(0o077)
|
|
79
|
+
prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
|
|
80
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
|
|
81
|
+
raise SecurityError, "cannot inhibit privilege gain" unless prctl.call(38, 1, 0, 0, 0).zero?
|
|
82
|
+
Dir.chdir(executor.fetch("cwd"))
|
|
83
|
+
ready_write.write("R")
|
|
84
|
+
ready_write.close
|
|
85
|
+
exit! 125 unless IO.select([gate_read], nil, nil, 35) && gate_read.read(1) == "E"
|
|
86
|
+
gate_read.close
|
|
87
|
+
argv = definition.fetch("argv")
|
|
88
|
+
exec(environment, [File.expand_path(argv.first), argv.first], *argv.drop(1), unsetenv_others: true)
|
|
89
|
+
rescue Exception
|
|
90
|
+
exit! 126
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
[child_input, child_output, child_error, gate_read, ready_write].compact.uniq.each(&:close)
|
|
94
|
+
unless IO.select([ready_read], nil, nil, 10) && ready_read.read(1) == "R"
|
|
95
|
+
raise SecurityError, "child did not reach admission gate"
|
|
96
|
+
end
|
|
97
|
+
ready_read.close
|
|
98
|
+
witness = child_witness(child)
|
|
99
|
+
bound = @owner.call("command_bind", {"execution_id" => id, "child" => witness}, mutation_id: "command-bind.#{id}")
|
|
100
|
+
raise SecurityError, "binding replay cannot execute" if bound.replayed
|
|
101
|
+
gate_write.write("E")
|
|
102
|
+
gate_write.close
|
|
103
|
+
send_frame(socket, {"type" => "started", "execution_id" => id})
|
|
104
|
+
outputs = {parent_output => interactive ? "terminal" : "stdout"}
|
|
105
|
+
outputs[parent_error] = "stderr" if parent_error
|
|
106
|
+
pump(socket, parent_input, outputs, child, interactive)
|
|
107
|
+
scope.cleanup!(leader: child)
|
|
108
|
+
_, result = Process.waitpid2(child)
|
|
109
|
+
reaped = true
|
|
110
|
+
termination = {"exit_status" => result.exitstatus, "signal" => result.termsig}
|
|
111
|
+
@owner.call("command_complete", {"execution_id" => id, "result" => termination}, mutation_id: "command-complete.#{id}")
|
|
112
|
+
send_frame(socket, termination.merge("type" => "result", "execution_id" => id))
|
|
113
|
+
result.exitstatus || 128 + result.termsig
|
|
114
|
+
ensure
|
|
115
|
+
[gate_write, ready_read, parent_input, parent_output, parent_error].compact.uniq.each { |io| io.close unless io.closed? }
|
|
116
|
+
if child && !reaped
|
|
117
|
+
scope.cleanup!(leader: child)
|
|
118
|
+
deadline = monotonic + 5
|
|
119
|
+
loop do
|
|
120
|
+
break if Process.waitpid(child, Process::WNOHANG)
|
|
121
|
+
break if monotonic >= deadline
|
|
122
|
+
IO.select(nil, nil, nil, 0.01)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pump(socket, input, outputs, child, interactive)
|
|
128
|
+
pending = "".b
|
|
129
|
+
pending_input = "".b
|
|
130
|
+
input_open = true
|
|
131
|
+
input_finished = false
|
|
132
|
+
terminal_at = nil
|
|
133
|
+
loop do
|
|
134
|
+
terminal_at ||= monotonic if zombie?(child)
|
|
135
|
+
break if terminal_at && (outputs.empty? || monotonic - terminal_at >= 1)
|
|
136
|
+
readers = (pending_input.bytesize < 131_072 ? [socket] : []) + outputs.keys
|
|
137
|
+
ready = IO.select(readers, pending_input.empty? || input.closed? ? [] : [input], nil, 0.1)
|
|
138
|
+
next unless ready
|
|
139
|
+
if ready.first.include?(socket)
|
|
140
|
+
bytes = socket.read_nonblock(65_536, exception: false)
|
|
141
|
+
next if bytes == :wait_readable
|
|
142
|
+
raise IOError, "command client disconnected" unless bytes
|
|
143
|
+
pending << bytes
|
|
144
|
+
raise ArgumentError, "command frame exceeds bound" if pending.bytesize > 131_072
|
|
145
|
+
while (newline = pending.index("\n"))
|
|
146
|
+
frame = JSON.parse(pending.slice!(0, newline + 1))
|
|
147
|
+
case frame.fetch("type")
|
|
148
|
+
when "input"
|
|
149
|
+
strict!(frame, %w[type data])
|
|
150
|
+
raise ArgumentError, "command input after EOF" unless input_open
|
|
151
|
+
pending_input << Base64.strict_decode64(frame.fetch("data"))
|
|
152
|
+
raise ArgumentError, "command buffered input exceeds bound" if pending_input.bytesize > 131_072
|
|
153
|
+
when "resize"
|
|
154
|
+
strict!(frame, %w[type rows cols])
|
|
155
|
+
raise ArgumentError, "resize requires a terminal" unless interactive
|
|
156
|
+
input.winsize = geometry(frame)
|
|
157
|
+
when "eof"
|
|
158
|
+
strict!(frame, %w[type])
|
|
159
|
+
raise ArgumentError, "duplicate input EOF" unless input_open
|
|
160
|
+
pending_input << "\x04" if interactive
|
|
161
|
+
input_open = false
|
|
162
|
+
else raise ArgumentError, "invalid command input"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
if ready[1].include?(input)
|
|
167
|
+
written = input.write_nonblock(pending_input, exception: false)
|
|
168
|
+
pending_input.slice!(0, written) if written.is_a?(Integer)
|
|
169
|
+
end
|
|
170
|
+
if !input_open && pending_input.empty? && !input_finished
|
|
171
|
+
input.close unless interactive
|
|
172
|
+
input_finished = true
|
|
173
|
+
end
|
|
174
|
+
outputs.keys.each do |output|
|
|
175
|
+
next unless ready.first.include?(output)
|
|
176
|
+
begin
|
|
177
|
+
bytes = output.read_nonblock(65_536, exception: false)
|
|
178
|
+
rescue Errno::EIO
|
|
179
|
+
bytes = nil
|
|
180
|
+
end
|
|
181
|
+
next if bytes == :wait_readable
|
|
182
|
+
if bytes
|
|
183
|
+
send_frame(socket, {"type" => "output", "stream" => outputs.fetch(output), "data" => Base64.strict_encode64(bytes)})
|
|
184
|
+
else
|
|
185
|
+
outputs.delete(output)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def child_witness(pid)
|
|
192
|
+
observer = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
|
|
193
|
+
Ace::Herdr::Molecules::BoundedObservation.new(deadline: monotonic + 5))
|
|
194
|
+
identity = observer.capture(pid)
|
|
195
|
+
raise SecurityError, "child generation unavailable" unless identity
|
|
196
|
+
status = File.readlines("/proc/#{pid}/status").filter_map { |line| line.include?(":") && line.split(":", 2) }.to_h
|
|
197
|
+
identity.merge("gid" => status.fetch("Gid").split.first.to_i, "groups" => status.fetch("Groups").split.map(&:to_i).sort,
|
|
198
|
+
"cwd" => File.readlink("/proc/#{pid}/cwd"), "process_group" => Process.getpgid(pid),
|
|
199
|
+
"no_new_privs" => Integer(status.fetch("NoNewPrivs").strip, 10),
|
|
200
|
+
"capabilities" => %w[CapInh CapPrm CapEff CapAmb].to_h { |key| [key, status.fetch(key).strip] })
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def zombie?(pid) = File.read("/proc/#{pid}/stat").split(")").last.split.first == "Z"
|
|
204
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
+
def geometry(value)
|
|
206
|
+
rows, cols = value.values_at("rows", "cols")
|
|
207
|
+
raise ArgumentError, "invalid command geometry" unless [rows, cols].all? { |v| v.is_a?(Integer) && v.between?(1, 65_535) }
|
|
208
|
+
[rows, cols]
|
|
209
|
+
end
|
|
210
|
+
def strict!(value, keys)
|
|
211
|
+
raise ArgumentError, "invalid command fields" unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
212
|
+
end
|
|
213
|
+
def send_frame(socket, frame)
|
|
214
|
+
Ace::Runtime::Molecules::ProtectedSocket.write(socket, frame,
|
|
215
|
+
deadline: Ace::Runtime::Molecules::ProtectedSocket.deadline(5), limit: 131_072)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "base64"
|
|
7
|
+
require "io/console"
|
|
8
|
+
require "ace/assign/authority/ordinary_client"
|
|
9
|
+
require_relative "../molecules/ordinary_owner_config"
|
|
10
|
+
require_relative "../molecules/command_policy"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Lab
|
|
14
|
+
module Organisms
|
|
15
|
+
class CommandRunClient
|
|
16
|
+
def initialize(project:)
|
|
17
|
+
@project = project
|
|
18
|
+
@config = Molecules::OrdinaryOwnerConfig.load(project: project)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def owner
|
|
22
|
+
Ace::Assign::Authority::OrdinaryClient.new(project_id: @project, service: @config.service,
|
|
23
|
+
verify_selection: @config.method(:verify_owner!))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def status(reference)
|
|
27
|
+
owner.call("command_status", {"execution_id" => reference}).data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(argv:, executor:, services:, pane: nil)
|
|
31
|
+
id = SecureRandom.hex(16)
|
|
32
|
+
selection = {"argv" => argv, "executor" => executor, "services" => services, "pane" => pane}
|
|
33
|
+
request = {"project" => @project, "execution_id" => id, "selection" => selection,
|
|
34
|
+
"mutation_id" => "command-admit.#{id}"}
|
|
35
|
+
parent = File.expand_path(".ace-local/lab-commands")
|
|
36
|
+
FileUtils.mkdir_p(parent, mode: 0o700)
|
|
37
|
+
directory = File.join(parent, id)
|
|
38
|
+
Dir.mkdir(directory, 0o700)
|
|
39
|
+
File.open(File.join(directory, "request.json"), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
40
|
+
file.write(JSON.generate(request) + "\n")
|
|
41
|
+
file.flush
|
|
42
|
+
file.fsync
|
|
43
|
+
end
|
|
44
|
+
File.open(directory, File::RDONLY) { |parent_io| parent_io.fsync }
|
|
45
|
+
$stderr.puts "Execution #{id}; inspect with ace-lab run-status --project #{@project} --reference #{id}"
|
|
46
|
+
reply = owner.call("command_admit", {"execution_id" => id, "selection" => selection}, mutation_id: request.fetch("mutation_id"))
|
|
47
|
+
raise Ace::Assign::AttemptErrors::Conflict, "original command admission must be reconciled" if reply.replayed
|
|
48
|
+
stream(id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def stream(id)
|
|
54
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
55
|
+
policy = Molecules::CommandPolicy.load(project: @project)
|
|
56
|
+
path = policy.launcher.fetch("socket_path")
|
|
57
|
+
wire.root_path!(File.dirname(path), directory: true)
|
|
58
|
+
endpoint = wire.socket_identity(path, mode: 0o660)
|
|
59
|
+
raise SecurityError, "command launcher endpoint owner differs" unless endpoint.last.zero?
|
|
60
|
+
expected = JSON.parse(Molecules::GrantResolver.read_verified(policy.launcher.fetch("identity_path")) || "null")
|
|
61
|
+
wire.connect(path, deadline: wire.deadline(5)) do |socket|
|
|
62
|
+
peer = Ace::Assign::Authority::OrdinaryPeer.new.capture(socket)
|
|
63
|
+
unless expected.is_a?(Hash) && expected["schema"] == "labd-command-launcher/v1" && expected["identity"] == peer
|
|
64
|
+
raise SecurityError, "command launcher generation differs"
|
|
65
|
+
end
|
|
66
|
+
interactive = $stdin.tty? && $stdout.tty?
|
|
67
|
+
rows, cols = interactive ? $stdin.winsize : [24, 80]
|
|
68
|
+
wire.write(socket, {"op" => "command_run", "project" => @project, "execution_id" => id,
|
|
69
|
+
"tty" => interactive, "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 16_384)
|
|
70
|
+
if interactive
|
|
71
|
+
$stdin.raw { pump(socket, id, interactive: true) }
|
|
72
|
+
else
|
|
73
|
+
pump(socket, id, interactive: false)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pump(socket, id, interactive:)
|
|
79
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
80
|
+
ingress = wire::Ingress.new(socket)
|
|
81
|
+
inputs = [socket]
|
|
82
|
+
resize = false
|
|
83
|
+
old_winch = Signal.trap("WINCH") { resize = true } if interactive
|
|
84
|
+
loop do
|
|
85
|
+
if resize
|
|
86
|
+
resize = false
|
|
87
|
+
rows, cols = $stdin.winsize
|
|
88
|
+
wire.write(socket, {"type" => "resize", "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 131_072)
|
|
89
|
+
end
|
|
90
|
+
ready = IO.select(inputs, nil, nil, 0.25)
|
|
91
|
+
next unless ready
|
|
92
|
+
if ready.first.include?(socket)
|
|
93
|
+
frame = wire.read(ingress, deadline: wire.deadline(5), limit: 131_072)
|
|
94
|
+
case frame["type"]
|
|
95
|
+
when "started"
|
|
96
|
+
raise SecurityError, "invalid command start frame" unless frame.keys.sort == %w[execution_id type]
|
|
97
|
+
raise SecurityError, "command reference differs" unless frame["execution_id"] == id
|
|
98
|
+
inputs << $stdin unless inputs.include?($stdin)
|
|
99
|
+
when "output"
|
|
100
|
+
raise SecurityError, "invalid command output frame" unless frame.keys.sort == %w[data stream type] && %w[stdout stderr terminal].include?(frame["stream"])
|
|
101
|
+
destination = frame["stream"] == "stderr" ? $stderr : $stdout
|
|
102
|
+
destination.write(Base64.strict_decode64(frame.fetch("data")))
|
|
103
|
+
destination.flush
|
|
104
|
+
when "result"
|
|
105
|
+
raise SecurityError, "invalid command result frame" unless frame.keys.sort == %w[execution_id exit_status signal type]
|
|
106
|
+
raise SecurityError, "command reference differs" unless frame["execution_id"] == id
|
|
107
|
+
exited, signal = frame.values_at("exit_status", "signal")
|
|
108
|
+
if exited.is_a?(Integer) && exited.between?(0, 255) && signal.nil?
|
|
109
|
+
return exited
|
|
110
|
+
elsif exited.nil? && signal.is_a?(Integer) && signal.between?(1, 127)
|
|
111
|
+
return 128 + signal
|
|
112
|
+
end
|
|
113
|
+
raise SecurityError, "command termination is invalid"
|
|
114
|
+
else
|
|
115
|
+
raise SecurityError, "command stream is incomplete"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
next unless ready.first.include?($stdin)
|
|
119
|
+
bytes = $stdin.read_nonblock(8192, exception: false)
|
|
120
|
+
next if bytes == :wait_readable
|
|
121
|
+
frame = bytes ? {"type" => "input", "data" => Base64.strict_encode64(bytes)} : {"type" => "eof"}
|
|
122
|
+
wire.write(socket, frame, deadline: wire.deadline(5), limit: 131_072)
|
|
123
|
+
inputs.delete($stdin) unless bytes
|
|
124
|
+
end
|
|
125
|
+
ensure
|
|
126
|
+
Signal.trap("WINCH", old_winch) if old_winch
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|