ace-hitl 0.14.0 → 0.15.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 +10 -0
- data/docs/usage.md +35 -0
- data/lib/ace/hitl/cli/commands/serve.rb +37 -2
- data/lib/ace/hitl/lifecycle/client.rb +11 -151
- data/lib/ace/hitl/lifecycle/credential_path_policy.rb +183 -0
- data/lib/ace/hitl/lifecycle/frame_exchange.rb +78 -0
- data/lib/ace/hitl/lifecycle/frame_serving.rb +119 -0
- data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +101 -0
- data/lib/ace/hitl/lifecycle/remote_peer.rb +78 -0
- data/lib/ace/hitl/lifecycle/service.rb +31 -102
- data/lib/ace/hitl/lifecycle/store.rb +1 -0
- data/lib/ace/hitl/lifecycle/tls_access_policy.rb +39 -0
- data/lib/ace/hitl/lifecycle/tls_authority.rb +112 -0
- data/lib/ace/hitl/lifecycle/tls_credentials.rb +210 -0
- data/lib/ace/hitl/lifecycle/tls_material.rb +133 -0
- data/lib/ace/hitl/lifecycle/tls_project_scope.rb +68 -0
- data/lib/ace/hitl/lifecycle/tls_service.rb +241 -0
- data/lib/ace/hitl/lifecycle.rb +11 -0
- data/lib/ace/hitl/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0827bd8ade04c613021128ac335a1d0fea74d5954e718122bd243a780bebb675'
|
|
4
|
+
data.tar.gz: df48a9612ca6a87670142ee8f511a837e206af2294a1a609be8045bfc14c1830
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0f0b827b307e8baf05a25c9503455fde43c8734e121f7d3f5f222a4d0a0aa201653bb346366fd6b8445c700031113068ff133cf24e4566b825e4bf59633f622
|
|
7
|
+
data.tar.gz: 462e2cef1a68a7a4a3975b96b324977223101a53ba384940875197e61c103054d3e52cbb83d4337f98aea0f5c92dfd306d0c372caa61e2bd8ab20517aca73cf3
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-10-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `ace-hitl serve --tls-authority FILE` for the host/Incus mTLS crossing: a TLS 1.3-only loopback lifecycle authority with mutual X.509 identity (closed-form URI SANs, exact SHA-256 fingerprint pins, configured issuing CA, serverAuth/clientAuth EKU), fail-closed credential provisioning-layout enforcement before any socket opens, and remote peer-to-project binding that refuses pending flows, creates, and proposals for any non-bound project. Unix peer-credential serving is unchanged; shared frame/store logic is extracted without behavior change.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Narrow `ace-hitl serve --group` to dedicated lifecycle groups: a missing group, broad or known-shared control groups (gid 0, `lab-control`), the legacy `/run/lab` directory, and directories containing a legacy `labd.sock` entry now refuse startup fail-closed before any endpoint is created.
|
|
19
|
+
|
|
10
20
|
## [0.14.0] - 2026-10-11
|
|
11
21
|
|
|
12
22
|
### Added
|
data/docs/usage.md
CHANGED
|
@@ -472,3 +472,38 @@ Missing local dependency archives fail visibly without network fallback. A faile
|
|
|
472
472
|
consumer retains diagnostic artifacts; inspect `result.json` and `service.log`
|
|
473
473
|
before retrying. Each invocation retains a new run directory and requires a new
|
|
474
474
|
empty GEM_HOME.
|
|
475
|
+
|
|
476
|
+
## The host/Incus mTLS crossing (nh0-A)
|
|
477
|
+
|
|
478
|
+
`ace-hitl serve --tls-authority FILE` runs one lifecycle authority of
|
|
479
|
+
the shared-Hermes topology on a loopback TCP endpoint where the
|
|
480
|
+
project's Incus TCP proxy device connects. The authority config
|
|
481
|
+
(`ace.hitl.tls.authority/v1`, closed fields, root-owned runtime file
|
|
482
|
+
`root:<service-group> 0640`) owns the loopback listen address, the
|
|
483
|
+
project ID, the service account names and the server credential
|
|
484
|
+
reference — never secret bytes:
|
|
485
|
+
|
|
486
|
+
```json
|
|
487
|
+
{"schema": "ace.hitl.tls.authority/v1",
|
|
488
|
+
"project_id": "proj-a", "listen": "127.0.0.1", "port": 8443,
|
|
489
|
+
"service_user": "hitl-proj-a", "service_group": "hitl-proj-a",
|
|
490
|
+
"tls_server_credential": {
|
|
491
|
+
"certificate_file": "/etc/ace-hitl/proj-a/server.pem",
|
|
492
|
+
"private_key_file": "/etc/ace-hitl/proj-a/server-key.pem",
|
|
493
|
+
"ca_file": "/etc/ace-hitl/proj-a/ca.pem",
|
|
494
|
+
"server_uri_san": "urn:ace:hitl:proj-a:lifecycle-server",
|
|
495
|
+
"allowed_client_uri_san": "urn:ace:hitl:proj-a:hermes-client",
|
|
496
|
+
"allowed_client_certificate_sha256": "<64 lowercase hex>"}}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Startup enforces the exact provisioning layout (ancestors `root:root`
|
|
500
|
+
0755, project directory `root:<service-group>` 0750, private key
|
|
501
|
+
`<service-user>:<service-group>` 0400 and never hard-linked,
|
|
502
|
+
certificate/CA `root:root` 0444) BEFORE any socket opens, validates its
|
|
503
|
+
own certificate identity (single URI SAN, serverAuth EKU, key match),
|
|
504
|
+
speaks TLS 1.3 only, and admits exactly the configured client
|
|
505
|
+
SAN/fingerprint pair — bound to the authority's one project, with
|
|
506
|
+
pending flows and creates refused for any other project. Unix
|
|
507
|
+
peer-credential serving (`serve` without `--tls-authority`) remains the
|
|
508
|
+
same-runtime boundary. lab-config (gad.8/gad.b) owns accounts, file
|
|
509
|
+
provisioning, Incus proxy devices and the tailnet listeners.
|
|
@@ -19,15 +19,20 @@ module Ace
|
|
|
19
19
|
desc "Run the authenticated HITL store boundary service (peer-credential access)"
|
|
20
20
|
|
|
21
21
|
option :socket, type: :string, desc: "Boundary socket path (default: ACE_HITL_SOCKET or /run/lab/hitl.sock)"
|
|
22
|
-
option :group, type: :string,
|
|
22
|
+
option :group, type: :string,
|
|
23
|
+
desc: "Dedicated lifecycle group allowed to connect (required; root and lab-control are refused)"
|
|
23
24
|
option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
|
|
24
25
|
option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
|
|
25
26
|
option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
|
|
27
|
+
option :tls_authority, type: :string, desc: "Serve the host/Incus mTLS crossing from this authority config (root:<service-group> 0640; loopback TCP + verified project client identity)"
|
|
26
28
|
|
|
27
29
|
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
28
30
|
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
29
31
|
|
|
30
32
|
def call(**options)
|
|
33
|
+
return serve_tls_authority(options) if options[:tls_authority]
|
|
34
|
+
|
|
35
|
+
group = dedicated_group(options)
|
|
31
36
|
policy = Providers::Lab.grants_policy(grants_path: grants_path(options))
|
|
32
37
|
unless policy.service_uid
|
|
33
38
|
raise_cli_error(
|
|
@@ -42,7 +47,7 @@ module Ace
|
|
|
42
47
|
binding: binding_policy,
|
|
43
48
|
policy: policy,
|
|
44
49
|
socket_path: socket_path(options),
|
|
45
|
-
group:
|
|
50
|
+
group: group,
|
|
46
51
|
logger: options[:verbose] ? ->(message) { warn message } : nil
|
|
47
52
|
)
|
|
48
53
|
service.run
|
|
@@ -52,6 +57,36 @@ module Ace
|
|
|
52
57
|
|
|
53
58
|
private
|
|
54
59
|
|
|
60
|
+
# The host/Incus crossing (spec 8xa.t.nh0): authority config,
|
|
61
|
+
# credential layout and TLS material are verified fail-closed
|
|
62
|
+
# BEFORE the loopback listener opens; the verified project-
|
|
63
|
+
# scoped client certificate is the only admitted peer.
|
|
64
|
+
def serve_tls_authority(options)
|
|
65
|
+
authority = Lifecycle::TlsAuthority.load(options[:tls_authority])
|
|
66
|
+
binding_policy = Providers::Lab.assignment_binding(repo_root: options[:repo_root])
|
|
67
|
+
service = Lifecycle::TlsService.new(
|
|
68
|
+
root: store_root(options),
|
|
69
|
+
binding: binding_policy,
|
|
70
|
+
project: authority.project_id,
|
|
71
|
+
credential: authority.credential,
|
|
72
|
+
ssl_context: authority.ssl_context!,
|
|
73
|
+
listen: authority.listen,
|
|
74
|
+
port: authority.port,
|
|
75
|
+
group: authority.service_group,
|
|
76
|
+
logger: options[:verbose] ? ->(message) { warn message } : nil
|
|
77
|
+
)
|
|
78
|
+
service.run
|
|
79
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
80
|
+
raise_cli_error(e.message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def dedicated_group(options)
|
|
84
|
+
group = options[:group].to_s
|
|
85
|
+
return group unless group.empty?
|
|
86
|
+
|
|
87
|
+
raise_cli_error("--group is required and must name a dedicated lifecycle group")
|
|
88
|
+
end
|
|
89
|
+
|
|
55
90
|
def socket_path(options)
|
|
56
91
|
options[:socket] || ENV[Providers::Lab::SOCKET_PATH_ENV] || Providers::Lab::DEFAULT_SOCKET_PATH
|
|
57
92
|
end
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require_relative "protocol"
|
|
5
|
+
require_relative "frame_exchange"
|
|
6
|
+
require_relative "lifecycle_requests"
|
|
5
7
|
|
|
6
8
|
module Ace
|
|
7
9
|
module Hitl
|
|
8
10
|
module Lifecycle
|
|
9
|
-
# The authenticated client of the scoped store
|
|
10
|
-
# 8wq.t.34i). The CLI lifecycle operations run
|
|
11
|
-
# direct shared-store access is not a
|
|
11
|
+
# The authenticated Unix-socket client of the scoped store
|
|
12
|
+
# boundary (spec 8wq.t.34i). The CLI lifecycle operations run
|
|
13
|
+
# through this client; direct shared-store access is not a
|
|
14
|
+
# client-side option. Cross-runtime clients use the mutual-TLS
|
|
15
|
+
# Hermes client of the host/Incus crossing (spec 8xa.t.nh0)
|
|
16
|
+
# instead; both share the request surface and frame exchange.
|
|
12
17
|
#
|
|
13
18
|
# Both endpoint directions are authenticated:
|
|
14
19
|
# - the client authenticates the SERVICE: the socket must be a
|
|
@@ -21,43 +26,10 @@ module Ace
|
|
|
21
26
|
# classified lifecycle errors arrive from the service and are
|
|
22
27
|
# re-raised under their own classes.
|
|
23
28
|
class Client
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def proposal_create(**params)
|
|
27
|
-
request("proposal-create", params)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def proposal_show(id, project:, history_after: 0)
|
|
31
|
-
request("proposal-show", {"id" => id, "project" => project, "history_after" => history_after})
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
|
|
35
|
-
request("proposal-revise", {"id" => id, "project" => project, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
|
|
36
|
-
end
|
|
29
|
+
include FrameExchange
|
|
30
|
+
include LifecycleRequests
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
request("proposal-ack", {"id" => id, "project" => project, "submitted_at" => submitted_at})
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def proposal_reply(id, project:, **params)
|
|
43
|
-
request("proposal-reply", {"id" => id, "project" => project}.merge(params))
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def proposal_reconcile(id, project:, checkpoint:)
|
|
47
|
-
request("proposal-reconcile", {"id" => id, "project" => project, "checkpoint" => checkpoint})
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def proposal_wake(project:, after: nil)
|
|
51
|
-
request("proposal-wake", {"project" => project, "after" => after}.compact)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def proposal_due(project:, after: nil)
|
|
55
|
-
request("proposal-due", {"project" => project, "after" => after}.compact)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def proposal_history(project:, query: "", after: nil)
|
|
59
|
-
request("proposal-history", {"project" => project, "query" => query, "after" => after}.compact)
|
|
60
|
-
end
|
|
32
|
+
attr_reader :socket_path, :service_uid
|
|
61
33
|
|
|
62
34
|
def initialize(socket_path:, service_uid:, deadline_seconds: Protocol::DEFAULT_DEADLINE_SECONDS)
|
|
63
35
|
@socket_path = socket_path.to_s
|
|
@@ -85,57 +57,6 @@ module Ace
|
|
|
85
57
|
raise TransportError, "HITL boundary request is not serializable (#{e.message})"
|
|
86
58
|
end
|
|
87
59
|
|
|
88
|
-
def create(deadline: nil, **params)
|
|
89
|
-
request("create", params.transform_keys(&:to_s), deadline: deadline)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def read(id, deadline: nil)
|
|
93
|
-
request("read", {"id" => id}, deadline: deadline)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def deliver(id, answer)
|
|
97
|
-
request("deliver", {"id" => id, "answer" => answer.to_s})
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def consume(id, timeout: 0, operation: nil, native_delivery: false, deadline: nil)
|
|
101
|
-
params = {"id" => id, "timeout" => Integer(timeout)}
|
|
102
|
-
params["operation"] = operation if operation
|
|
103
|
-
params["native_delivery"] = true if native_delivery
|
|
104
|
-
request("consume", params, deadline: deadline)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def cancel(id, reason: "", deadline: nil)
|
|
108
|
-
request("cancel", {"id" => id, "reason" => reason.to_s}, deadline: deadline)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def pending(project: nil)
|
|
112
|
-
items = []
|
|
113
|
-
after = nil
|
|
114
|
-
loop do
|
|
115
|
-
page = pending_page(project: project, after: after)
|
|
116
|
-
items.concat(page.fetch("items"))
|
|
117
|
-
cursor = page.fetch("next")
|
|
118
|
-
break unless cursor
|
|
119
|
-
unless cursor.is_a?(String) && (after.nil? || cursor > after)
|
|
120
|
-
raise TransportError, "pending cursor did not advance"
|
|
121
|
-
end
|
|
122
|
-
after = cursor
|
|
123
|
-
end
|
|
124
|
-
items
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def pending_page(project: nil, after: nil)
|
|
128
|
-
request("pending", {"project" => project, "after" => after}.compact)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def states
|
|
132
|
-
request("states")
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def ping
|
|
136
|
-
request("ping")
|
|
137
|
-
end
|
|
138
|
-
|
|
139
60
|
private
|
|
140
61
|
|
|
141
62
|
# The endpoint must be owned by the trusted service identity and
|
|
@@ -175,24 +96,6 @@ module Ace
|
|
|
175
96
|
socket.getpeereid
|
|
176
97
|
end
|
|
177
98
|
|
|
178
|
-
# A consume without a timeout waits indefinitely at the client
|
|
179
|
-
# boundary too; every bounded call gets a deadline slightly
|
|
180
|
-
# beyond its own wait so the server result wins the race.
|
|
181
|
-
def request_deadline(op, params, selected)
|
|
182
|
-
if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
|
|
183
|
-
raise TransportError, "HITL boundary deadline is invalid"
|
|
184
|
-
end
|
|
185
|
-
local = deadline_for(op, params)
|
|
186
|
-
[selected, local].compact.min
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def await!(socket, direction, deadline)
|
|
190
|
-
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
-
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
192
|
-
readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
|
|
193
|
-
raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
99
|
def connect!(socket, deadline)
|
|
197
100
|
if deadline && deadline <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
198
101
|
raise TransportError, "HITL boundary deadline exceeded"
|
|
@@ -203,49 +106,6 @@ module Ace
|
|
|
203
106
|
error = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
|
|
204
107
|
raise SystemCallError.new("HITL boundary connection failed", error) unless error.zero?
|
|
205
108
|
end
|
|
206
|
-
|
|
207
|
-
def write_frame!(socket, frame, deadline)
|
|
208
|
-
offset = 0
|
|
209
|
-
while offset < frame.bytesize
|
|
210
|
-
await!(socket, :write, deadline)
|
|
211
|
-
written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
|
|
212
|
-
next if written == :wait_writable
|
|
213
|
-
raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
|
|
214
|
-
offset += written
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def deadline_for(op, params)
|
|
219
|
-
if op == "consume" && Integer(params["timeout"]) <= 0
|
|
220
|
-
nil
|
|
221
|
-
else
|
|
222
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
|
|
223
|
-
end
|
|
224
|
-
rescue ArgumentError, TypeError
|
|
225
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def read_line(socket, deadline)
|
|
229
|
-
data = +""
|
|
230
|
-
loop do
|
|
231
|
-
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
-
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
233
|
-
ready = IO.select([socket], nil, nil, remaining)
|
|
234
|
-
raise TransportError, "HITL boundary deadline exceeded" unless ready
|
|
235
|
-
|
|
236
|
-
chunk = socket.read_nonblock(4096, exception: false)
|
|
237
|
-
raise TransportError, "HITL boundary closed before responding" if chunk.nil?
|
|
238
|
-
# A spurious not-ready result goes back to select; only the
|
|
239
|
-
# deadline ends the wait (review 8x32r9b4).
|
|
240
|
-
next if chunk == :wait_readable
|
|
241
|
-
|
|
242
|
-
data << chunk
|
|
243
|
-
if data.bytesize > Protocol::MAX_FRAME_BYTES
|
|
244
|
-
raise TransportError, "HITL boundary response is too large"
|
|
245
|
-
end
|
|
246
|
-
return data if data.include?("\n")
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
109
|
end
|
|
250
110
|
end
|
|
251
111
|
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Lifecycle
|
|
8
|
+
# Provisioning-time enforcement of the one exact ownership/mode
|
|
9
|
+
# layout of the host/Incus crossing (spec 8xa.t.nh0). There are no
|
|
10
|
+
# mode alternatives:
|
|
11
|
+
#
|
|
12
|
+
# - every credential-tree ancestor above the project leaf is
|
|
13
|
+
# `root:root` mode `0755` (verified up to the filesystem root);
|
|
14
|
+
# - the project credential directory is `root:<service-group>`
|
|
15
|
+
# mode `0750`;
|
|
16
|
+
# - the referenced private key is `<service-user>:<service-group>`
|
|
17
|
+
# mode `0400` and never hard-linked;
|
|
18
|
+
# - the referenced certificate and CA bundle are `root:root`
|
|
19
|
+
# mode `0444`;
|
|
20
|
+
# - the root-owned runtime file containing endpoint/credential
|
|
21
|
+
# references is `root:<service-group>` mode `0640`;
|
|
22
|
+
# - each path component and file is a regular owned object: no
|
|
23
|
+
# symlink, group/world-writable component, hard-linked key or
|
|
24
|
+
# ownership substitution is accepted.
|
|
25
|
+
#
|
|
26
|
+
# Exact mode + exact owner/group verification is the enforceable
|
|
27
|
+
# ACL proxy: any POSIX ACL that grants beyond the declared mode
|
|
28
|
+
# surfaces in the group class bits and fails the exact match.
|
|
29
|
+
#
|
|
30
|
+
# Any missing, unreadable, redirected, over-broad, incorrectly
|
|
31
|
+
# owned/mode-set or mismatched object REFUSES STARTUP before a
|
|
32
|
+
# socket is opened: every check runs before the TLS service binds.
|
|
33
|
+
class CredentialPathPolicy
|
|
34
|
+
Error = Class.new(Lifecycle::Error)
|
|
35
|
+
|
|
36
|
+
ANCESTOR_MODE = 0o755
|
|
37
|
+
PROJECT_DIR_MODE = 0o750
|
|
38
|
+
KEY_MODE = 0o400
|
|
39
|
+
PUBLIC_MODE = 0o444
|
|
40
|
+
RUNTIME_FILE_MODE = 0o640
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Verify the three referenced credential files of one project
|
|
44
|
+
# credential object (server or client form; both share the
|
|
45
|
+
# layout).
|
|
46
|
+
def verify_credential_tree!(credential:, service_user:, service_group:)
|
|
47
|
+
new(service_user: service_user, service_group: service_group)
|
|
48
|
+
.verify_credential_tree!(credential)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Verify one root-owned runtime configuration file that holds
|
|
52
|
+
# endpoint/credential references.
|
|
53
|
+
def verify_runtime_file!(path, service_group:)
|
|
54
|
+
new(service_user: nil, service_group: service_group).verify_runtime_file!(path)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(service_user:, service_group:)
|
|
59
|
+
# The runtime-file check verifies root ownership only; the
|
|
60
|
+
# service user is required for the key ownership rule.
|
|
61
|
+
@service_uid = service_user.nil? ? nil : resolve_user(service_user)
|
|
62
|
+
@service_gid = resolve_group(service_group)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verify_credential_tree!(credential)
|
|
66
|
+
roles = {"certificate_file" => :public, "private_key_file" => :key, "ca_file" => :public}
|
|
67
|
+
# Pass one: structural verification of EVERY referenced file
|
|
68
|
+
# (existence, no symlink, regular file) before any ownership
|
|
69
|
+
# comparison — structural refusals never depend on privilege.
|
|
70
|
+
paths = roles.map do |field, role|
|
|
71
|
+
path = credential.fetch(field)
|
|
72
|
+
lstat_file!(path, "credential file")
|
|
73
|
+
[path, role]
|
|
74
|
+
end
|
|
75
|
+
# Pass two: the exact ownership/mode rules per role.
|
|
76
|
+
paths.each do |path, role|
|
|
77
|
+
verify_owner_mode!(path, role)
|
|
78
|
+
end
|
|
79
|
+
project_dir = File.dirname(paths.first.first)
|
|
80
|
+
unless paths.all? { |path, _role| File.dirname(path) == project_dir }
|
|
81
|
+
raise Error,
|
|
82
|
+
"credential files must share one project credential directory " \
|
|
83
|
+
"(#{paths.map(&:first).join(", ")})"
|
|
84
|
+
end
|
|
85
|
+
verify_project_directory(project_dir)
|
|
86
|
+
verify_ancestors(project_dir)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def verify_runtime_file!(path)
|
|
91
|
+
stat = lstat_file!(path, "runtime configuration file")
|
|
92
|
+
unless stat.uid.zero? && stat.gid == @service_gid && (stat.mode & 0o7777) == RUNTIME_FILE_MODE
|
|
93
|
+
raise Error,
|
|
94
|
+
"runtime configuration file must be root:<service-group> mode 0640: #{path} " \
|
|
95
|
+
"(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def verify_owner_mode!(path, role)
|
|
104
|
+
stat = File.lstat(path)
|
|
105
|
+
expected_owner = role == :key ? [@service_uid, @service_gid] : [0, 0]
|
|
106
|
+
expected_mode = role == :key ? KEY_MODE : PUBLIC_MODE
|
|
107
|
+
unless [stat.uid, stat.gid] == expected_owner && (stat.mode & 0o7777) == expected_mode
|
|
108
|
+
raise Error,
|
|
109
|
+
"credential file has the wrong owner or mode: #{path} (got uid=#{stat.uid} " \
|
|
110
|
+
"gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
111
|
+
end
|
|
112
|
+
if role == :key && stat.nlink != 1
|
|
113
|
+
raise Error, "credential private key must not be hard-linked: #{path}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def verify_project_directory(path)
|
|
118
|
+
stat = File.lstat(path)
|
|
119
|
+
unless stat.directory? && !stat.symlink?
|
|
120
|
+
raise Error, "project credential directory is not a regular directory: #{path}"
|
|
121
|
+
end
|
|
122
|
+
unless stat.uid.zero? && stat.gid == @service_gid && (stat.mode & 0o7777) == PROJECT_DIR_MODE
|
|
123
|
+
raise Error,
|
|
124
|
+
"project credential directory must be root:<service-group> mode 0750: #{path} " \
|
|
125
|
+
"(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
126
|
+
end
|
|
127
|
+
rescue Errno::ENOENT
|
|
128
|
+
raise Error, "project credential directory does not exist: #{path}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Every ancestor above the project leaf — up to the filesystem
|
|
132
|
+
# root — is a root:root 0755 directory. A group/world-writable,
|
|
133
|
+
# foreign-owned or symlinked component anywhere on the chain
|
|
134
|
+
# would allow credential substitution.
|
|
135
|
+
def verify_ancestors(path)
|
|
136
|
+
parent = File.expand_path("..", path)
|
|
137
|
+
components = parent.split("/").reject(&:empty?)
|
|
138
|
+
verify_ancestor!("/", File.lstat("/"))
|
|
139
|
+
components.each_with_object("") do |component, current|
|
|
140
|
+
current << "/#{component}"
|
|
141
|
+
stat = File.lstat(current)
|
|
142
|
+
verify_ancestor!(current, stat)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def verify_ancestor!(path, stat)
|
|
147
|
+
unless stat.directory? && !stat.symlink? && stat.uid.zero? && stat.gid.zero? &&
|
|
148
|
+
(stat.mode & 0o7777) == ANCESTOR_MODE
|
|
149
|
+
raise Error,
|
|
150
|
+
"credential path ancestor must be a root:root mode 0755 directory: #{path} " \
|
|
151
|
+
"(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
|
|
152
|
+
end
|
|
153
|
+
rescue Errno::ENOENT
|
|
154
|
+
raise Error, "credential path ancestor does not exist: #{path}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def lstat_file!(path, label)
|
|
158
|
+
stat = File.lstat(path)
|
|
159
|
+
raise Error, "#{label} must not be a symlink: #{path}" if stat.symlink?
|
|
160
|
+
raise Error, "#{label} is not a regular file: #{path}" unless stat.file?
|
|
161
|
+
|
|
162
|
+
stat
|
|
163
|
+
rescue Errno::ENOENT
|
|
164
|
+
raise Error, "#{label} does not exist: #{path}"
|
|
165
|
+
rescue SystemCallError
|
|
166
|
+
raise Error, "#{label} is not verifiable: #{path}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def resolve_user(name)
|
|
170
|
+
Etc.getpwnam(name.to_s).uid
|
|
171
|
+
rescue ArgumentError
|
|
172
|
+
raise Error, "the configured service user does not exist: #{name.inspect}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve_group(name)
|
|
176
|
+
Etc.getgrnam(name.to_s).gid
|
|
177
|
+
rescue ArgumentError
|
|
178
|
+
raise Error, "the configured service group does not exist: #{name.inspect}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Lifecycle
|
|
6
|
+
# The deadline-bounded frame exchange of a lifecycle client (spec
|
|
7
|
+
# 8wq.t.34i, shared by the Unix and the TLS 1.3/TCP transports):
|
|
8
|
+
# one newline-delimited request frame out, one bounded response
|
|
9
|
+
# frame back, size and deadline rules identical on both transports.
|
|
10
|
+
# The includer supplies `@deadline_seconds` and implements
|
|
11
|
+
# `#request(op, params, deadline:)`.
|
|
12
|
+
module FrameExchange
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def await!(socket, direction, deadline)
|
|
16
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
18
|
+
readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
|
|
19
|
+
raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write_frame!(socket, frame, deadline)
|
|
23
|
+
offset = 0
|
|
24
|
+
while offset < frame.bytesize
|
|
25
|
+
await!(socket, :write, deadline)
|
|
26
|
+
written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
|
|
27
|
+
next if written == :wait_writable
|
|
28
|
+
raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
|
|
29
|
+
offset += written
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_line(socket, deadline)
|
|
34
|
+
data = +""
|
|
35
|
+
loop do
|
|
36
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
38
|
+
ready = IO.select([socket], nil, nil, remaining)
|
|
39
|
+
raise TransportError, "HITL boundary deadline exceeded" unless ready
|
|
40
|
+
|
|
41
|
+
chunk = socket.read_nonblock(4096, exception: false)
|
|
42
|
+
raise TransportError, "HITL boundary closed before responding" if chunk.nil?
|
|
43
|
+
# A spurious not-ready result goes back to select; only the
|
|
44
|
+
# deadline ends the wait (review 8x32r9b4).
|
|
45
|
+
next if chunk == :wait_readable
|
|
46
|
+
|
|
47
|
+
data << chunk
|
|
48
|
+
if data.bytesize > Protocol::MAX_FRAME_BYTES
|
|
49
|
+
raise TransportError, "HITL boundary response is too large"
|
|
50
|
+
end
|
|
51
|
+
return data if data.include?("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A consume without a timeout waits indefinitely at the client
|
|
56
|
+
# boundary too; every bounded call gets a deadline slightly
|
|
57
|
+
# beyond its own wait so the server result wins the race.
|
|
58
|
+
def request_deadline(op, params, selected)
|
|
59
|
+
if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
|
|
60
|
+
raise TransportError, "HITL boundary deadline is invalid"
|
|
61
|
+
end
|
|
62
|
+
local = deadline_for(op, params)
|
|
63
|
+
[selected, local].compact.min
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def deadline_for(op, params)
|
|
67
|
+
if op == "consume" && Integer(params["timeout"]) <= 0
|
|
68
|
+
nil
|
|
69
|
+
else
|
|
70
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
|
|
71
|
+
end
|
|
72
|
+
rescue ArgumentError, TypeError
|
|
73
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|