legion-crypt 1.5.12 → 1.5.37
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/.rubocop.yml +3 -0
- data/CHANGELOG.md +146 -1
- data/README.md +68 -14
- data/legion-crypt.gemspec +2 -1
- data/lib/legion/crypt/cert_rotation.rb +83 -65
- data/lib/legion/crypt/cipher.rb +5 -15
- data/lib/legion/crypt/cluster_secret.rb +71 -19
- data/lib/legion/crypt/erasure.rb +3 -10
- data/lib/legion/crypt/jwks_client.rb +43 -31
- data/lib/legion/crypt/jwt.rb +8 -7
- data/lib/legion/crypt/kerberos_auth.rb +18 -8
- data/lib/legion/crypt/ldap_auth.rb +14 -13
- data/lib/legion/crypt/lease_manager.rb +485 -335
- data/lib/legion/crypt/mtls.rb +3 -6
- data/lib/legion/crypt/partition_keys.rb +2 -0
- data/lib/legion/crypt/settings.rb +2 -1
- data/lib/legion/crypt/spiffe/workload_api_client.rb +83 -71
- data/lib/legion/crypt/token_renewer.rb +212 -82
- data/lib/legion/crypt/vault.rb +113 -83
- data/lib/legion/crypt/vault_client.rb +355 -0
- data/lib/legion/crypt/vault_cluster.rb +161 -39
- data/lib/legion/crypt/vault_entity.rb +21 -34
- data/lib/legion/crypt/vault_jwt_auth.rb +9 -20
- data/lib/legion/crypt/vault_kerberos_auth.rb +11 -14
- data/lib/legion/crypt/vault_operation.rb +27 -0
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +116 -63
- metadata +23 -9
- data/lib/legion/logging/helper.rb +0 -101
- data/lib/legion/logging.rb +0 -58
data/lib/legion/crypt/mtls.rb
CHANGED
|
@@ -33,12 +33,9 @@ module Legion
|
|
|
33
33
|
resolved_ttl = ttl || cert_ttl_setting || DEFAULT_TTL
|
|
34
34
|
log.info "[mTLS] certificate issue requested common_name=#{common_name} ttl=#{resolved_ttl}"
|
|
35
35
|
|
|
36
|
-
response = ::
|
|
37
|
-
pki_path,
|
|
38
|
-
common_name: common_name,
|
|
39
|
-
ttl: resolved_ttl,
|
|
40
|
-
ip_sans: local_ip,
|
|
41
|
-
alt_names: ''
|
|
36
|
+
response = Legion::Crypt.vault_client.logical.write(
|
|
37
|
+
path: pki_path,
|
|
38
|
+
data: { common_name: common_name, ttl: resolved_ttl, ip_sans: local_ip, alt_names: '' }
|
|
42
39
|
)
|
|
43
40
|
|
|
44
41
|
raise "Vault PKI returned nil for #{pki_path} (common_name=#{common_name})" if response.nil?
|
|
@@ -41,6 +41,8 @@ module Legion
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def decrypt_for_tenant(ciphertext:, init_vector:, auth_tag:, tenant_id:, master_key:)
|
|
44
|
+
raise ArgumentError, 'invalid GCM authentication tag length' unless auth_tag.is_a?(String) && auth_tag.bytesize == 16
|
|
45
|
+
|
|
44
46
|
key = derive_key(master_key: master_key, tenant_id: tenant_id)
|
|
45
47
|
decipher = OpenSSL::Cipher.new('aes-256-gcm')
|
|
46
48
|
decipher.decrypt
|
|
@@ -55,7 +55,7 @@ module Legion
|
|
|
55
55
|
|
|
56
56
|
def self.vault
|
|
57
57
|
{
|
|
58
|
-
enabled:
|
|
58
|
+
enabled: true,
|
|
59
59
|
protocol: 'http',
|
|
60
60
|
address: 'localhost',
|
|
61
61
|
port: 8200,
|
|
@@ -63,6 +63,7 @@ module Legion
|
|
|
63
63
|
connected: false,
|
|
64
64
|
renewer_time: 5,
|
|
65
65
|
renewer: true,
|
|
66
|
+
operation_timeout: 5,
|
|
66
67
|
push_cluster_secret: false,
|
|
67
68
|
read_cluster_secret: false,
|
|
68
69
|
kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'legion/logging/helper'
|
|
4
4
|
require 'socket'
|
|
5
5
|
require 'openssl'
|
|
6
|
+
require 'timeout'
|
|
6
7
|
|
|
7
8
|
module Legion
|
|
8
9
|
module Crypt
|
|
@@ -14,9 +15,8 @@ module Legion
|
|
|
14
15
|
# a full gRPC stack by implementing just enough of the HTTP/2 framing to
|
|
15
16
|
# send a single unary RPC call and parse a single response.
|
|
16
17
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# that callers never have to special-case the nil case.
|
|
18
|
+
# X.509 fallback is available only when explicitly enabled; otherwise
|
|
19
|
+
# unavailable or invalid Workload API responses fail closed.
|
|
20
20
|
class WorkloadApiClient
|
|
21
21
|
include Legion::Logging::Helper
|
|
22
22
|
|
|
@@ -40,7 +40,7 @@ module Legion
|
|
|
40
40
|
|
|
41
41
|
# Fetch an X.509 SVID from the SPIRE Workload API.
|
|
42
42
|
# Returns a populated X509Svid struct.
|
|
43
|
-
#
|
|
43
|
+
# Uses a self-signed fallback only when explicitly enabled.
|
|
44
44
|
def fetch_x509_svid
|
|
45
45
|
log.info("[SPIFFE] Fetching X.509 SVID from Workload API socket=#{@socket_path}")
|
|
46
46
|
raw = call_workload_api(FETCH_X509_METHOD, '')
|
|
@@ -82,11 +82,13 @@ module Legion
|
|
|
82
82
|
|
|
83
83
|
# Returns true when the SPIRE agent socket exists and is reachable.
|
|
84
84
|
def available?
|
|
85
|
-
|
|
85
|
+
with_socket_deadline(deadline: ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + CONNECT_TIMEOUT) do
|
|
86
|
+
return false unless ::File.exist?(@socket_path)
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
sock = UNIXSocket.new(@socket_path)
|
|
89
|
+
sock.close
|
|
90
|
+
true
|
|
91
|
+
end
|
|
90
92
|
rescue StandardError => e
|
|
91
93
|
handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.available',
|
|
92
94
|
socket_path: @socket_path)
|
|
@@ -96,16 +98,28 @@ module Legion
|
|
|
96
98
|
private
|
|
97
99
|
|
|
98
100
|
# Minimal HTTP/2 + gRPC unary call over a Unix domain socket.
|
|
99
|
-
#
|
|
101
|
+
# One bounded request; response DATA frames may split a gRPC message.
|
|
100
102
|
def call_workload_api(method_path, request_body)
|
|
101
103
|
log.debug("[SPIFFE] Calling Workload API method=#{method_path}")
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + READ_TIMEOUT
|
|
105
|
+
sock = nil
|
|
106
|
+
with_socket_deadline(deadline: deadline) do
|
|
107
|
+
connect_deadline = [deadline, ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + CONNECT_TIMEOUT].min
|
|
108
|
+
with_socket_deadline(deadline: connect_deadline) do
|
|
109
|
+
raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'" unless ::File.exist?(@socket_path)
|
|
110
|
+
|
|
111
|
+
sock = UNIXSocket.new(@socket_path)
|
|
112
|
+
sock.write(HTTP2_PREFACE)
|
|
113
|
+
sock.write(HTTP2_SETTINGS_FRAME)
|
|
114
|
+
sock.flush
|
|
115
|
+
send_grpc_request(sock, method_path, request_body)
|
|
116
|
+
end
|
|
117
|
+
read_grpc_response(sock, deadline: deadline)
|
|
108
118
|
end
|
|
119
|
+
rescue Errno::EACCES => e
|
|
120
|
+
raise WorkloadApiError, "Cannot connect to SPIRE agent socket: #{e.message}"
|
|
121
|
+
ensure
|
|
122
|
+
close_workload_api_socket(sock, method_path) if sock
|
|
109
123
|
end
|
|
110
124
|
|
|
111
125
|
def close_workload_api_socket(sock, method_path)
|
|
@@ -116,23 +130,13 @@ module Legion
|
|
|
116
130
|
nil
|
|
117
131
|
end
|
|
118
132
|
|
|
119
|
-
def
|
|
120
|
-
|
|
133
|
+
def with_socket_deadline(deadline:, **, &)
|
|
134
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
135
|
+
raise WorkloadApiError, 'Workload API operation timed out' unless remaining.positive?
|
|
121
136
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
sock.write(HTTP2_SETTINGS_FRAME)
|
|
126
|
-
sock.flush
|
|
127
|
-
sock
|
|
128
|
-
rescue Errno::ENOENT => e
|
|
129
|
-
handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.connect_socket',
|
|
130
|
-
socket_path: @socket_path)
|
|
131
|
-
raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'"
|
|
132
|
-
rescue Errno::ECONNREFUSED, Errno::EACCES => e
|
|
133
|
-
handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.connect_socket',
|
|
134
|
-
socket_path: @socket_path)
|
|
135
|
-
raise WorkloadApiError, "Cannot connect to SPIRE agent socket: #{e.message}"
|
|
137
|
+
Timeout.timeout(remaining, &)
|
|
138
|
+
rescue Timeout::Error
|
|
139
|
+
raise WorkloadApiError, 'Workload API operation timed out'
|
|
136
140
|
end
|
|
137
141
|
|
|
138
142
|
# Build and send a minimal gRPC/HTTP2 HEADERS + DATA frame.
|
|
@@ -195,61 +199,58 @@ module Legion
|
|
|
195
199
|
payload.b
|
|
196
200
|
end
|
|
197
201
|
|
|
198
|
-
def read_grpc_response(sock)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
buffer = ''.b
|
|
202
|
-
|
|
202
|
+
def read_grpc_response(sock, deadline:)
|
|
203
|
+
buffer = ''.b
|
|
204
|
+
message = ''.b
|
|
203
205
|
loop do
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
next unless ready
|
|
206
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
207
|
+
raise WorkloadApiError, 'Workload API read timeout' unless remaining.positive?
|
|
208
|
+
next unless sock.wait_readable(remaining)
|
|
208
209
|
|
|
209
210
|
chunk = sock.read_nonblock(4096, exception: false)
|
|
210
|
-
|
|
211
|
+
next if chunk == :wait_readable
|
|
212
|
+
break if chunk.nil?
|
|
211
213
|
|
|
212
|
-
buffer
|
|
213
|
-
result
|
|
214
|
-
return result
|
|
214
|
+
buffer << chunk
|
|
215
|
+
result = extract_grpc_body(buffer, message: message)
|
|
216
|
+
return result unless result.nil?
|
|
215
217
|
end
|
|
216
218
|
|
|
217
219
|
raise WorkloadApiError, 'No valid gRPC response received from Workload API'
|
|
218
220
|
end
|
|
219
221
|
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
def extract_grpc_body(buffer)
|
|
222
|
+
# Consume complete HTTP/2 frames and accumulate stream 1 DATA bytes.
|
|
223
|
+
# The gRPC prefix and body can each span multiple frames and reads.
|
|
224
|
+
def extract_grpc_body(buffer, message:)
|
|
223
225
|
pos = 0
|
|
224
226
|
while pos + 9 <= buffer.bytesize
|
|
225
227
|
frame_length = (buffer.getbyte(pos) << 16) | (buffer.getbyte(pos + 1) << 8) | buffer.getbyte(pos + 2)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
228
|
+
frame_end = pos + 9 + frame_length
|
|
229
|
+
break if frame_end > buffer.bytesize
|
|
230
|
+
|
|
231
|
+
frame_type = buffer.getbyte(pos + 3)
|
|
232
|
+
flags = buffer.getbyte(pos + 4)
|
|
233
|
+
stream_id = buffer.unpack1('N', offset: pos + 5) & 0x7FFFFFFF
|
|
234
|
+
pos += 9
|
|
235
|
+
if frame_type.zero? && stream_id == 1
|
|
236
|
+
if flags.anybits?(0x08)
|
|
237
|
+
padding = buffer.getbyte(pos)
|
|
238
|
+
raise WorkloadApiError, 'Invalid padded HTTP/2 DATA frame' if frame_length.zero? || padding >= frame_length
|
|
239
|
+
|
|
240
|
+
pos += 1
|
|
241
|
+
frame_length -= padding + 1
|
|
242
|
+
end
|
|
243
|
+
message << buffer.byteslice(pos, frame_length)
|
|
232
244
|
end
|
|
245
|
+
pos = frame_end
|
|
246
|
+
next if message.bytesize < 5
|
|
233
247
|
|
|
234
|
-
|
|
235
|
-
pos += frame_length
|
|
236
|
-
|
|
237
|
-
next unless frame_type.zero? && payload && payload.bytesize >= 5
|
|
238
|
-
|
|
239
|
-
# gRPC message: 1-byte compressed flag + 4-byte length + body
|
|
240
|
-
compressed = payload.getbyte(0)
|
|
241
|
-
msg_length = (payload.getbyte(1) << 24) | (payload.getbyte(2) << 16) |
|
|
242
|
-
(payload.getbyte(3) << 8) | payload.getbyte(4)
|
|
243
|
-
next if msg_length.zero?
|
|
244
|
-
|
|
245
|
-
msg_body = payload.byteslice(5, msg_length)
|
|
246
|
-
next if msg_body.nil? || msg_body.bytesize < msg_length
|
|
247
|
-
|
|
248
|
-
# Compressed gRPC responses are not expected from SPIRE; skip them.
|
|
249
|
-
next unless compressed.zero?
|
|
248
|
+
raise WorkloadApiError, 'Compressed gRPC responses are unsupported' unless message.getbyte(0).zero?
|
|
250
249
|
|
|
251
|
-
|
|
250
|
+
length = message.unpack1('N', offset: 1)
|
|
251
|
+
return message.byteslice(5, length) if message.bytesize >= length + 5
|
|
252
252
|
end
|
|
253
|
+
buffer.slice!(0, pos) if pos.positive?
|
|
253
254
|
nil
|
|
254
255
|
end
|
|
255
256
|
|
|
@@ -258,7 +259,18 @@ module Legion
|
|
|
258
259
|
def encode_jwt_request(audience)
|
|
259
260
|
audience_bytes = audience.b
|
|
260
261
|
# Field 1, wire type 2 (length-delimited) = tag 0x0A
|
|
261
|
-
"\
|
|
262
|
+
"\x0A".b + encode_varint(value: audience_bytes.bytesize) + audience_bytes
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def encode_varint(value:, **)
|
|
266
|
+
bytes = ''.b
|
|
267
|
+
loop do
|
|
268
|
+
byte = value & 0x7F
|
|
269
|
+
value >>= 7
|
|
270
|
+
bytes << (value.zero? ? byte : byte | 0x80)
|
|
271
|
+
break if value.zero?
|
|
272
|
+
end
|
|
273
|
+
bytes
|
|
262
274
|
end
|
|
263
275
|
|
|
264
276
|
# Parse the raw protobuf bytes from FetchX509SVIDResponse into an X509Svid.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'legion/logging/helper'
|
|
4
4
|
require 'legion/crypt/kerberos_auth'
|
|
5
|
+
require 'legion/crypt/vault_operation'
|
|
6
|
+
require 'legion/crypt/vault_client'
|
|
5
7
|
|
|
6
8
|
module Legion
|
|
7
9
|
module Crypt
|
|
@@ -12,6 +14,7 @@ module Legion
|
|
|
12
14
|
MAX_BACKOFF = 600
|
|
13
15
|
MIN_SLEEP = 30
|
|
14
16
|
RENEWAL_RATIO = 0.75
|
|
17
|
+
MINIMUM_WAIT = 0.01
|
|
15
18
|
|
|
16
19
|
attr_reader :cluster_name
|
|
17
20
|
|
|
@@ -22,59 +25,131 @@ module Legion
|
|
|
22
25
|
@thread = nil
|
|
23
26
|
@stop = false
|
|
24
27
|
@backoff = INITIAL_BACKOFF
|
|
28
|
+
@state_mutex = Mutex.new
|
|
29
|
+
@wake = ConditionVariable.new
|
|
30
|
+
@generation = 0
|
|
31
|
+
@stopping = false
|
|
32
|
+
ttl = config[:lease_duration].to_f
|
|
33
|
+
@expires_at = config[:expires_at] || (Time.now + ttl if ttl.positive?)
|
|
34
|
+
@renewal_terminal = false
|
|
25
35
|
end
|
|
26
36
|
|
|
27
37
|
def start
|
|
28
|
-
|
|
38
|
+
@state_mutex.synchronize do
|
|
39
|
+
return if @stopping || @thread&.alive? || crypt_stopping?
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
@stop = false
|
|
42
|
+
@generation += 1
|
|
43
|
+
generation = @generation
|
|
44
|
+
@thread = Thread.new { renewal_loop(generation: generation) }
|
|
45
|
+
@thread.name = "vault-renewer-#{@cluster_name}"
|
|
46
|
+
end
|
|
33
47
|
log.info("TokenRenewer[#{@cluster_name}]: token renewal thread started")
|
|
34
48
|
end
|
|
35
49
|
|
|
36
|
-
def
|
|
37
|
-
@
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
def request_stop
|
|
51
|
+
@state_mutex.synchronize do
|
|
52
|
+
@stop = true
|
|
53
|
+
@generation += 1
|
|
54
|
+
@wake.broadcast
|
|
55
|
+
end
|
|
41
56
|
nil
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stop(deadline: nil)
|
|
60
|
+
deadline ||= ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + VaultOperation::SHUTDOWN_TIMEOUT
|
|
61
|
+
@state_mutex.synchronize do
|
|
62
|
+
return if @stopping
|
|
63
|
+
|
|
64
|
+
@stopping = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
request_stop
|
|
69
|
+
thread = @state_mutex.synchronize { @thread }
|
|
70
|
+
stop_thread_and_revoke(thread: thread, deadline: deadline)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
handle_exception(e, level: :warn, operation: 'crypt.token_renewer.stop', cluster_name: @cluster_name)
|
|
73
|
+
ensure
|
|
74
|
+
@state_mutex.synchronize { @stopping = false }
|
|
75
|
+
end
|
|
44
76
|
end
|
|
45
77
|
|
|
46
78
|
def running?
|
|
47
|
-
@thread&.alive? == true
|
|
79
|
+
@state_mutex.synchronize { @thread&.alive? == true }
|
|
48
80
|
end
|
|
49
81
|
|
|
50
|
-
def renew_token
|
|
51
|
-
|
|
52
|
-
@
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
82
|
+
def renew_token(generation: nil)
|
|
83
|
+
generation ||= @state_mutex.synchronize { @generation }
|
|
84
|
+
return false unless @state_mutex.synchronize { active_generation?(generation: generation) }
|
|
85
|
+
|
|
86
|
+
deadline = @state_mutex.synchronize do
|
|
87
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + (@expires_at - Time.now) if @expires_at
|
|
88
|
+
end
|
|
89
|
+
VaultOperation.with_timeout(deadline: deadline) do
|
|
90
|
+
result = @vault_client.auth_token.renew_self
|
|
91
|
+
@state_mutex.synchronize do
|
|
92
|
+
return false unless active_generation?(generation: generation)
|
|
93
|
+
|
|
94
|
+
@config[:lease_duration] = result.auth.lease_duration
|
|
95
|
+
@config[:renewable] = result.auth.renewable? if result.auth.respond_to?(:renewable?)
|
|
96
|
+
ttl = result.auth.lease_duration.to_f
|
|
97
|
+
@expires_at = ttl.positive? ? Time.now + ttl : nil
|
|
98
|
+
@config[:expires_at] = @expires_at
|
|
99
|
+
@config[:connected] = true
|
|
100
|
+
@config[:relogin_required] = false
|
|
101
|
+
end
|
|
102
|
+
log.info("TokenRenewer[#{@cluster_name}]: token renewed, ttl=#{result.auth.lease_duration}s")
|
|
103
|
+
true
|
|
104
|
+
end
|
|
56
105
|
rescue StandardError => e
|
|
106
|
+
@state_mutex.synchronize do
|
|
107
|
+
if active_generation?(generation: generation) && @config[:auth_method].to_s != 'kerberos'
|
|
108
|
+
@renewal_terminal = e.is_a?(Legion::Crypt::Vault::HTTPClientError) && ![408, 429].include?(e.code)
|
|
109
|
+
if @renewal_terminal || (@expires_at && @expires_at <= Time.now)
|
|
110
|
+
@config[:connected] = false
|
|
111
|
+
@config[:relogin_required] = true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
57
115
|
handle_exception(e, level: :warn, operation: 'crypt.token_renewer.renew_token', cluster_name: @cluster_name)
|
|
58
116
|
log.warn("TokenRenewer[#{@cluster_name}]: token renewal failed: #{e.message}")
|
|
59
117
|
false
|
|
60
118
|
end
|
|
61
119
|
|
|
62
|
-
def reauth_kerberos
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
auth_path: krb_config[:auth_path] || KerberosAuth::DEFAULT_AUTH_PATH
|
|
68
|
-
)
|
|
120
|
+
def reauth_kerberos(generation: nil)
|
|
121
|
+
generation ||= @state_mutex.synchronize { @generation }
|
|
122
|
+
krb_config = @state_mutex.synchronize do
|
|
123
|
+
return false unless active_generation?(generation: generation)
|
|
124
|
+
return false unless @config[:auth_method].to_s == 'kerberos'
|
|
69
125
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
126
|
+
(@config[:kerberos] || {}).dup
|
|
127
|
+
end
|
|
128
|
+
active = lambda do |&mutation|
|
|
129
|
+
@state_mutex.synchronize do
|
|
130
|
+
next false unless active_generation?(generation: generation)
|
|
131
|
+
|
|
132
|
+
mutation&.call
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
VaultOperation.with_timeout do
|
|
137
|
+
result = Legion::Crypt::KerberosAuth.login(
|
|
138
|
+
vault_client: @vault_client,
|
|
139
|
+
service_principal: krb_config[:service_principal],
|
|
140
|
+
auth_path: krb_config[:auth_path] || KerberosAuth::DEFAULT_AUTH_PATH,
|
|
141
|
+
principal_guard: active
|
|
142
|
+
)
|
|
76
143
|
|
|
77
|
-
|
|
144
|
+
@state_mutex.synchronize do
|
|
145
|
+
return false unless active_generation?(generation: generation)
|
|
146
|
+
|
|
147
|
+
Legion::Crypt.publish_vault_grant(client: @vault_client, config: @config, grant: result, auth_method: 'kerberos')
|
|
148
|
+
@expires_at = @config[:expires_at]
|
|
149
|
+
end
|
|
150
|
+
log.info("TokenRenewer[#{@cluster_name}]: re-authenticated via Kerberos, ttl=#{result[:lease_duration]}s")
|
|
151
|
+
end
|
|
152
|
+
reissue_all_leases(active: active)
|
|
78
153
|
true
|
|
79
154
|
rescue StandardError => e
|
|
80
155
|
handle_exception(e, level: :warn, operation: 'crypt.token_renewer.reauth_kerberos', cluster_name: @cluster_name)
|
|
@@ -83,101 +158,156 @@ module Legion
|
|
|
83
158
|
end
|
|
84
159
|
|
|
85
160
|
def sleep_duration
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return [duration, lease_duration - 1].min if lease_duration.positive? && lease_duration < MIN_SLEEP
|
|
161
|
+
@state_mutex.synchronize do
|
|
162
|
+
return MIN_SLEEP unless @expires_at
|
|
89
163
|
|
|
90
|
-
|
|
164
|
+
remaining = @expires_at - Time.now
|
|
165
|
+
renewable = @config[:renewable] || @config[:auth_method].to_s == 'kerberos'
|
|
166
|
+
delay = renewable ? remaining - (@config[:lease_duration].to_f * (1 - RENEWAL_RATIO)) : remaining
|
|
167
|
+
[delay, MINIMUM_WAIT].max
|
|
168
|
+
end
|
|
91
169
|
end
|
|
92
170
|
|
|
93
|
-
def next_backoff
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
171
|
+
def next_backoff(generation: nil)
|
|
172
|
+
@state_mutex.synchronize do
|
|
173
|
+
return unless active_generation?(generation: generation || @generation)
|
|
174
|
+
|
|
175
|
+
current = @backoff
|
|
176
|
+
@backoff = [@backoff * 2, MAX_BACKOFF].min
|
|
177
|
+
current
|
|
178
|
+
end
|
|
97
179
|
end
|
|
98
180
|
|
|
99
|
-
def reset_backoff
|
|
100
|
-
@
|
|
181
|
+
def reset_backoff(generation: nil)
|
|
182
|
+
@state_mutex.synchronize do
|
|
183
|
+
return unless active_generation?(generation: generation || @generation)
|
|
184
|
+
|
|
185
|
+
@backoff = INITIAL_BACKOFF
|
|
186
|
+
end
|
|
101
187
|
end
|
|
102
188
|
|
|
103
189
|
private
|
|
104
190
|
|
|
105
|
-
def
|
|
106
|
-
|
|
191
|
+
def crypt_stopping?
|
|
192
|
+
Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
|
|
193
|
+
end
|
|
107
194
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
195
|
+
def active_generation?(generation:, **)
|
|
196
|
+
!@stop && !crypt_stopping? && @generation == generation
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def renewal_loop(generation:)
|
|
200
|
+
interruptible_sleep(sleep_duration, generation: generation)
|
|
201
|
+
|
|
202
|
+
loop do
|
|
203
|
+
policy = renewal_policy(generation: generation)
|
|
204
|
+
return unless policy
|
|
205
|
+
|
|
206
|
+
renewable, kerberos = policy
|
|
207
|
+
renewed = (renewable && renew_token(generation: generation)) || (kerberos && reauth_kerberos(generation: generation))
|
|
208
|
+
return unless @state_mutex.synchronize { active_generation?(generation: generation) }
|
|
209
|
+
|
|
210
|
+
if renewed
|
|
211
|
+
on_renewal_success(generation: generation)
|
|
115
212
|
else
|
|
116
|
-
|
|
117
|
-
on_renewal_success
|
|
118
|
-
else
|
|
119
|
-
on_renewal_failure
|
|
120
|
-
end
|
|
213
|
+
on_renewal_failure(generation: generation)
|
|
121
214
|
end
|
|
122
215
|
end
|
|
123
216
|
rescue StandardError => e
|
|
124
217
|
handle_exception(e, level: :warn, operation: 'crypt.token_renewer.renewal_loop', cluster_name: @cluster_name)
|
|
125
218
|
log.warn("TokenRenewer[#{@cluster_name}]: renewal loop error: #{e.message}")
|
|
126
|
-
|
|
219
|
+
on_renewal_failure(generation: generation)
|
|
220
|
+
retry if @state_mutex.synchronize { active_generation?(generation: generation) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def renewal_policy(generation:, **)
|
|
224
|
+
@state_mutex.synchronize do
|
|
225
|
+
return unless active_generation?(generation: generation)
|
|
226
|
+
|
|
227
|
+
kerberos = @config[:auth_method].to_s == 'kerberos'
|
|
228
|
+
if @expires_at && @expires_at <= Time.now
|
|
229
|
+
@config[:connected] = false
|
|
230
|
+
@config[:relogin_required] = true unless kerberos
|
|
231
|
+
return unless kerberos
|
|
232
|
+
end
|
|
233
|
+
return if !kerberos && (!@config[:renewable] || @config[:relogin_required])
|
|
234
|
+
|
|
235
|
+
[@config[:renewable], kerberos]
|
|
236
|
+
end
|
|
127
237
|
end
|
|
128
238
|
|
|
129
|
-
def on_renewal_success
|
|
130
|
-
reset_backoff
|
|
131
|
-
|
|
239
|
+
def on_renewal_success(generation:)
|
|
240
|
+
return unless reset_backoff(generation: generation)
|
|
241
|
+
|
|
242
|
+
interruptible_sleep(sleep_duration, generation: generation)
|
|
132
243
|
end
|
|
133
244
|
|
|
134
|
-
def on_renewal_failure
|
|
135
|
-
|
|
136
|
-
|
|
245
|
+
def on_renewal_failure(generation:)
|
|
246
|
+
delay = next_backoff(generation: generation)
|
|
247
|
+
return unless delay
|
|
248
|
+
|
|
249
|
+
@state_mutex.synchronize do
|
|
250
|
+
return unless active_generation?(generation: generation)
|
|
251
|
+
|
|
252
|
+
kerberos = @config[:auth_method].to_s == 'kerberos'
|
|
253
|
+
remaining = @expires_at && (@expires_at - Time.now)
|
|
254
|
+
if kerberos || @renewal_terminal || (remaining && !remaining.positive?)
|
|
255
|
+
@config[:connected] = false
|
|
256
|
+
@config[:relogin_required] = true unless kerberos
|
|
257
|
+
end
|
|
258
|
+
return if !kerberos && @config[:relogin_required]
|
|
259
|
+
|
|
260
|
+
delay = [delay, [remaining / 2, MINIMUM_WAIT].max, remaining].min if remaining&.positive?
|
|
261
|
+
end
|
|
137
262
|
log.warn("TokenRenewer[#{@cluster_name}]: backoff retry in #{delay}s")
|
|
138
|
-
interruptible_sleep(delay)
|
|
263
|
+
interruptible_sleep(delay, generation: generation)
|
|
139
264
|
end
|
|
140
265
|
|
|
141
|
-
def reissue_all_leases
|
|
266
|
+
def reissue_all_leases(active:)
|
|
142
267
|
return unless defined?(Legion::Crypt::LeaseManager)
|
|
268
|
+
return unless active.call
|
|
143
269
|
|
|
144
|
-
Legion::Crypt::LeaseManager.instance.reissue_all
|
|
270
|
+
Legion::Crypt::LeaseManager.instance.reissue_all(active: active)
|
|
145
271
|
rescue StandardError => e
|
|
146
272
|
handle_exception(e, level: :warn, operation: 'crypt.token_renewer.reissue_all_leases', cluster_name: @cluster_name)
|
|
147
273
|
log.warn("TokenRenewer[#{@cluster_name}]: failed to reissue leases after reauth: #{e.message}")
|
|
148
274
|
end
|
|
149
275
|
|
|
150
|
-
def interruptible_sleep(seconds)
|
|
276
|
+
def interruptible_sleep(seconds, generation:)
|
|
151
277
|
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
278
|
+
@state_mutex.synchronize do
|
|
279
|
+
while active_generation?(generation: generation)
|
|
280
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
281
|
+
break unless remaining.positive?
|
|
155
282
|
|
|
156
|
-
|
|
283
|
+
@wake.wait(@state_mutex, remaining)
|
|
284
|
+
end
|
|
157
285
|
end
|
|
158
286
|
end
|
|
159
287
|
|
|
160
|
-
def stop_thread_and_revoke
|
|
161
|
-
return unless
|
|
288
|
+
def stop_thread_and_revoke(thread:, deadline:)
|
|
289
|
+
return unless thread
|
|
162
290
|
|
|
163
291
|
log.info("TokenRenewer[#{@cluster_name}]: stopping token renewal thread")
|
|
164
|
-
|
|
165
|
-
|
|
292
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
293
|
+
thread.join(remaining) if remaining.positive? && thread != Thread.current
|
|
166
294
|
|
|
167
|
-
if
|
|
295
|
+
if thread.alive?
|
|
168
296
|
log.warn("TokenRenewer[#{@cluster_name}]: token renewal thread did not stop within timeout; skipping token revocation")
|
|
169
297
|
else
|
|
170
|
-
@thread = nil
|
|
171
|
-
revoke_token
|
|
298
|
+
@state_mutex.synchronize { @thread = nil if @thread == thread }
|
|
299
|
+
revoke_token(deadline: deadline)
|
|
172
300
|
log.info("TokenRenewer[#{@cluster_name}]: token renewal thread stopped")
|
|
173
301
|
end
|
|
174
302
|
end
|
|
175
303
|
|
|
176
|
-
def revoke_token
|
|
177
|
-
|
|
178
|
-
|
|
304
|
+
def revoke_token(deadline: nil)
|
|
305
|
+
owned_token = @state_mutex.synchronize do
|
|
306
|
+
@config[:auth_method]&.to_s == 'kerberos' && @vault_client&.token
|
|
307
|
+
end
|
|
308
|
+
return unless owned_token
|
|
179
309
|
|
|
180
|
-
@vault_client.auth_token.revoke_self
|
|
310
|
+
VaultOperation.with_timeout(deadline: deadline) { @vault_client.auth_token.revoke_self }
|
|
181
311
|
log.info("TokenRenewer[#{@cluster_name}]: Vault token revoked")
|
|
182
312
|
rescue StandardError => e
|
|
183
313
|
handle_exception(e, level: :warn, operation: 'crypt.token_renewer.revoke_token', cluster_name: @cluster_name)
|