deploy-agent 1.4.1 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 944241c474fe9e8a7f3dc96805f25672f7ef67f637c829ecee0e70df4bf97689
4
- data.tar.gz: d869a3e2b4100889bc02950c989db2298215e3bc66628b2c09325f498172ee33
3
+ metadata.gz: c5067e76ea2bf2055765fc88baebf2b021437ea1e97fc5f58d5c077af284b6c7
4
+ data.tar.gz: 956efa4a5e8f55e5dfa1dc8c8fdb913b3e5b02757f374f3b4df04d4645942f91
5
5
  SHA512:
6
- metadata.gz: 5c50f09a5aa5ec13156cbe4ef6ca7642447e9888d2703ec411b5b43a7c130154db359ce76696ae8c63b1dbd44a91e2be5239d54397cda4c769a2387c6a633ed9
7
- data.tar.gz: fedac9d8f5bd77f35cf27baff2b0354a9bfbf2418ef385fe2fd206adb7fb7992c9f45c01137b5ccf062d780b8ae915de91ae6bd4b1b2a567e86a3e7528afdb5d
6
+ metadata.gz: 66b0d717f7af4cb3e248260c76ee91a8ce02eeea02a13cc5e26de5953c80c52caf77272d226abec0c51c60a196c931a9ee70376761b888930649e51e5b5c3115
7
+ data.tar.gz: 71dcda3189f645a60af9b553032e40286393bb04d806802ca129cba5e035d74a525486c6407caae416bf9745f6036e1b4da523bf7c05b53a1456b7ac2a5a619b
data/ca.crt CHANGED
@@ -31,3 +31,33 @@ l4CPcGbB0L8yyIhGwiEfrZpjx6hOelX1daG8QPTvSSYpB6ODtQeb3zpDf8vU8M7T
31
31
  oAwG8/0g1Owh/a970vIKu4TBa4D2IiCfA3KPWlsIUSoeu9uBTKmUQ0Raa0AhZWPv
32
32
  JI4XgcL63KznYzLm0BOxvTYMxDfn7fs=
33
33
  -----END CERTIFICATE-----
34
+ -----BEGIN CERTIFICATE-----
35
+ MIIFGzCCAwOgAwIBAgIBATANBgkqhkiG9w0BAQsFADAvMRowGAYDVQQDDBFEZXBs
36
+ b3lIUSBBZ2VudCBDQTERMA8GA1UECgwIRGVwbG95SFEwHhcNMjYwOTE1MDczODI4
37
+ WhcNMzYwOTE1MDczODI4WjAvMRowGAYDVQQDDBFEZXBsb3lIUSBBZ2VudCBDQTER
38
+ MA8GA1UECgwIRGVwbG95SFEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC
39
+ AQC4bMAQgGZC7ZUO9Wq2+rWodOXL4IZ6Ae+Dfce+SlTsQUx5HrYrmMFTE9NJTxcE
40
+ dGYjAVVwpdvgT8flFKRiQa7j1xVOLr73ra7Uro0ZGHw1TOBFFAhXLZ25oXT8SsB7
41
+ 1aaRZksZAPGR1ZkWRvKqUkRge9LERtcmZDOSeKyf3i3PQp3AaODA4tRwsQnyKWoA
42
+ 6h9Ruk9jnHqNRapiEjjdkubKKRjgU28IO0Abom5dRsDvvFYVquaQmud745kdaxDr
43
+ fQA9Kyz8lzNSglmTHACjzxtX1AGRpn5yLO7PqaV0M/QqnKZhaKW+tzP3pWdsDZ8v
44
+ RBGDhyVYeyHYSPAEOgsRdTqQYpnXkR1FLLS/FCFQ/UgyWFStFVv3l9mzq/fQLr/O
45
+ sdPzG9KNNTz4OqMrGOeRMcBXsKQJjIRS6TSzggPagcxT2iPRHCGQknko8/O3IyM1
46
+ 9YIsLm7kxw2oEIzhHx8Wv3s+SafJr4cuTu1n9RfyP3LmU/C6GbfrH/jDvVuLvFdH
47
+ 9AY55RNCfCgCKWjqDeYrzGxIcuoZ57H6DJlGouw0MiSg4hUUqNnM1EQG410bJouA
48
+ itprB7FIGcZ9uHQvpEgs8NP/DTRsmUP+51Vdp6SLbFSNrt5v/APDrd3ya8R3f7g0
49
+ XVLFl/jxNM4Ki+L6ZGwRz4SYThew0OZXsezbnYiGEro1JwIDAQABo0IwQDAdBgNV
50
+ HQ4EFgQUgnCdcTQCEbmADry95bq7UAoX71AwDwYDVR0TAQH/BAUwAwEB/zAOBgNV
51
+ HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAHaN63Zqs4W5FTTqQPYnkMH0
52
+ /hMzRVVcQ8tZng6rGy4HdONixQ2XOwFOCMbl8uqRB/ihyLbwiLIVgsqG3wMEIiqV
53
+ lxNDYerUtdBolam/lqA371d18lEqKFiFfncCRrd+AzTMcYnYZZK026smx1JCxDCP
54
+ t1rB/T9Lv0g2aRyWTKsKBeI5GFOIEIHScP1k/R7549kNEwerFKX5USWszDfnouiV
55
+ hSz9UjlY1vQQ//qYgimevJxlGudt2b+73+BkT5THdNKBxeyn02/DbhaH/1zafE/7
56
+ lxAKmN94HkAOM2TD27ZPRDIK4jmKK/Hx03X0kYD+KL/9Mr/4PIjlObRFF8BUawp1
57
+ Kpn/Caw4gNz76IujGXGvJp9FxsZUscRRRdikIZ3AtR0/6Wyf8fFaAd89DoBGSzLM
58
+ mRynJbnK5ROlVzvxX43ow7sBLtoh1+/RCsWcCVgOC2EMCW2n4b5M0USv7FBrhPie
59
+ F50NkMqsTtEoxw8kcKvLd4FbPvWuIxPsXzxlnFaHTbVgTevgL6O8IVeWLqdqwFGK
60
+ owB7z1tEQTN6sV4+fDDtaVQFCalEV9QPAolVLYdBUApc866heDHmzW85tkq2zoDG
61
+ 2cj80hCcI42DPvrb9nEcIcZiFZDCcIlDjRf6kDmqBW4oXr5V1yAxrC+i0UA2+LWj
62
+ NVgD1tDYCLJlNY3GA6j8
63
+ -----END CERTIFICATE-----
data/deploy-agent.gemspec CHANGED
@@ -7,7 +7,8 @@ Gem::Specification.new do |s|
7
7
  s.version = DeployAgent::VERSION
8
8
  s.required_ruby_version = '>= 2.7'
9
9
  s.summary = 'The DeployHQ Agent'
10
- s.description = 'This gem allows you to configure a secure proxy through which DeployHQ can forward connections'
10
+ s.description = 'Deprecated: use https://github.com/deployhq/network-agent instead. ' \
11
+ 'This gem allows you to configure a secure proxy through which DeployHQ can forward connections'
11
12
  s.authors = ['Charlie Smurthwaite']
12
13
  s.email = ['support@deployhq.com']
13
14
  s.files = Dir.glob('{lib,bin}/**/*')
@@ -19,5 +20,14 @@ Gem::Specification.new do |s|
19
20
 
20
21
  s.add_dependency 'nio4r', '~> 2.7'
21
22
  s.add_dependency 'rb-readline', '~> 0.5'
22
- s.add_dependency 'timers', '~> 4.3'
23
+ # timers 4.4.0 raised required_ruby_version to >= 3.1. '~> 4.3' admits it, and the
24
+ # RubyGems shipped with Ruby 2.7 cannot back off to 4.3.5 on its own, so a plain
25
+ # `gem install deploy-agent` fails outright on every Ruby this gem still supports
26
+ # below 3.1. Keep the upper bound until required_ruby_version moves past 3.1.
27
+ s.add_dependency 'timers', '>= 4.3', '< 4.4'
28
+
29
+ s.post_install_message = <<~MSG
30
+ WARNING: deploy-agent is deprecated and only receives essential fixes.
31
+ Please migrate to the new agent: https://github.com/deployhq/network-agent
32
+ MSG
23
33
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module DeployAgent
6
+ # Validates and atomically installs a replacement client certificate offered by
7
+ # the Deploy server over the tunnel (see ServerConnection::COMMAND_RENEW_RESPONSE).
8
+ #
9
+ # A renewal is a re-signature of the certificate we already hold: the server
10
+ # re-signs our stored public key under a different CA, keeping the subject and
11
+ # the serial. Our private key is never involved, so a renewed certificate must
12
+ # still pair with the agent.key already on disk.
13
+ #
14
+ # Every check runs before anything touches the filesystem, and the replacement
15
+ # is swapped in with rename(2). A bad agent.crt is not a recoverable state: the
16
+ # agent would fail the TLS handshake on every reconnect and Agent#run gives up
17
+ # and exits the process after four consecutive SSL errors.
18
+ class CertificateRenewal
19
+
20
+ class InvalidCertificate < StandardError; end
21
+
22
+ def initialize(certificate_path: CERTIFICATE_PATH, key_path: KEY_PATH, ca_path: CA_PATH)
23
+ @certificate_path = certificate_path
24
+ @key_path = key_path
25
+ @ca_path = ca_path
26
+ end
27
+
28
+ # Validate a PEM-encoded replacement certificate and install it.
29
+ #
30
+ # Returns the installed OpenSSL::X509::Certificate, or nil when the server
31
+ # offered the certificate we are already using (in which case nothing is
32
+ # written and there is no reason to reconnect).
33
+ #
34
+ # Raises InvalidCertificate - having written nothing at all - if any check
35
+ # fails. The caller keeps its working certificate.
36
+ def install(pem)
37
+ new_certificate = parse(pem)
38
+ validate!(new_certificate)
39
+ return nil if new_certificate.to_der == current_certificate.to_der
40
+
41
+ write(new_certificate)
42
+ new_certificate
43
+ end
44
+
45
+ private
46
+
47
+ def parse(pem)
48
+ raise InvalidCertificate, 'renewal response contained no certificate' if pem.nil? || pem.empty?
49
+
50
+ OpenSSL::X509::Certificate.new(pem)
51
+ rescue OpenSSL::OpenSSLError => e
52
+ raise InvalidCertificate, "could not parse the offered certificate: #{e.message}"
53
+ end
54
+
55
+ def validate!(new_certificate)
56
+ # Renewal re-signs our public key, it never re-keys us, so the replacement
57
+ # has to pair with the private key we already hold.
58
+ unless new_certificate.check_private_key(private_key)
59
+ raise InvalidCertificate, 'offered certificate does not match the agent private key'
60
+ end
61
+
62
+ # The backend identifies this agent by the certificate serial, and the
63
+ # subject carries the agent name. Neither may change across a renewal.
64
+ unless new_certificate.subject == current_certificate.subject
65
+ raise InvalidCertificate,
66
+ "offered certificate has a different subject (#{current_certificate.subject} -> #{new_certificate.subject})"
67
+ end
68
+
69
+ unless new_certificate.serial == current_certificate.serial
70
+ raise InvalidCertificate,
71
+ "offered certificate has a different serial (#{current_certificate.serial} -> #{new_certificate.serial})"
72
+ end
73
+
74
+ # And it has to chain to a CA we ship *as a TLS client certificate*, or we
75
+ # would be trading a working certificate for one the server refuses on the
76
+ # next handshake - and every handshake after it.
77
+ store = certificate_store
78
+ return if store.verify(new_certificate)
79
+
80
+ raise InvalidCertificate, "offered certificate is not a usable client certificate (#{store.error_string})"
81
+ end
82
+
83
+ # Write to a private temporary file in the same directory, flush it all the
84
+ # way to disk, then rename over agent.crt so a reader never sees a partial
85
+ # certificate and a crash mid-write cannot destroy the working one.
86
+ def write(certificate)
87
+ temp_path = temporary_path
88
+ begin
89
+ File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
90
+ file.write(certificate.to_pem)
91
+ file.flush
92
+ file.fsync
93
+ end
94
+ File.rename(temp_path, @certificate_path)
95
+ rescue StandardError
96
+ File.unlink(temp_path) if File.file?(temp_path)
97
+ raise
98
+ end
99
+ end
100
+
101
+ def temporary_path
102
+ directory = File.dirname(@certificate_path)
103
+ basename = File.basename(@certificate_path)
104
+ File.join(directory, ".#{basename}.#{Process.pid}.#{rand(0xffffffff).to_s(16)}")
105
+ end
106
+
107
+ def current_certificate
108
+ @current_certificate ||= OpenSSL::X509::Certificate.new(File.read(@certificate_path))
109
+ rescue SystemCallError, OpenSSL::OpenSSLError => e
110
+ raise InvalidCertificate, "could not read the current certificate: #{e.message}"
111
+ end
112
+
113
+ def private_key
114
+ @private_key ||= OpenSSL::PKey::RSA.new(File.read(@key_path))
115
+ rescue SystemCallError, OpenSSL::OpenSSLError => e
116
+ raise InvalidCertificate, "could not read the agent private key: #{e.message}"
117
+ end
118
+
119
+ def certificate_store
120
+ @certificate_store ||= OpenSSL::X509::Store.new.tap do |store|
121
+ store.add_file(@ca_path)
122
+ # The agent presents this certificate for TLS client authentication, so
123
+ # verify it for that purpose rather than the store's default, which
124
+ # accepts anything that merely chains. Real agent certificates carry no
125
+ # extensions at all and this purpose accepts them; what it rejects is a
126
+ # certificate whose keyUsage or extendedKeyUsage rules client auth out
127
+ # (a serverAuth-only certificate, say), which would otherwise install
128
+ # cleanly and then be refused by the server on every reconnect.
129
+ store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT
130
+ end
131
+ rescue SystemCallError, OpenSSL::OpenSSLError => e
132
+ raise InvalidCertificate, "could not read the CA bundle: #{e.message}"
133
+ end
134
+
135
+ end
136
+ end
@@ -4,7 +4,20 @@ require 'optparse'
4
4
  module DeployAgent
5
5
  class CLI
6
6
 
7
+ DEPRECATION_NOTICE = <<~MSG
8
+ \e[33m╔══════════════════════════════════════════════════════════════════╗
9
+ ║ DEPRECATED: deploy-agent only receives essential fixes. ║
10
+ ║ Please migrate to the new agent: ║
11
+ ║ ║
12
+ ║ https://github.com/deployhq/network-agent ║
13
+ ║ ║
14
+ ║ Fewer dependencies, easier to install. ║
15
+ ╚══════════════════════════════════════════════════════════════════╝\e[0m
16
+ MSG
17
+
7
18
  def dispatch(arguments)
19
+ warn DEPRECATION_NOTICE
20
+
8
21
  methods = self.public_methods(false).delete_if { |n| n == :dispatch }.sort
9
22
 
10
23
  @options = {}
@@ -9,6 +9,17 @@ module DeployAgent
9
9
  attr_reader :destination_connections, :agent
10
10
  attr_writer :nio_monitor
11
11
 
12
+ # Tunnel commands. 1-7 are the original proxy protocol. 8 and 9 were added
13
+ # for in-band certificate renewal and are implemented identically in the Go
14
+ # agent (network-agent) and in the backend.
15
+ COMMAND_RENEW_REQUEST = 8 # agent -> server, payload "ruby/<version>"
16
+ COMMAND_RENEW_RESPONSE = 9 # server -> agent, payload [status:1][body]
17
+
18
+ # COMMAND_RENEW_RESPONSE statuses
19
+ RENEW_STATUS_RENEWED = 0 # body is the replacement certificate, PEM encoded
20
+ RENEW_STATUS_CURRENT = 1 # no body, the certificate we hold is current
21
+ RENEW_STATUS_ERROR = 2 # body is a UTF-8 message
22
+
12
23
  # Create a secure TLS connection to the Deploy server
13
24
  def initialize(agent, server_host, nio_selector, check_certificate=true)
14
25
  @agent = agent
@@ -40,6 +51,12 @@ module DeployAgent
40
51
  @nio_monitor = @nio_selector.register(@tcp_socket, :r)
41
52
  @nio_monitor.value = self
42
53
 
54
+ # Ask the server whether a replacement certificate is waiting for us. The
55
+ # server decides and answers with COMMAND_RENEW_RESPONSE; it never sends one
56
+ # unsolicited. This has to come after the monitor exists because send_packet
57
+ # arms it for writing.
58
+ request_certificate_renewal
59
+
43
60
  @agent.logger.info "Successfully connected to server"
44
61
  rescue => e
45
62
  @agent.logger.info "Something went wrong connecting to server."
@@ -111,6 +128,9 @@ module DeployAgent
111
128
  # This is a shutdown request. Disconnect and don't re-attempt connection.
112
129
  @agent.logger.warn "Server requested reconnect. Closing connection."
113
130
  close
131
+ when COMMAND_RENEW_RESPONSE
132
+ # The server has answered our renewal request.
133
+ handle_renewal_response(packet[1..-1])
114
134
  end
115
135
  end
116
136
  rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::ENETRESET
@@ -183,6 +203,48 @@ module DeployAgent
183
203
  raise ServerDisconnected
184
204
  end
185
205
 
206
+ # Ask the server to re-issue our client certificate, telling it which agent
207
+ # implementation and version is asking. Renewal is best effort: it must never
208
+ # stop us connecting, so a failure here is logged and otherwise ignored.
209
+ def request_certificate_renewal
210
+ @agent.logger.debug "Requesting certificate renewal"
211
+ send_packet([COMMAND_RENEW_REQUEST, "ruby/#{DeployAgent::VERSION}"].pack('Ca*'))
212
+ rescue => e
213
+ @agent.logger.warn "Could not request certificate renewal: #{e.message}"
214
+ end
215
+
216
+ # Process a COMMAND_RENEW_RESPONSE. Renewal must never take the agent down, so
217
+ # anything unexpected is logged and the existing certificate is kept.
218
+ def handle_renewal_response(body)
219
+ body = body.to_s
220
+ status = body.bytes[0]
221
+ payload = body[1..-1].to_s
222
+
223
+ case status
224
+ when RENEW_STATUS_RENEWED
225
+ certificate = CertificateRenewal.new.install(payload)
226
+ if certificate
227
+ @agent.logger.info "Certificate renewed (issuer=#{certificate.issuer})"
228
+ # Reconnect so the new certificate is the one we present. close raises
229
+ # ServerDisconnected, which Agent#run catches and retries, and the new
230
+ # connection re-reads agent.crt from disk.
231
+ close
232
+ else
233
+ @agent.logger.debug "Server offered the certificate we already hold"
234
+ end
235
+ when RENEW_STATUS_CURRENT
236
+ @agent.logger.debug "Certificate is up to date"
237
+ when RENEW_STATUS_ERROR
238
+ @agent.logger.warn "Server could not renew our certificate: #{payload}"
239
+ else
240
+ @agent.logger.warn "Unknown certificate renewal status: #{status.inspect}"
241
+ end
242
+ rescue ServerDisconnected
243
+ raise
244
+ rescue => e
245
+ @agent.logger.warn "Certificate renewal failed: #{e.message}"
246
+ end
247
+
186
248
  # Queue a packet of data to be sent to the Deploy server
187
249
  def send_packet(data)
188
250
  @tx_buffer << [data.bytesize+2, data].pack('na*')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAgent
4
- VERSION = '1.4.1'
4
+ VERSION = '1.5.0'
5
5
  end
data/lib/deploy_agent.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'deploy_agent/version'
2
+ require 'deploy_agent/certificate_renewal'
2
3
  require 'deploy_agent/configuration_generator'
3
4
  require 'deploy_agent/server_connection'
4
5
  require 'deploy_agent/destination_connection'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deploy-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charlie Smurthwaite
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-19 00:00:00.000000000 Z
10
+ date: 2026-10-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: nio4r
@@ -41,18 +41,24 @@ dependencies:
41
41
  name: timers
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
45
  - !ruby/object:Gem::Version
46
46
  version: '4.3'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '4.4'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
52
55
  - !ruby/object:Gem::Version
53
56
  version: '4.3'
54
- description: This gem allows you to configure a secure proxy through which DeployHQ
55
- can forward connections
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '4.4'
60
+ description: 'Deprecated: use https://github.com/deployhq/network-agent instead. This
61
+ gem allows you to configure a secure proxy through which DeployHQ can forward connections'
56
62
  email:
57
63
  - support@deployhq.com
58
64
  executables:
@@ -65,6 +71,7 @@ files:
65
71
  - deploy-agent.gemspec
66
72
  - lib/deploy_agent.rb
67
73
  - lib/deploy_agent/agent.rb
74
+ - lib/deploy_agent/certificate_renewal.rb
68
75
  - lib/deploy_agent/cli.rb
69
76
  - lib/deploy_agent/configuration_generator.rb
70
77
  - lib/deploy_agent/destination_connection.rb
@@ -73,6 +80,9 @@ files:
73
80
  homepage: https://www.deployhq.com/
74
81
  licenses: []
75
82
  metadata: {}
83
+ post_install_message: |
84
+ WARNING: deploy-agent is deprecated and only receives essential fixes.
85
+ Please migrate to the new agent: https://github.com/deployhq/network-agent
76
86
  rdoc_options: []
77
87
  require_paths:
78
88
  - lib