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 diff --git a/README.md b/README.md index 9a343dd..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. > @@ -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,26 @@ 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. 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 +to connect. See [Upgrading](#upgrading). + ## Troubleshooting **Agent won't start — "not configured"** 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----- diff --git a/deploy-agent.gemspec b/deploy-agent.gemspec index e87e452..0007d26 100644 --- a/deploy-agent.gemspec +++ b/deploy-agent.gemspec @@ -20,10 +20,14 @@ 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. + 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.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..4eb6504 --- /dev/null +++ b/lib/deploy_agent/certificate_renewal.rb @@ -0,0 +1,136 @@ +# 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 *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 is not a usable client certificate (#{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 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 + + end +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 ║ 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*') diff --git a/spec/ca_bundle_spec.rb b/spec/ca_bundle_spec.rb new file mode 100644 index 0000000..30fe543 --- /dev/null +++ b/spec/ca_bundle_spec.rb @@ -0,0 +1,55 @@ +# 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 + + # 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| + 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 + + 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..c3a0695 --- /dev/null +++ b/spec/deploy_agent/certificate_renewal_spec.rb @@ -0,0 +1,276 @@ +# 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. + # +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 + 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 + 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 +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 + + # 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) + + 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', /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 + 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