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.
@@ -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 = ::Vault.logical.write(
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: !Gem::Specification.find_by_name('vault').nil?,
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
- # For environments that cannot make a real SPIRE call (CI, lite mode,
18
- # no socket present) the client returns a self-signed fallback SVID so
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
- # Falls back to a self-signed certificate when the Workload API is unavailable.
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
- return false unless ::File.exist?(@socket_path)
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
- sock = UNIXSocket.new(@socket_path)
88
- sock.close
89
- true
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
- # This is intentionally simple: one request frame, one response frame.
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
- sock = connect_socket
103
- begin
104
- send_grpc_request(sock, method_path, request_body)
105
- read_grpc_response(sock)
106
- ensure
107
- close_workload_api_socket(sock, method_path)
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 connect_socket
120
- raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'" unless ::File.exist?(@socket_path)
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
- sock = UNIXSocket.new(@socket_path)
123
- # Write HTTP/2 connection preface and initial SETTINGS frame.
124
- sock.write(HTTP2_PREFACE)
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
- # Read until we see a DATA frame containing a gRPC message or timeout.
200
- deadline = Time.now + READ_TIMEOUT
201
- buffer = ''.b
202
-
202
+ def read_grpc_response(sock, deadline:)
203
+ buffer = ''.b
204
+ message = ''.b
203
205
  loop do
204
- raise WorkloadApiError, 'Workload API read timeout' if Time.now > deadline
205
-
206
- ready = sock.wait_readable(1.0)
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
- break if chunk == :wait_readable || chunk.nil?
211
+ next if chunk == :wait_readable
212
+ break if chunk.nil?
211
213
 
212
- buffer += chunk.b
213
- result = extract_grpc_body(buffer)
214
- return result if 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
- # Scan the raw HTTP/2 buffer for a DATA frame (type=0x00) that contains
221
- # a non-empty gRPC message and return the message body bytes.
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
- frame_type = buffer.getbyte(pos + 3)
227
- pos += 9 # skip frame header
228
-
229
- if pos + frame_length > buffer.bytesize
230
- # Incomplete frame — need more data.
231
- return nil
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
- payload = buffer.byteslice(pos, frame_length)
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
- return msg_body
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
- "\n#{[audience_bytes.bytesize].pack('C')}#{audience_bytes}"
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
- return if running?
38
+ @state_mutex.synchronize do
39
+ return if @stopping || @thread&.alive? || crypt_stopping?
29
40
 
30
- @stop = false
31
- @thread = Thread.new { renewal_loop }
32
- @thread.name = "vault-renewer-#{@cluster_name}"
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 stop
37
- @stop = true
38
- @thread&.wakeup
39
- rescue ThreadError => e
40
- handle_exception(e, level: :debug, operation: 'crypt.token_renewer.stop', cluster_name: @cluster_name)
50
+ def request_stop
51
+ @state_mutex.synchronize do
52
+ @stop = true
53
+ @generation += 1
54
+ @wake.broadcast
55
+ end
41
56
  nil
42
- ensure
43
- stop_thread_and_revoke
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
- result = @vault_client.auth_token.renew_self
52
- @config[:lease_duration] = result.auth.lease_duration
53
- @config[:renewable] = result.auth.renewable? if result.auth.respond_to?(:renewable?)
54
- log.info("TokenRenewer[#{@cluster_name}]: token renewed, ttl=#{result.auth.lease_duration}s")
55
- true
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
- krb_config = @config[:kerberos] || {}
64
- result = Legion::Crypt::KerberosAuth.login(
65
- vault_client: @vault_client,
66
- service_principal: krb_config[:service_principal],
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
- @config[:token] = result[:token]
71
- @config[:lease_duration] = result[:lease_duration]
72
- @config[:renewable] = result[:renewable]
73
- @config[:connected] = true
74
- @vault_client.token = result[:token]
75
- log.info("TokenRenewer[#{@cluster_name}]: re-authenticated via Kerberos, ttl=#{result[:lease_duration]}s")
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
- reissue_all_leases
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
- lease_duration = @config[:lease_duration].to_i
87
- duration = [(lease_duration * RENEWAL_RATIO).to_i, 1].max
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
- [duration, MIN_SLEEP].max
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
- current = @backoff
95
- @backoff = [@backoff * 2, MAX_BACKOFF].min
96
- current
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
- @backoff = INITIAL_BACKOFF
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 renewal_loop
106
- interruptible_sleep(sleep_duration)
191
+ def crypt_stopping?
192
+ Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
193
+ end
107
194
 
108
- until @stop
109
- if @config[:renewable]
110
- if renew_token || reauth_kerberos
111
- on_renewal_success
112
- else
113
- on_renewal_failure
114
- end
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
- if reauth_kerberos # rubocop:disable Style/IfInsideElse
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
- retry unless @stop
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
- interruptible_sleep(sleep_duration)
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
- @config[:connected] = false
136
- delay = next_backoff
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
- loop do
153
- remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
154
- break if remaining <= 0 || @stop
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
- sleep([remaining, 1.0].min)
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 @thread
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
- @thread.join(5)
165
- thread_still_running = @thread.alive?
292
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
293
+ thread.join(remaining) if remaining.positive? && thread != Thread.current
166
294
 
167
- if thread_still_running
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
- return unless @vault_client&.token
178
- return unless @config[:auth_method]&.to_s == 'kerberos'
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)