Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/boring_services/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions lib/boring_services/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -42,6 +43,54 @@ 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) ||
host['name'].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?

Expand All @@ -64,6 +113,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)

Expand Down
41 changes: 37 additions & 4 deletions lib/boring_services/ssh_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require 'sshkit'
require 'sshkit/dsl'
require 'net/ssh/proxy/command'
require 'shellwords'

module BoringServices
class SSHExecutor
Expand Down Expand Up @@ -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

Expand All @@ -185,19 +189,48 @@ 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
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))
options[:proxy] = jump_proxy(jump_host)
end
sshkit_host.ssh_options = options
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
11 changes: 11 additions & 0 deletions test/configuration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion test/fixtures/test_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ test:
- name: redis
enabled: true
hosts:
- 10.0.0.1
- host: 10.0.0.1
name: jump
- host: 10.0.0.2
label: target
jump_host: jump
port: 6379
- name: memcached
enabled: false
Expand Down
41 changes: 41 additions & 0 deletions test/ssh_executor_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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_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}"
assert_includes proxy.command_line_template, '-W %h:%p ubuntu@1.2.3.4'
ensure
config_file.close!
end
end
end