From c8f7a7024445ea88e6784a3e6c006e35ad3781b1 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 26 Aug 2026 11:57:54 +0100 Subject: [PATCH 1/3] Support services behind a jump host --- CHANGELOG.md | 5 +++ lib/boring_services/cli.rb | 2 + lib/boring_services/configuration.rb | 57 ++++++++++++++++++++++++++++ lib/boring_services/ssh_executor.rb | 38 +++++++++++++++++-- test/configuration_test.rb | 11 ++++++ test/fixtures/test_config.yml | 6 ++- test/ssh_executor_test.rb | 39 +++++++++++++++++++ 7 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 test/ssh_executor_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac507c..70d3db8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Per-host jump routing, strict known-hosts configuration, and a targeted + `install --host LABEL` path for safely adding services to a new origin. + ## [0.8.1] - 2026-08-20 ### Changed diff --git a/lib/boring_services/cli.rb b/lib/boring_services/cli.rb index fa54186..ddfda73 100644 --- a/lib/boring_services/cli.rb +++ b/lib/boring_services/cli.rb @@ -25,8 +25,10 @@ def setup end desc 'install [SERVICE]', 'Install service(s) - all services or specific service' + option :host, type: :string, desc: 'Install only the host matching this label or address' def install(service_name = nil) config = Configuration.load(options[:config], options[:environment]) + config.only_host!(options[:host]) if options[:host] installer = Installer.new(config) service_name ? installer.install_service(service_name) : installer.install_all rescue Error => e diff --git a/lib/boring_services/configuration.rb b/lib/boring_services/configuration.rb index 1f4230d..0640d03 100644 --- a/lib/boring_services/configuration.rb +++ b/lib/boring_services/configuration.rb @@ -14,6 +14,7 @@ def initialize(config_path, environment) @config_path = config_path @environment = environment.to_s @config = load_config + @all_hosts = host_entries end def service_config(service_name) @@ -42,6 +43,52 @@ def ssh_key @config['ssh_key'] || '~/.ssh/id_rsa' end + def ssh_known_hosts_file + path = @config['ssh_known_hosts_file'] + File.expand_path(path) if path + end + + def verify_host_key_mode + case @config.fetch('verify_host_key', true) + when true, 'always', :always then :always + when 'accept_new', :accept_new then :accept_new + when false, 'never', :never then :never + else :always + end + end + + def host_config(reference) + reference = reference.to_s + @all_hosts.find do |host| + next unless host.is_a?(Hash) + + host['host'].to_s == reference || host['label'].to_s.casecmp?(reference) + end + end + + def jump_host_config(host) + return unless host.is_a?(Hash) + + reference = host['jump_host'] || host[:jump_host] + return unless reference + + host_config(reference) || raise(Error, "Jump host not found: #{reference}") + end + + def only_host!(reference) + selected = host_config(reference) + raise Error, "Host not found: #{reference}" unless selected + + @config['services'] = services.filter_map do |service| + hosts = Array(service['hosts'] || service['host']).compact + matches = hosts.select { |host| same_host?(host, selected) } + next if matches.empty? + + service.merge('hosts' => matches).tap { |entry| entry.delete('host') } + end + self + end + def forward_agent return @config['forward_agent'] unless @config['forward_agent'].nil? @@ -64,6 +111,16 @@ def secrets private + def host_entries + services.flat_map { |service| Array(service['hosts'] || service['host']).compact } + end + + def same_host?(candidate, selected) + return candidate.to_s == selected['host'].to_s unless candidate.is_a?(Hash) + + candidate['host'].to_s == selected['host'].to_s + end + def load_config raise Error, "Config file not found: #{@config_path}" unless File.exist?(@config_path) diff --git a/lib/boring_services/ssh_executor.rb b/lib/boring_services/ssh_executor.rb index 217e235..c207b4c 100644 --- a/lib/boring_services/ssh_executor.rb +++ b/lib/boring_services/ssh_executor.rb @@ -2,6 +2,8 @@ require 'sshkit' require 'sshkit/dsl' +require 'net/ssh/proxy/command' +require 'shellwords' module BoringServices class SSHExecutor @@ -174,8 +176,10 @@ def setup_sshkit forward_agent: config.forward_agent, auth_methods: config.ssh_auth_methods, keys_only: !config.use_ssh_agent, - use_agent: config.use_ssh_agent + use_agent: config.use_ssh_agent, + verify_host_key: config.verify_host_key_mode } + ssh.ssh_options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file end end @@ -185,19 +189,45 @@ def formatted_host(host) target_host = host['host'] || host[:host] raise Error, 'Host entry missing host field' unless target_host - build_sshkit_host(target_host, host['user'] || host[:user]) + build_sshkit_host(target_host, host['user'] || host[:user], host) else host_string = host.to_s return SSHKit::Host.new(host_string) if host_string.include?('@') - build_sshkit_host(host_string, nil) + build_sshkit_host(host_string, nil, nil) end end - def build_sshkit_host(hostname, user) + def build_sshkit_host(hostname, user, host_config) sshkit_host = SSHKit::Host.new(hostname) sshkit_host.user = user || config.user + if (jump_host = config.jump_host_config(host_config)) + sshkit_host.ssh_options = { proxy: jump_proxy(jump_host) } + end sshkit_host end + + def jump_proxy(jump_host) + user = jump_host['user'] || config.user + command = [ + 'ssh', '-F', '/dev/null', + '-o', 'BatchMode=yes', + '-o', 'ForwardAgent=no', + '-o', "StrictHostKeyChecking=#{open_ssh_host_key_mode}" + ] + command.push('-o', "UserKnownHostsFile=#{config.ssh_known_hosts_file}") if config.ssh_known_hosts_file + command.push('-i', File.expand_path(config.ssh_key), '-o', 'IdentitiesOnly=yes') + command.push('-W', '%h:%p', "#{user}@#{jump_host.fetch('host')}") + command_line = Shellwords.join(command).gsub('\\%h', '%h').gsub('\\%p', '%p') + Net::SSH::Proxy::Command.new(command_line) + end + + def open_ssh_host_key_mode + case config.verify_host_key_mode + when :accept_new then 'accept-new' + when :never then 'no' + else 'yes' + end + end end end diff --git a/test/configuration_test.rb b/test/configuration_test.rb index d34c7ec..dd528ae 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -74,6 +74,17 @@ def test_ssh_key_defaults assert_equal '~/.ssh/id_rsa', config.ssh_key end + def test_jump_host_resolves_and_host_filter_keeps_only_the_target + config = BoringServices::Configuration.new(fixture_path('test_config.yml'), 'test') + + target = config.host_config('target') + assert_equal '10.0.0.1', config.jump_host_config(target).fetch('host') + + config.only_host!('target') + assert_equal [target], config.service_config('redis').fetch('hosts') + assert_equal '10.0.0.1', config.jump_host_config(target).fetch('host') + end + def test_forward_agent config = BoringServices::Configuration.new(fixture_path('test_config.yml'), 'test') assert_equal false, config.forward_agent diff --git a/test/fixtures/test_config.yml b/test/fixtures/test_config.yml index b982fa4..056f57f 100644 --- a/test/fixtures/test_config.yml +++ b/test/fixtures/test_config.yml @@ -10,7 +10,11 @@ test: - name: redis enabled: true hosts: - - 10.0.0.1 + - host: 10.0.0.1 + label: jump + - host: 10.0.0.2 + label: target + jump_host: jump port: 6379 - name: memcached enabled: false diff --git a/test/ssh_executor_test.rb b/test/ssh_executor_test.rb new file mode 100644 index 0000000..17ec993 --- /dev/null +++ b/test/ssh_executor_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' +require 'tempfile' + +class SSHExecutorTest < Minitest::Test + def test_target_uses_the_configured_jump_host_and_known_hosts_file + Tempfile.create('known-hosts') do |known_hosts| + config_file = Tempfile.new(['services', '.yml']) + config_file.write <<~YAML + test: + user: ubuntu + ssh_key: /tmp/deploy-key + ssh_known_hosts_file: #{known_hosts.path} + verify_host_key: always + services: + - name: redis + hosts: + - host: 1.2.3.4 + label: jump + - host: 5.6.7.8 + label: target + jump_host: jump + YAML + config_file.flush + config = BoringServices::Configuration.new(config_file.path, 'test') + executor = BoringServices::SSHExecutor.new(config) + + host = executor.send(:formatted_host, config.host_config('target')) + proxy = host.ssh_options.fetch(:proxy) + + assert_instance_of Net::SSH::Proxy::Command, proxy + assert_includes proxy.command_line_template, 'ssh -F /dev/null' + assert_includes proxy.command_line_template, 'StrictHostKeyChecking\\=yes' + assert_includes proxy.command_line_template, "UserKnownHostsFile\\=#{known_hosts.path}" + assert_includes proxy.command_line_template, '-W %h:%p ubuntu@1.2.3.4' + ensure + config_file.close! + end + end +end From 126ccf6594c1154a1d55132b30a1ec41e03557b7 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 26 Aug 2026 11:58:44 +0100 Subject: [PATCH 2/3] Resolve service hosts by name --- lib/boring_services/configuration.rb | 4 +++- test/fixtures/test_config.yml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/boring_services/configuration.rb b/lib/boring_services/configuration.rb index 0640d03..0950d44 100644 --- a/lib/boring_services/configuration.rb +++ b/lib/boring_services/configuration.rb @@ -62,7 +62,9 @@ def host_config(reference) @all_hosts.find do |host| next unless host.is_a?(Hash) - host['host'].to_s == reference || host['label'].to_s.casecmp?(reference) + host['host'].to_s == reference || + host['label'].to_s.casecmp?(reference) || + host['name'].to_s.casecmp?(reference) end end diff --git a/test/fixtures/test_config.yml b/test/fixtures/test_config.yml index 056f57f..7f0086f 100644 --- a/test/fixtures/test_config.yml +++ b/test/fixtures/test_config.yml @@ -11,7 +11,7 @@ test: enabled: true hosts: - host: 10.0.0.1 - label: jump + name: jump - host: 10.0.0.2 label: target jump_host: jump From 7e013360a9ca859d0cc6a80c381ab952bc8a7dbe Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 26 Aug 2026 12:02:16 +0100 Subject: [PATCH 3/3] Keep trust settings on proxied service hosts --- lib/boring_services/ssh_executor.rb | 5 ++++- test/ssh_executor_test.rb | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/boring_services/ssh_executor.rb b/lib/boring_services/ssh_executor.rb index c207b4c..f43a311 100644 --- a/lib/boring_services/ssh_executor.rb +++ b/lib/boring_services/ssh_executor.rb @@ -201,9 +201,12 @@ def formatted_host(host) def build_sshkit_host(hostname, user, host_config) sshkit_host = SSHKit::Host.new(hostname) sshkit_host.user = user || config.user + options = { verify_host_key: config.verify_host_key_mode } + options[:user_known_hosts_file] = [config.ssh_known_hosts_file] if config.ssh_known_hosts_file if (jump_host = config.jump_host_config(host_config)) - sshkit_host.ssh_options = { proxy: jump_proxy(jump_host) } + options[:proxy] = jump_proxy(jump_host) end + sshkit_host.ssh_options = options sshkit_host end diff --git a/test/ssh_executor_test.rb b/test/ssh_executor_test.rb index 17ec993..3949ad0 100644 --- a/test/ssh_executor_test.rb +++ b/test/ssh_executor_test.rb @@ -28,6 +28,8 @@ def test_target_uses_the_configured_jump_host_and_known_hosts_file proxy = host.ssh_options.fetch(:proxy) assert_instance_of Net::SSH::Proxy::Command, proxy + assert_equal :always, host.ssh_options.fetch(:verify_host_key) + assert_equal [known_hosts.path], host.ssh_options.fetch(:user_known_hosts_file) assert_includes proxy.command_line_template, 'ssh -F /dev/null' assert_includes proxy.command_line_template, 'StrictHostKeyChecking\\=yes' assert_includes proxy.command_line_template, "UserKnownHostsFile\\=#{known_hosts.path}"