From 8729a2042c7b5edeb6f74625a9ed6b622f8ab563 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 16:06:15 +0200 Subject: [PATCH 1/9] fix: pin timers below 4.4 so the gem installs on Ruby 2.7 timers 4.4.0 (2025-02-07) raised required_ruby_version to >= 3.1. The old '~> 4.3' constraint admits it, and the RubyGems shipped with Ruby 2.7 and earlier cannot back off to 4.3.5 on its own, so `gem install deploy-agent` failed outright on every supported Ruby below 3.1: The last version of timers (~> 4.3) to support your Ruby & RubyGems was 4.3.5. ... timers requires Ruby version >= 3.1. Reproduced on Ruby 2.7.8 / RubyGems 3.1.6 with a clean GEM_HOME before the change, and resolving to timers-4.3.5 after it. required_ruby_version stays at '>= 2.7', so the upper bound holds until that moves past 3.1. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- deploy-agent.gemspec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deploy-agent.gemspec b/deploy-agent.gemspec index e87e452..de43339 100644 --- a/deploy-agent.gemspec +++ b/deploy-agent.gemspec @@ -20,7 +20,11 @@ Gem::Specification.new do |s| s.add_dependency 'nio4r', '~> 2.7' s.add_dependency 'rb-readline', '~> 0.5' - s.add_dependency 'timers', '~> 4.3' + # timers 4.4.0 raised required_ruby_version to >= 3.1. '~> 4.3' admits it, and the + # RubyGems shipped with Ruby 2.7 cannot back off to 4.3.5 on its own, so a plain + # `gem install deploy-agent` fails outright on every Ruby this gem still supports + # below 3.1. Keep the upper bound until required_ruby_version moves past 3.1. + s.add_dependency 'timers', '>= 4.3', '< 4.4' s.post_install_message = <<~MSG WARNING: deploy-agent is deprecated and will not receive further updates. From d3e6e394e718450338ec2036ae6846ab14cea4b8 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 16:14:34 +0200 Subject: [PATCH 2/9] feat: in-band certificate renewal over the agent tunnel The CA every shipped agent pins expires 2027-03-17. Trusting a replacement root is only half of mutual TLS: an agent also has to hold a client certificate issued by that root before the old one expires, or the backend rejects it whatever it trusts (deployhq/deployhq#1202). Renewal is agent-initiated on every connect and server-decided. Two new tunnel commands, identical in the backend and in network-agent: 8 COMMAND_RENEW_REQUEST agent -> server, payload "ruby/" 9 COMMAND_RENEW_RESPONSE server -> agent, payload [status:1][body] 0 renewed (body = new certificate, PEM) 1 current (no body) 2 error (body = message) The request goes out once per connection, straight after the handshake. The server never sends 9 unsolicited, and agents that predate this change already ignore unknown command bytes, so nothing regresses for them. DeployAgent::CertificateRenewal validates before it writes, and writes nothing at all unless every check passes: the replacement must pair with the agent.key we already hold (renewal re-signs our public key, it never re-keys us), must carry the same subject and serial the backend identifies us by, and must chain to a CA in the bundled ca.crt. It is then swapped in with a 0600 temp file, fsync and rename(2). That strictness is deliberate. A bad agent.crt is unrecoverable in the field: Agent#run gives up and exits the process after four consecutive SSL errors, so a renewal that "mostly" works would take the agent down for good. Every failure path here logs and keeps the working certificate instead, and nothing renewal-related is allowed to escape rx_data. On success the connection is closed so the existing ServerDisconnected retry reconnects and presents the new certificate. A certificate identical to the one already installed is treated as a no-op rather than a renewal, so a server that keeps answering 0 cannot spin the agent through a reconnect loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- lib/deploy_agent.rb | 1 + lib/deploy_agent/certificate_renewal.rb | 125 ++++++++++++++++++++++++ lib/deploy_agent/server_connection.rb | 62 ++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 lib/deploy_agent/certificate_renewal.rb diff --git a/lib/deploy_agent.rb b/lib/deploy_agent.rb index 212b5e0..2dfe0af 100644 --- a/lib/deploy_agent.rb +++ b/lib/deploy_agent.rb @@ -1,4 +1,5 @@ require 'deploy_agent/version' +require 'deploy_agent/certificate_renewal' require 'deploy_agent/configuration_generator' require 'deploy_agent/server_connection' require 'deploy_agent/destination_connection' diff --git a/lib/deploy_agent/certificate_renewal.rb b/lib/deploy_agent/certificate_renewal.rb new file mode 100644 index 0000000..16b8fce --- /dev/null +++ b/lib/deploy_agent/certificate_renewal.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'openssl' + +module DeployAgent + # Validates and atomically installs a replacement client certificate offered by + # the Deploy server over the tunnel (see ServerConnection::COMMAND_RENEW_RESPONSE). + # + # A renewal is a re-signature of the certificate we already hold: the server + # re-signs our stored public key under a different CA, keeping the subject and + # the serial. Our private key is never involved, so a renewed certificate must + # still pair with the agent.key already on disk. + # + # Every check runs before anything touches the filesystem, and the replacement + # is swapped in with rename(2). A bad agent.crt is not a recoverable state: the + # agent would fail the TLS handshake on every reconnect and Agent#run gives up + # and exits the process after four consecutive SSL errors. + class CertificateRenewal + + class InvalidCertificate < StandardError; end + + def initialize(certificate_path: CERTIFICATE_PATH, key_path: KEY_PATH, ca_path: CA_PATH) + @certificate_path = certificate_path + @key_path = key_path + @ca_path = ca_path + end + + # Validate a PEM-encoded replacement certificate and install it. + # + # Returns the installed OpenSSL::X509::Certificate, or nil when the server + # offered the certificate we are already using (in which case nothing is + # written and there is no reason to reconnect). + # + # Raises InvalidCertificate - having written nothing at all - if any check + # fails. The caller keeps its working certificate. + def install(pem) + new_certificate = parse(pem) + validate!(new_certificate) + return nil if new_certificate.to_der == current_certificate.to_der + + write(new_certificate) + new_certificate + end + + private + + def parse(pem) + raise InvalidCertificate, 'renewal response contained no certificate' if pem.nil? || pem.empty? + + OpenSSL::X509::Certificate.new(pem) + rescue OpenSSL::OpenSSLError => e + raise InvalidCertificate, "could not parse the offered certificate: #{e.message}" + end + + def validate!(new_certificate) + # Renewal re-signs our public key, it never re-keys us, so the replacement + # has to pair with the private key we already hold. + unless new_certificate.check_private_key(private_key) + raise InvalidCertificate, 'offered certificate does not match the agent private key' + end + + # The backend identifies this agent by the certificate serial, and the + # subject carries the agent name. Neither may change across a renewal. + unless new_certificate.subject == current_certificate.subject + raise InvalidCertificate, + "offered certificate has a different subject (#{current_certificate.subject} -> #{new_certificate.subject})" + end + + unless new_certificate.serial == current_certificate.serial + raise InvalidCertificate, + "offered certificate has a different serial (#{current_certificate.serial} -> #{new_certificate.serial})" + end + + # And it has to chain to a CA we ship, or we would be trading a working + # certificate for one the server will refuse on the next handshake. + store = certificate_store + return if store.verify(new_certificate) + + raise InvalidCertificate, "offered certificate does not chain to a trusted CA (#{store.error_string})" + end + + # Write to a private temporary file in the same directory, flush it all the + # way to disk, then rename over agent.crt so a reader never sees a partial + # certificate and a crash mid-write cannot destroy the working one. + def write(certificate) + temp_path = temporary_path + begin + File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file| + file.write(certificate.to_pem) + file.flush + file.fsync + end + File.rename(temp_path, @certificate_path) + rescue StandardError + File.unlink(temp_path) if File.file?(temp_path) + raise + end + end + + def temporary_path + directory = File.dirname(@certificate_path) + basename = File.basename(@certificate_path) + File.join(directory, ".#{basename}.#{Process.pid}.#{rand(0xffffffff).to_s(16)}") + end + + def current_certificate + @current_certificate ||= OpenSSL::X509::Certificate.new(File.read(@certificate_path)) + rescue SystemCallError, OpenSSL::OpenSSLError => e + raise InvalidCertificate, "could not read the current certificate: #{e.message}" + end + + def private_key + @private_key ||= OpenSSL::PKey::RSA.new(File.read(@key_path)) + rescue SystemCallError, OpenSSL::OpenSSLError => e + raise InvalidCertificate, "could not read the agent private key: #{e.message}" + end + + def certificate_store + @certificate_store ||= OpenSSL::X509::Store.new.tap { |store| store.add_file(@ca_path) } + rescue SystemCallError, OpenSSL::OpenSSLError => e + raise InvalidCertificate, "could not read the CA bundle: #{e.message}" + end + + end +end diff --git a/lib/deploy_agent/server_connection.rb b/lib/deploy_agent/server_connection.rb index 0c6aee5..0f62189 100644 --- a/lib/deploy_agent/server_connection.rb +++ b/lib/deploy_agent/server_connection.rb @@ -9,6 +9,17 @@ class ServerDisconnected < StandardError;end attr_reader :destination_connections, :agent attr_writer :nio_monitor + # Tunnel commands. 1-7 are the original proxy protocol. 8 and 9 were added + # for in-band certificate renewal and are implemented identically in the Go + # agent (network-agent) and in the backend. + COMMAND_RENEW_REQUEST = 8 # agent -> server, payload "ruby/" + COMMAND_RENEW_RESPONSE = 9 # server -> agent, payload [status:1][body] + + # COMMAND_RENEW_RESPONSE statuses + RENEW_STATUS_RENEWED = 0 # body is the replacement certificate, PEM encoded + RENEW_STATUS_CURRENT = 1 # no body, the certificate we hold is current + RENEW_STATUS_ERROR = 2 # body is a UTF-8 message + # Create a secure TLS connection to the Deploy server def initialize(agent, server_host, nio_selector, check_certificate=true) @agent = agent @@ -40,6 +51,12 @@ def initialize(agent, server_host, nio_selector, check_certificate=true) @nio_monitor = @nio_selector.register(@tcp_socket, :r) @nio_monitor.value = self + # Ask the server whether a replacement certificate is waiting for us. The + # server decides and answers with COMMAND_RENEW_RESPONSE; it never sends one + # unsolicited. This has to come after the monitor exists because send_packet + # arms it for writing. + request_certificate_renewal + @agent.logger.info "Successfully connected to server" rescue => e @agent.logger.info "Something went wrong connecting to server." @@ -111,6 +128,9 @@ def rx_data # This is a shutdown request. Disconnect and don't re-attempt connection. @agent.logger.warn "Server requested reconnect. Closing connection." close + when COMMAND_RENEW_RESPONSE + # The server has answered our renewal request. + handle_renewal_response(packet[1..-1]) end end rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::ENETRESET @@ -183,6 +203,48 @@ def close raise ServerDisconnected end + # Ask the server to re-issue our client certificate, telling it which agent + # implementation and version is asking. Renewal is best effort: it must never + # stop us connecting, so a failure here is logged and otherwise ignored. + def request_certificate_renewal + @agent.logger.debug "Requesting certificate renewal" + send_packet([COMMAND_RENEW_REQUEST, "ruby/#{DeployAgent::VERSION}"].pack('Ca*')) + rescue => e + @agent.logger.warn "Could not request certificate renewal: #{e.message}" + end + + # Process a COMMAND_RENEW_RESPONSE. Renewal must never take the agent down, so + # anything unexpected is logged and the existing certificate is kept. + def handle_renewal_response(body) + body = body.to_s + status = body.bytes[0] + payload = body[1..-1].to_s + + case status + when RENEW_STATUS_RENEWED + certificate = CertificateRenewal.new.install(payload) + if certificate + @agent.logger.info "Certificate renewed (issuer=#{certificate.issuer})" + # Reconnect so the new certificate is the one we present. close raises + # ServerDisconnected, which Agent#run catches and retries, and the new + # connection re-reads agent.crt from disk. + close + else + @agent.logger.debug "Server offered the certificate we already hold" + end + when RENEW_STATUS_CURRENT + @agent.logger.debug "Certificate is up to date" + when RENEW_STATUS_ERROR + @agent.logger.warn "Server could not renew our certificate: #{payload}" + else + @agent.logger.warn "Unknown certificate renewal status: #{status.inspect}" + end + rescue ServerDisconnected + raise + rescue => e + @agent.logger.warn "Certificate renewal failed: #{e.message}" + end + # Queue a packet of data to be sent to the Deploy server def send_packet(data) @tx_buffer << [data.bytesize+2, data].pack('na*') From 24aaab89cc4f2cec260f8b6a64bb337a2fc1592b Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 16:14:45 +0200 Subject: [PATCH 3/9] test: cover certificate renewal, the wire protocol and the CA bundle CertificateRenewal is driven against CAs generated into a tmpdir, so the happy path is a real re-signature: same subject, same serial, same public key, new issuer. Each rejection - wrong key, wrong serial, wrong subject, untrusted issuer, unparseable PEM, empty and nil payloads - asserts the existing agent.crt is still byte-identical afterwards and that no temp file was left behind. ServerConnection is exercised through the real rx_data dispatch loop with a pre-filled receive buffer, so no socket, handshake or Deploy server is needed. Statuses 0, 1 and 2 are covered, along with a rejected certificate, an unexpected error inside the handler, an unknown status byte and an unknown command byte. Everything except a successful renewal must leave the connection up, and a trailing benign frame proves the stream stayed in sync. The CA bundle spec reads ca.crt the way ca_file does - every certificate in the file, order irrelevant - asserts at least one is present and that each is a self-signed root, and prints the subjects and expiry dates so a bundle change is visible in the test output. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- spec/ca_bundle_spec.rb | 45 ++++ spec/deploy_agent/certificate_renewal_spec.rb | 227 +++++++++++++++++ spec/deploy_agent/server_connection_spec.rb | 232 ++++++++++++++++++ 3 files changed, 504 insertions(+) create mode 100644 spec/ca_bundle_spec.rb create mode 100644 spec/deploy_agent/certificate_renewal_spec.rb create mode 100644 spec/deploy_agent/server_connection_spec.rb diff --git a/spec/ca_bundle_spec.rb b/spec/ca_bundle_spec.rb new file mode 100644 index 0000000..665d0dd --- /dev/null +++ b/spec/ca_bundle_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'openssl' + +# ca.crt is the trust anchor the agent verifies the Deploy server against. It is +# loaded with SSLContext#ca_file, which reads *every* certificate in the file, so +# it may hold more than one during a CA rotation - an old root and its +# replacement - and the order does not matter. Nothing in this gem ever calls +# OpenSSL::X509::Certificate.new on it (that would silently read only the first +# certificate), and nothing should start. +RSpec.describe 'the bundled CA file' do + let(:pem) { File.read(DeployAgent::CA_PATH) } + + let(:certificates) do + pem.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m) + .map { |block| OpenSSL::X509::Certificate.new(block) } + end + + it 'ships at least one certificate' do + expect(certificates.length).to be >= 1 + end + + it 'loads as a trust bundle' do + store = OpenSSL::X509::Store.new + expect { store.add_file(DeployAgent::CA_PATH) }.not_to raise_error + end + + it 'contains only self-signed roots' do + certificates.each do |certificate| + expect(certificate.verify(certificate.public_key)).to be(true), "#{certificate.subject} is not self-signed" + end + end + + it 'reports what is bundled' do + certificates.each_with_index do |certificate, index| + puts format(' ca.crt[%d] subject=%s expires=%s', + index: index, + subject: certificate.subject.to_s, + expiry: certificate.not_after.utc.strftime('%Y-%m-%d %H:%M:%S UTC')) + end + + expect(certificates).to all(be_a(OpenSSL::X509::Certificate)) + end +end diff --git a/spec/deploy_agent/certificate_renewal_spec.rb b/spec/deploy_agent/certificate_renewal_spec.rb new file mode 100644 index 0000000..2331690 --- /dev/null +++ b/spec/deploy_agent/certificate_renewal_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'openssl' +require 'tmpdir' + +# RSA key generation is slow and none of these fixtures depend on per-example +# state, so they are built once for the whole file. +module CertificateRenewalFixtures + TEN_YEARS = 10 * 365 * 86_400 + KEY_BITS = 2048 # production uses 4096; key size is irrelevant to what is under test + + module_function + + def key(name) + cache[:"key_#{name}"] ||= OpenSSL::PKey::RSA.new(KEY_BITS) + end + + # Mirrors deployhq's lib/certificate_authority.rb. + def authority(name) + cache[:"ca_#{name}"] ||= begin + subject = OpenSSL::X509::Name.new([['CN', "Deploy Test CA (#{name})"]]) + certificate = OpenSSL::X509::Certificate.new + certificate.not_before = Time.now - 60 + certificate.not_after = Time.now + TEN_YEARS + certificate.serial = 1 + certificate.version = 2 + certificate.subject = subject + certificate.issuer = subject + certificate.public_key = key("ca_#{name}").public_key + factory = OpenSSL::X509::ExtensionFactory.new + factory.subject_certificate = certificate + factory.issuer_certificate = certificate + certificate.add_extension(factory.create_extension('basicConstraints', 'CA:TRUE', true)) + certificate.add_extension(factory.create_extension('keyUsage', 'cRLSign,keyCertSign', true)) + certificate.sign(key("ca_#{name}"), OpenSSL::Digest.new('SHA256')) + certificate + end + end + + # Mirrors deployhq's Agent#generate_crypto: no extensions, serial = agent id. + def agent_certificate(authority_name, public_key, serial: 42, common_name: 'Deploy Agent #42') + certificate = OpenSSL::X509::Certificate.new + certificate.not_before = Time.now - 60 + certificate.not_after = Time.now + TEN_YEARS + certificate.subject = OpenSSL::X509::Name.new([['CN', common_name]]) + certificate.serial = serial + certificate.version = 2 + certificate.public_key = public_key + certificate.issuer = authority(authority_name).subject + certificate.sign(key("ca_#{authority_name}"), OpenSSL::Digest.new('SHA256')) + certificate + end + + def cache + @cache ||= {} + end +end + +RSpec.describe DeployAgent::CertificateRenewal do + fixtures = CertificateRenewalFixtures + + # The agent's own key pair, which a renewal must never change. + let(:agent_key) { fixtures.key('agent') } + + # What the agent holds today, and what the backend would send back after + # re-signing the same public key under the new CA. + let(:current_certificate) { fixtures.agent_certificate('old', agent_key.public_key) } + let(:renewed_certificate) { fixtures.agent_certificate('new', agent_key.public_key) } + + let(:config_dir) { Dir.mktmpdir('deploy-agent-renewal') } + let(:certificate_path) { File.join(config_dir, 'agent.crt') } + let(:key_path) { File.join(config_dir, 'agent.key') } + let(:ca_path) { File.join(config_dir, 'ca.crt') } + + let(:renewal) do + described_class.new(certificate_path: certificate_path, key_path: key_path, ca_path: ca_path) + end + + before do + File.write(certificate_path, current_certificate.to_pem) + File.write(key_path, agent_key.to_pem) + # The shipped bundle during the migration window: both roots are trusted. + File.write(ca_path, fixtures.authority('old').to_pem + fixtures.authority('new').to_pem) + end + + after do + FileUtils.remove_entry(config_dir) + end + + # Swallow the rejection so the example can assert on the side effects instead. + def attempt(renewal, pem) + renewal.install(pem) + rescue DeployAgent::CertificateRenewal::InvalidCertificate + nil + end + + describe '#install' do + context 'with a certificate re-signed under the new CA' do + it 'returns the installed certificate' do + expect(renewal.install(renewed_certificate.to_pem).to_der).to eq(renewed_certificate.to_der) + end + + it 'writes it to the certificate path' do + renewal.install(renewed_certificate.to_pem) + + installed = OpenSSL::X509::Certificate.new(File.read(certificate_path)) + expect(installed.to_der).to eq(renewed_certificate.to_der) + end + + it 'preserves the identity the backend keys off' do + renewal.install(renewed_certificate.to_pem) + + installed = OpenSSL::X509::Certificate.new(File.read(certificate_path)) + expect(installed.serial).to eq(current_certificate.serial) + expect(installed.subject.to_s).to eq(current_certificate.subject.to_s) + end + + it 'installs a certificate issued by the new CA' do + renewal.install(renewed_certificate.to_pem) + + installed = OpenSSL::X509::Certificate.new(File.read(certificate_path)) + expect(installed.issuer.to_s).to eq(fixtures.authority('new').subject.to_s) + expect(installed.issuer.to_s).not_to eq(current_certificate.issuer.to_s) + end + + it 'still pairs with the untouched agent private key' do + key_before = File.binread(key_path) + renewal.install(renewed_certificate.to_pem) + + installed = OpenSSL::X509::Certificate.new(File.read(certificate_path)) + expect(installed.check_private_key(agent_key)).to be(true) + expect(File.binread(key_path)).to eq(key_before) + end + + it 'writes a file that is not readable by group or other' do + renewal.install(renewed_certificate.to_pem) + + expect(File.stat(certificate_path).mode & 0o077).to eq(0) + end + + it 'leaves no temporary files behind' do + renewal.install(renewed_certificate.to_pem) + + expect(Dir.children(config_dir).sort).to eq(['agent.crt', 'agent.key', 'ca.crt']) + end + end + + context 'when the server offers the certificate already installed' do + it 'returns nil' do + expect(renewal.install(current_certificate.to_pem)).to be_nil + end + + it 'does not rewrite the file' do + before_bytes = File.binread(certificate_path) + renewal.install(current_certificate.to_pem) + + expect(File.binread(certificate_path)).to eq(before_bytes) + end + end + + shared_examples 'a rejected renewal' do |message| + it 'raises InvalidCertificate' do + expect { renewal.install(offered_pem) } + .to raise_error(DeployAgent::CertificateRenewal::InvalidCertificate, message) + end + + it 'leaves the existing certificate byte-identical' do + before_bytes = File.binread(certificate_path) + attempt(renewal, offered_pem) + + expect(File.binread(certificate_path)).to eq(before_bytes) + end + + it 'leaves no temporary files behind' do + attempt(renewal, offered_pem) + + expect(Dir.children(config_dir).sort).to eq(['agent.crt', 'agent.key', 'ca.crt']) + end + end + + context 'when the certificate does not match the agent private key' do + let(:offered_pem) { fixtures.agent_certificate('new', fixtures.key('impostor').public_key).to_pem } + + include_examples 'a rejected renewal', /does not match the agent private key/ + end + + context 'when the serial has changed' do + let(:offered_pem) { fixtures.agent_certificate('new', agent_key.public_key, serial: 99).to_pem } + + include_examples 'a rejected renewal', /different serial/ + end + + context 'when the subject has changed' do + let(:offered_pem) do + fixtures.agent_certificate('new', agent_key.public_key, common_name: 'Deploy Agent #99').to_pem + end + + include_examples 'a rejected renewal', /different subject/ + end + + context 'when the issuer is not in the bundled CA file' do + let(:offered_pem) { fixtures.agent_certificate('rogue', agent_key.public_key).to_pem } + + include_examples 'a rejected renewal', /does not chain to a trusted CA/ + end + + context 'when the payload is not a certificate' do + let(:offered_pem) { "-----BEGIN CERTIFICATE-----\nnot base64 at all\n-----END CERTIFICATE-----\n" } + + include_examples 'a rejected renewal', /could not parse the offered certificate/ + end + + context 'when the payload is empty' do + let(:offered_pem) { '' } + + include_examples 'a rejected renewal', /contained no certificate/ + end + + context 'when the payload is nil' do + let(:offered_pem) { nil } + + include_examples 'a rejected renewal', /contained no certificate/ + end + end +end diff --git a/spec/deploy_agent/server_connection_spec.rb b/spec/deploy_agent/server_connection_spec.rb new file mode 100644 index 0000000..c6801cb --- /dev/null +++ b/spec/deploy_agent/server_connection_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Exercises the real rx_data packet-dispatch loop with a pre-filled receive +# buffer, so the renewal command can be driven without a socket, a TLS handshake +# or a Deploy server. The connection is allocated rather than constructed for the +# same reason: #initialize opens a TCP socket. +RSpec.describe DeployAgent::ServerConnection do + let(:logger) { instance_double(Logger, info: nil, warn: nil, error: nil, debug: nil) } + let(:agent) { instance_double(DeployAgent::Agent, logger: logger) } + let(:nio_selector) { double('nio_selector', deregister: nil) } + let(:nio_monitor) { double('nio_monitor') } + let(:tcp_socket) { double('tcp_socket', close: nil) } + + let(:socket) do + socket = double('socket', close: nil) + allow(socket).to receive(:read_nonblock) do + error = StandardError.new('would block') + error.extend(IO::WaitReadable) + raise error + end + socket + end + + let(:connection) do + connection = described_class.allocate + connection.instance_variable_set(:@agent, agent) + connection.instance_variable_set(:@destination_connections, {}) + connection.instance_variable_set(:@nio_selector, nio_selector) + connection.instance_variable_set(:@nio_monitor, nio_monitor) + connection.instance_variable_set(:@socket, socket) + connection.instance_variable_set(:@tcp_socket, tcp_socket) + connection.instance_variable_set(:@tx_buffer, String.new.force_encoding('BINARY')) + connection.instance_variable_set(:@rx_buffer, String.new.force_encoding('BINARY')) + connection + end + + let(:renewal) { instance_double(DeployAgent::CertificateRenewal) } + + let(:certificate_pem) { "-----BEGIN CERTIFICATE-----\nrenewed\n-----END CERTIFICATE-----\n" } + + before do + allow(nio_monitor).to receive(:interests=) + allow(DeployAgent::CertificateRenewal).to receive(:new).and_return(renewal) + end + + # Wire frame: [total length including these two bytes][payload]. Mirrors send_packet. + def frame(payload) + [payload.bytesize + 2, payload].pack('na*') + end + + def renewal_response(status, body = '') + frame([described_class::COMMAND_RENEW_RESPONSE, status, body].pack('CCa*')) + end + + # Command 3 for an id that was never opened: logs and no-ops. Used as a trailing + # frame to prove the preceding one left the stream in sync. + def benign_frame + frame([3, 999].pack('Cn')) + end + + def benign_frame_processed? + begin + expect(logger).to have_received(:info).with('[999] Close requested by server, not open') + rescue RSpec::Expectations::ExpectationNotMetError + return false + end + true + end + + def deliver(*frames) + connection.instance_variable_set(:@rx_buffer, frames.join.force_encoding('BINARY')) + connection.rx_data + end + + describe 'protocol constants' do + it 'matches the contract shared with the backend and the Go agent' do + expect(described_class::COMMAND_RENEW_REQUEST).to eq(8) + expect(described_class::COMMAND_RENEW_RESPONSE).to eq(9) + expect(described_class::RENEW_STATUS_RENEWED).to eq(0) + expect(described_class::RENEW_STATUS_CURRENT).to eq(1) + expect(described_class::RENEW_STATUS_ERROR).to eq(2) + end + end + + describe '#request_certificate_renewal' do + it 'queues command 8 carrying the agent implementation and version' do + connection.send(:request_certificate_renewal) + + expect(connection.instance_variable_get(:@tx_buffer)) + .to eq(frame([8, "ruby/#{DeployAgent::VERSION}"].pack('Ca*'))) + end + + it 'arms the monitor for writing' do + connection.send(:request_certificate_renewal) + + expect(nio_monitor).to have_received(:interests=).with(:rw) + end + + it 'logs and continues when the request cannot be queued' do + connection.instance_variable_set(:@nio_monitor, nil) + + expect { connection.send(:request_certificate_renewal) }.not_to raise_error + expect(logger).to have_received(:warn).with(/Could not request certificate renewal/) + end + end + + describe '#rx_data' do + context 'with a renewal response of status 0 (renewed)' do + let(:certificate) { double('certificate', issuer: 'CN=Deploy CA (new)') } + + before do + allow(renewal).to receive(:install).and_return(certificate) + end + + it 'installs the certificate it was sent' do + begin + deliver(renewal_response(0, certificate_pem)) + rescue described_class::ServerDisconnected + nil + end + + expect(renewal).to have_received(:install).with(certificate_pem) + end + + it 'disconnects so the new certificate is presented on reconnect' do + expect { deliver(renewal_response(0, certificate_pem)) } + .to raise_error(described_class::ServerDisconnected) + end + + it 'logs the new issuer' do + begin + deliver(renewal_response(0, certificate_pem)) + rescue described_class::ServerDisconnected + nil + end + + expect(logger).to have_received(:info).with('Certificate renewed (issuer=CN=Deploy CA (new))') + end + end + + context 'with a renewal response of status 0 offering the certificate already held' do + before do + allow(renewal).to receive(:install).and_return(nil) + end + + it 'stays connected' do + expect { deliver(renewal_response(0, certificate_pem)) }.not_to raise_error + end + + it 'keeps processing the stream' do + deliver(renewal_response(0, certificate_pem), benign_frame) + + expect(benign_frame_processed?).to be(true) + end + end + + context 'with a renewal response of status 1 (current)' do + it 'does not attempt an install' do + deliver(renewal_response(1)) + + expect(DeployAgent::CertificateRenewal).not_to have_received(:new) + end + + it 'stays connected and keeps processing the stream' do + expect { deliver(renewal_response(1), benign_frame) }.not_to raise_error + expect(benign_frame_processed?).to be(true) + end + end + + context 'with a renewal response of status 2 (error)' do + it 'logs the server message without attempting an install' do + deliver(renewal_response(2, 'no replacement available')) + + expect(logger).to have_received(:warn).with('Server could not renew our certificate: no replacement available') + expect(DeployAgent::CertificateRenewal).not_to have_received(:new) + end + + it 'stays connected and keeps processing the stream' do + expect { deliver(renewal_response(2, 'boom'), benign_frame) }.not_to raise_error + expect(benign_frame_processed?).to be(true) + end + end + + context 'with a renewal response carrying an unknown status' do + it 'logs and stays connected' do + expect { deliver(renewal_response(200), benign_frame) }.not_to raise_error + expect(logger).to have_received(:warn).with(/Unknown certificate renewal status/) + expect(benign_frame_processed?).to be(true) + end + end + + context 'when the offered certificate is rejected' do + before do + allow(renewal).to receive(:install) + .and_raise(DeployAgent::CertificateRenewal::InvalidCertificate, 'offered certificate has a different serial') + end + + it 'keeps the working certificate and stays connected' do + expect { deliver(renewal_response(0, certificate_pem)) }.not_to raise_error + expect(logger) + .to have_received(:warn).with('Certificate renewal failed: offered certificate has a different serial') + end + + it 'keeps processing the stream' do + deliver(renewal_response(0, certificate_pem), benign_frame) + + expect(benign_frame_processed?).to be(true) + end + end + + context 'when the renewal handler hits an unexpected error' do + before do + allow(renewal).to receive(:install).and_raise(Errno::EACCES, 'agent.crt') + end + + it 'never lets it escape rx_data' do + expect { deliver(renewal_response(0, certificate_pem), benign_frame) }.not_to raise_error + expect(logger).to have_received(:warn).with(/Certificate renewal failed/) + expect(benign_frame_processed?).to be(true) + end + end + + context 'with a command this version does not know' do + it 'ignores it without desynchronising the stream' do + expect { deliver(frame([200].pack('C')), benign_frame) }.not_to raise_error + expect(benign_frame_processed?).to be(true) + end + end + end +end From 6821912c0a7d49367ce9d2a9db5bd12e222f6297 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 16:15:10 +0200 Subject: [PATCH 4/9] ci: prove the gem installs on Ruby 2.7 `bundle exec rspec` resolves dependencies from the checkout, never the way a customer's `gem install` does, so CI stayed green for months while the gem was uninstallable on every Ruby below 3.1. Build it and install it for real on the oldest Ruby the gemspec claims to support, then run the executable it puts on PATH. Gated on for release: a gem nobody can install should not be published. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- .github/workflows/ci.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 626916b..7c3f465 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,26 @@ jobs: - name: Run tests run: bundle exec rspec + # `bundle exec rspec` runs against the checkout and so never resolves the + # gemspec's dependencies the way a customer does. That gap let timers 4.4.0 + # (required_ruby_version >= 3.1) satisfy a '~> 4.3' constraint and break + # `gem install deploy-agent` outright on every Ruby below 3.1, with a green + # CI throughout. This job closes it: build the gem and install it for real on + # the oldest Ruby the gemspec claims to support. + gem-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7' + - name: Build and install the gem + run: | + gem build deploy-agent.gemspec + gem install --no-document ./deploy-agent-*.gem + - name: Run the installed executable + run: deploy-agent version + release-please: runs-on: ubuntu-latest if: github.ref == 'refs/heads/master' @@ -46,7 +66,7 @@ jobs: release: runs-on: ubuntu-latest - needs: [lint, test, release-please] + needs: [lint, test, gem-install, release-please] if: ${{ needs.release-please.outputs.release_created }} steps: - uses: actions/checkout@v4 From 34ccce986bf670944e5aea2c943cb61c5770d20d Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 16:15:50 +0200 Subject: [PATCH 5/9] docs: document certificate renewal and how to upgrade Adds an Upgrading section - `gem install` plus `deploy-agent restart`, with the reason `gem update` is the wrong command here - and a customer-facing note on what in-band certificate renewal does, that it needs no action, that it can never leave the agent with a broken certificate, and what the 2027 deadline means for anyone who does not upgrade. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 9a343dd..83eebce 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,18 @@ The agent connects **outbound** to DeployHQ, so no inbound firewall rules are ne gem install deploy-agent ``` +## Upgrading + +```bash +gem install deploy-agent +deploy-agent restart +``` + +Use `gem install`, not `gem update`. On Ruby versions below 3.1 a released gem +could not have its dependencies resolved, and `gem update deploy-agent` responds +to that by reporting `Gems already up-to-date` and installing nothing. `gem +install` resolves from scratch and picks up the fix. + ## Quick Start ### 1. Configure the agent @@ -114,6 +126,25 @@ To allow the agent to connect to additional servers, edit `~/.deploy/agent.acces Lines starting with `#` are comments. Each entry can be an individual IP address or a CIDR network range. +## Certificate renewal + +DeployHQ is rotating the certificate authority behind the agent connection. Each +time the agent connects it asks whether a replacement client certificate is +waiting for it, and installs one if DeployHQ offers it. + +This is automatic and needs no action from you. The agent keeps its identity — +same name, same configured servers, nothing to re-claim — and simply reconnects +once using the new certificate. + +A replacement is only written after it has been checked against the agent's +existing private key and the certificate authorities the agent ships with. If +any check fails, the agent logs a warning, keeps the certificate it already has +and carries on. Run `deploy-agent run -v` to watch this happen. + +What matters on your side is staying up to date: an agent still presenting a +certificate issued by the old authority after **17 March 2027** will not be able +to connect. See [Upgrading](#upgrading). + ## Troubleshooting **Agent won't start — "not configured"** From 7d7a9a2b633d279b53639a52e95a8ee61f42fba7 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 17:40:52 +0200 Subject: [PATCH 6/9] fix: verify renewed certificates for TLS client authentication The trust store was built with its default purpose, which accepts any certificate that merely chains to a trusted root. A replacement that paired with the agent key, kept subject and serial and chained to a bundled CA but carried an extendedKeyUsage or keyUsage ruling out client auth would pass every check, get written over agent.crt, and then be refused by the server on every reconnect - exactly the unrecoverable state the validation exists to prevent. Pin the store to PURPOSE_SSL_CLIENT, which is what the agent actually presents the certificate as. Measured on Ruby 2.7.8 / OpenSSL 1.1.1t, same CA and key pair throughout, only the leaf's extensions varying: default PURPOSE_SSL_CLIENT no extensions at all accepted accepted extendedKeyUsage=clientAuth accepted accepted extendedKeyUsage=serverAuth (crit) accepted rejected keyUsage=keyEncipherment (crit) accepted rejected Real agent certificates carry no extensions at all, so the purpose does not narrow what we accept today - the first row is the one that matters, and it has its own spec so nobody tightens this into a regression later. The error message now covers both ways the store can refuse (untrusted issuer, unusable purpose) and carries the store's own reason. Reported by Codex on #24. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- lib/deploy_agent/certificate_renewal.rb | 19 +++++-- spec/deploy_agent/certificate_renewal_spec.rb | 53 ++++++++++++++++++- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/lib/deploy_agent/certificate_renewal.rb b/lib/deploy_agent/certificate_renewal.rb index 16b8fce..4eb6504 100644 --- a/lib/deploy_agent/certificate_renewal.rb +++ b/lib/deploy_agent/certificate_renewal.rb @@ -71,12 +71,13 @@ def validate!(new_certificate) "offered certificate has a different serial (#{current_certificate.serial} -> #{new_certificate.serial})" end - # And it has to chain to a CA we ship, or we would be trading a working - # certificate for one the server will refuse on the next handshake. + # And it has to chain to a CA we ship *as a TLS client certificate*, or we + # would be trading a working certificate for one the server refuses on the + # next handshake - and every handshake after it. store = certificate_store return if store.verify(new_certificate) - raise InvalidCertificate, "offered certificate does not chain to a trusted CA (#{store.error_string})" + raise InvalidCertificate, "offered certificate is not a usable client certificate (#{store.error_string})" end # Write to a private temporary file in the same directory, flush it all the @@ -116,7 +117,17 @@ def private_key end def certificate_store - @certificate_store ||= OpenSSL::X509::Store.new.tap { |store| store.add_file(@ca_path) } + @certificate_store ||= OpenSSL::X509::Store.new.tap do |store| + store.add_file(@ca_path) + # The agent presents this certificate for TLS client authentication, so + # verify it for that purpose rather than the store's default, which + # accepts anything that merely chains. Real agent certificates carry no + # extensions at all and this purpose accepts them; what it rejects is a + # certificate whose keyUsage or extendedKeyUsage rules client auth out + # (a serverAuth-only certificate, say), which would otherwise install + # cleanly and then be refused by the server on every reconnect. + store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT + end rescue SystemCallError, OpenSSL::OpenSSLError => e raise InvalidCertificate, "could not read the CA bundle: #{e.message}" end diff --git a/spec/deploy_agent/certificate_renewal_spec.rb b/spec/deploy_agent/certificate_renewal_spec.rb index 2331690..c3a0695 100644 --- a/spec/deploy_agent/certificate_renewal_spec.rb +++ b/spec/deploy_agent/certificate_renewal_spec.rb @@ -40,7 +40,8 @@ def authority(name) end # Mirrors deployhq's Agent#generate_crypto: no extensions, serial = agent id. - def agent_certificate(authority_name, public_key, serial: 42, common_name: 'Deploy Agent #42') + # +extensions+ is only used to build the certificates a renewal must refuse. + def agent_certificate(authority_name, public_key, serial: 42, common_name: 'Deploy Agent #42', extensions: []) certificate = OpenSSL::X509::Certificate.new certificate.not_before = Time.now - 60 certificate.not_after = Time.now + TEN_YEARS @@ -49,10 +50,22 @@ def agent_certificate(authority_name, public_key, serial: 42, common_name: 'Depl certificate.version = 2 certificate.public_key = public_key certificate.issuer = authority(authority_name).subject + add_extensions(certificate, authority_name, extensions) certificate.sign(key("ca_#{authority_name}"), OpenSSL::Digest.new('SHA256')) certificate end + def add_extensions(certificate, authority_name, extensions) + return if extensions.empty? + + factory = OpenSSL::X509::ExtensionFactory.new + factory.subject_certificate = certificate + factory.issuer_certificate = authority(authority_name) + extensions.each do |oid, value, critical| + certificate.add_extension(factory.create_extension(oid, value, critical)) + end + end + def cache @cache ||= {} end @@ -102,6 +115,20 @@ def attempt(renewal, pem) expect(renewal.install(renewed_certificate.to_pem).to_der).to eq(renewed_certificate.to_der) end + # Real agent certificates carry no extensions at all, so the client-auth + # purpose the trust store is pinned to must not reject them. + it 'accepts a certificate with no extensions, which is what the backend issues' do + expect(renewed_certificate.extensions).to be_empty + expect(renewal.install(renewed_certificate.to_pem)).not_to be_nil + end + + it 'accepts a certificate that explicitly allows client authentication' do + offered = fixtures.agent_certificate('new', agent_key.public_key, + extensions: [['extendedKeyUsage', 'clientAuth', false]]) + + expect(renewal.install(offered.to_pem)).not_to be_nil + end + it 'writes it to the certificate path' do renewal.install(renewed_certificate.to_pem) @@ -203,7 +230,29 @@ def attempt(renewal, pem) context 'when the issuer is not in the bundled CA file' do let(:offered_pem) { fixtures.agent_certificate('rogue', agent_key.public_key).to_pem } - include_examples 'a rejected renewal', /does not chain to a trusted CA/ + include_examples 'a rejected renewal', /not a usable client certificate/ + end + + # These chain to the trusted new CA and keep subject, serial and public key, + # so they clear every other check. Only the trust store's client-auth purpose + # catches them - and if it did not, the agent would install a certificate the + # server then refuses on every single reconnect. + context 'when the extended key usage rules out client authentication' do + let(:offered_pem) do + fixtures.agent_certificate('new', agent_key.public_key, + extensions: [['extendedKeyUsage', 'serverAuth', true]]).to_pem + end + + include_examples 'a rejected renewal', /not a usable client certificate/ + end + + context 'when the key usage rules out client authentication' do + let(:offered_pem) do + fixtures.agent_certificate('new', agent_key.public_key, + extensions: [['keyUsage', 'keyEncipherment', true]]).to_pem + end + + include_examples 'a rejected renewal', /not a usable client certificate/ end context 'when the payload is not a certificate' do From d2300cc8f5c5b818ed5e08a1337bf369a1777493 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Mon, 14 Sep 2026 17:40:59 +0200 Subject: [PATCH 7/9] test: assert every bundled CA is a self-issued CA:TRUE root A valid self-signature only proves the certificate's key matches its own signature; it says nothing about whether the certificate is allowed to act as a trust anchor. Also assert that each certificate in ca.crt is issued to itself and carries basicConstraints CA:TRUE, so a bundle that gains a leaf or a cross-signed intermediate during the CA rotation fails here rather than at an agent's next handshake. Reported by CodeRabbit on #24. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011XaiFEqsyQGcLhRmBtKRFA --- spec/ca_bundle_spec.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/spec/ca_bundle_spec.rb b/spec/ca_bundle_spec.rb index 665d0dd..30fe543 100644 --- a/spec/ca_bundle_spec.rb +++ b/spec/ca_bundle_spec.rb @@ -26,9 +26,19 @@ expect { store.add_file(DeployAgent::CA_PATH) }.not_to raise_error end - it 'contains only self-signed roots' do + # A valid self-signature proves the key matches, but says nothing about what + # the certificate is allowed to be. A trust anchor here must also be issued to + # itself and actually assert CA:TRUE, or it cannot sign the agent and server + # certificates this file exists to verify. + it 'contains only self-signed certificate authorities' do certificates.each do |certificate| - expect(certificate.verify(certificate.public_key)).to be(true), "#{certificate.subject} is not self-signed" + subject = certificate.subject.to_s + basic_constraints = certificate.extensions.find { |extension| extension.oid == 'basicConstraints' }&.value + + expect(certificate.issuer.to_s).to eq(subject), "#{subject} is not issued to itself" + expect(certificate.verify(certificate.public_key)).to be(true), "#{subject} is not self-signed" + expect(basic_constraints).to include('CA:TRUE'), + "#{subject} is not marked as a CA (basicConstraints=#{basic_constraints.inspect})" end end From 1ec521d921c6f2ea4e70d49c2f96888abe69f1ac Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Tue, 15 Sep 2026 11:23:07 +0200 Subject: [PATCH 8/9] docs: say the deprecated gem still receives essential fixes The CLI banner, README and post-install message all said deploy-agent "will not receive further updates" -- in the release that exists to ship one. Keep the deprecation and the pointer to network-agent; drop the promise this release breaks. Also replace "run `deploy-agent run -v` to watch this happen": a successful renewal is logged at the default level, so -v is not needed, and the README now says where to look. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +++-- deploy-agent.gemspec | 2 +- lib/deploy_agent/cli.rb | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 83eebce..14b9526 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Deploy Agent -> **DEPRECATED:** This gem is deprecated and will not receive further updates. +> **DEPRECATED:** This gem is deprecated and only receives essential fixes. > Please migrate to the new [network-agent](https://github.com/deployhq/network-agent) instead, > which has fewer dependencies and is easier to install. > @@ -139,7 +139,8 @@ once using the new certificate. A replacement is only written after it has been checked against the agent's existing private key and the certificate authorities the agent ships with. If any check fails, the agent logs a warning, keeps the certificate it already has -and carries on. Run `deploy-agent run -v` to watch this happen. +and carries on. A successful renewal is logged as `Certificate renewed` at the +default log level, in `~/.deploy/agent.log` when the agent runs in the background. What matters on your side is staying up to date: an agent still presenting a certificate issued by the old authority after **17 March 2027** will not be able diff --git a/deploy-agent.gemspec b/deploy-agent.gemspec index de43339..0007d26 100644 --- a/deploy-agent.gemspec +++ b/deploy-agent.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |s| s.add_dependency 'timers', '>= 4.3', '< 4.4' s.post_install_message = <<~MSG - WARNING: deploy-agent is deprecated and will not receive further updates. + WARNING: deploy-agent is deprecated and only receives essential fixes. Please migrate to the new agent: https://github.com/deployhq/network-agent MSG end diff --git a/lib/deploy_agent/cli.rb b/lib/deploy_agent/cli.rb index 36e7f92..94c494f 100644 --- a/lib/deploy_agent/cli.rb +++ b/lib/deploy_agent/cli.rb @@ -6,7 +6,7 @@ class CLI DEPRECATION_NOTICE = <<~MSG \e[33m╔══════════════════════════════════════════════════════════════════╗ - ║ DEPRECATED: deploy-agent will not receive further updates. ║ + ║ DEPRECATED: deploy-agent only receives essential fixes. ║ ║ Please migrate to the new agent: ║ ║ ║ ║ https://github.com/deployhq/network-agent ║ From d36bd01326ba2ceddb6b57d05fc0749b8b4a8a0c Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Tue, 15 Sep 2026 18:09:22 +0200 Subject: [PATCH 9/9] feat: trust the new DeployHQ Agent CA alongside the current one Appends the public certificate of the CA that agent certificates are issued under from the rotation onwards: CN=DeployHQ Agent CA, O=DeployHQ, valid until 2036-09-15 SHA256 4D:96:83:F0:CA:37:C8:4F:4A:52:E5:0E:E9:4E:61:5F:3B:CD:37:3A:29:F1:0B:5E:87:BE:C5:87:26:57:C7:6C The current CA stays first and unchanged. With both in the bundle this build accepts a renewed certificate and trusts the agent server whichever of the two signs its certificate. Co-Authored-By: Claude Opus 5 (1M context) --- ca.crt | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ca.crt b/ca.crt index 9233736..17c3696 100644 --- a/ca.crt +++ b/ca.crt @@ -31,3 +31,33 @@ l4CPcGbB0L8yyIhGwiEfrZpjx6hOelX1daG8QPTvSSYpB6ODtQeb3zpDf8vU8M7T oAwG8/0g1Owh/a970vIKu4TBa4D2IiCfA3KPWlsIUSoeu9uBTKmUQ0Raa0AhZWPv JI4XgcL63KznYzLm0BOxvTYMxDfn7fs= -----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFGzCCAwOgAwIBAgIBATANBgkqhkiG9w0BAQsFADAvMRowGAYDVQQDDBFEZXBs +b3lIUSBBZ2VudCBDQTERMA8GA1UECgwIRGVwbG95SFEwHhcNMjYwOTE1MDczODI4 +WhcNMzYwOTE1MDczODI4WjAvMRowGAYDVQQDDBFEZXBsb3lIUSBBZ2VudCBDQTER +MA8GA1UECgwIRGVwbG95SFEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC +AQC4bMAQgGZC7ZUO9Wq2+rWodOXL4IZ6Ae+Dfce+SlTsQUx5HrYrmMFTE9NJTxcE +dGYjAVVwpdvgT8flFKRiQa7j1xVOLr73ra7Uro0ZGHw1TOBFFAhXLZ25oXT8SsB7 +1aaRZksZAPGR1ZkWRvKqUkRge9LERtcmZDOSeKyf3i3PQp3AaODA4tRwsQnyKWoA +6h9Ruk9jnHqNRapiEjjdkubKKRjgU28IO0Abom5dRsDvvFYVquaQmud745kdaxDr +fQA9Kyz8lzNSglmTHACjzxtX1AGRpn5yLO7PqaV0M/QqnKZhaKW+tzP3pWdsDZ8v +RBGDhyVYeyHYSPAEOgsRdTqQYpnXkR1FLLS/FCFQ/UgyWFStFVv3l9mzq/fQLr/O +sdPzG9KNNTz4OqMrGOeRMcBXsKQJjIRS6TSzggPagcxT2iPRHCGQknko8/O3IyM1 +9YIsLm7kxw2oEIzhHx8Wv3s+SafJr4cuTu1n9RfyP3LmU/C6GbfrH/jDvVuLvFdH +9AY55RNCfCgCKWjqDeYrzGxIcuoZ57H6DJlGouw0MiSg4hUUqNnM1EQG410bJouA +itprB7FIGcZ9uHQvpEgs8NP/DTRsmUP+51Vdp6SLbFSNrt5v/APDrd3ya8R3f7g0 +XVLFl/jxNM4Ki+L6ZGwRz4SYThew0OZXsezbnYiGEro1JwIDAQABo0IwQDAdBgNV +HQ4EFgQUgnCdcTQCEbmADry95bq7UAoX71AwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAHaN63Zqs4W5FTTqQPYnkMH0 +/hMzRVVcQ8tZng6rGy4HdONixQ2XOwFOCMbl8uqRB/ihyLbwiLIVgsqG3wMEIiqV +lxNDYerUtdBolam/lqA371d18lEqKFiFfncCRrd+AzTMcYnYZZK026smx1JCxDCP +t1rB/T9Lv0g2aRyWTKsKBeI5GFOIEIHScP1k/R7549kNEwerFKX5USWszDfnouiV +hSz9UjlY1vQQ//qYgimevJxlGudt2b+73+BkT5THdNKBxeyn02/DbhaH/1zafE/7 +lxAKmN94HkAOM2TD27ZPRDIK4jmKK/Hx03X0kYD+KL/9Mr/4PIjlObRFF8BUawp1 +Kpn/Caw4gNz76IujGXGvJp9FxsZUscRRRdikIZ3AtR0/6Wyf8fFaAd89DoBGSzLM +mRynJbnK5ROlVzvxX43ow7sBLtoh1+/RCsWcCVgOC2EMCW2n4b5M0USv7FBrhPie +F50NkMqsTtEoxw8kcKvLd4FbPvWuIxPsXzxlnFaHTbVgTevgL6O8IVeWLqdqwFGK +owB7z1tEQTN6sV4+fDDtaVQFCalEV9QPAolVLYdBUApc866heDHmzW85tkq2zoDG +2cj80hCcI42DPvrb9nEcIcZiFZDCcIlDjRf6kDmqBW4oXr5V1yAxrC+i0UA2+LWj +NVgD1tDYCLJlNY3GA6j8 +-----END CERTIFICATE-----