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
26 changes: 25 additions & 1 deletion lib/smart_proxy_remote_execution_ssh.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ def public_key_file
File.expand_path("#{private_key_file}.pub")
end

def cert_file
File.expand_path("#{private_key_file}-cert.pub")
end
Comment on lines +24 to +26
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we require this strict naming convention for cert file
similarly as we do for public key file, or should we make this an editable
value in settings, with this format being the possible default?


def ca_public_key_file
path = Plugin.settings.ssh_user_ca_public_key_file
File.expand_path(path) if present?(path)
end

def validate_mode!
Plugin.settings.mode = Plugin.settings.mode.to_sym

Expand Down Expand Up @@ -50,14 +59,23 @@ def validate_ssh_settings!
end

unless File.exist?(private_key_file)
raise "SSH public key file #{private_key_file} doesn't exist.\n"\
raise "SSH private key file #{private_key_file} doesn't exist.\n"\
"You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
end

unless File.exist?(public_key_file)
raise "SSH public key file #{public_key_file} doesn't exist"
end

if present?(Plugin.settings.ssh_user_ca_public_key_file)
{ ca_public_key_file: 'CA public key', cert_file: 'certificate' }.each do |file, label|
file_path = public_send(file)
unless file_path && File.exist?(file_path)
raise "SSH #{label} file '#{file_path}' doesn't exist"
end
end
Comment on lines +71 to +76
Copy link
Contributor

@adamruzicka adamruzicka Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the recent changes to how ca_public_key_file setting is handled (possibly returning nil), you can run into errors like this if you only set ssh_user_ca_public_key_file

2025-12-19T13:17:30  [W] Error details for Couldn't enable 'script': <RuntimeError>: SSH certificate file '' doesn't exist
/Users/aruzicka/vcs/foreman/smart-proxy-remote-execution-ssh/lib/smart_proxy_remote_execution_ssh.rb:75:in `block in validate_ssh_settings!'

it is not technically wrong, but it looks odd

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching it, I decided to handle client_cert_file differently.

end

validate_ssh_log_level!
end

Expand Down Expand Up @@ -100,6 +118,12 @@ def job_storage
def with_mqtt?
Proxy::RemoteExecution::Ssh::Plugin.settings.mode == :'pull-mqtt'
end

private

def present?(value)
value && !value.empty?
end
end
end
end
6 changes: 6 additions & 0 deletions lib/smart_proxy_remote_execution_ssh/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class Api < ::Sinatra::Base
File.read(Ssh.public_key_file)
end

get "/ca_pubkey" do
if Ssh.ca_public_key_file
File.read(Ssh.ca_public_key_file)
end
end

if Proxy::RemoteExecution::Ssh::Plugin.settings.cockpit_integration
post "/session" do
do_authorize_any
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def initialize(options, logger:)
@host_public_key = options.fetch(:host_public_key, nil)
@verify_host = options.fetch(:verify_host, nil)
@client_private_key_file = settings.ssh_identity_key_file
@client_ca_known_hosts_file = settings.ssh_ca_known_hosts_file
@client_cert_file = Proxy::RemoteExecution::Ssh.cert_file if File.exist?(Proxy::RemoteExecution::Ssh.cert_file)

@local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
@socket_working_dir = options.fetch(:socket_working_dir, settings.socket_working_dir)
Expand Down Expand Up @@ -154,9 +156,14 @@ def establish_ssh_options
ssh_options << "-o User=#{@ssh_user}"
ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
ssh_options << "-o CertificateFile=#{@client_cert_file}" if @client_cert_file
Copy link
Contributor

@adamruzicka adamruzicka Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if @client_cert_file

won't this always evaluate to true because Proxy::RemoteExecution::Ssh.cert_file always returns a path, even if doesn't point at an actual file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is true, and also it definitely should not work like that. I Fixed the cert_file method to only return path if file is present.

ssh_options << "-o IdentitiesOnly=yes"
ssh_options << "-o StrictHostKeyChecking=accept-new"
ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
ssh_options << "-o StrictHostKeyChecking=#{@client_ca_known_hosts_file ? 'yes' : 'accept-new'}"
if @host_public_key
ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}"
elsif @client_ca_known_hosts_file
ssh_options << "-o UserKnownHostsFile=#{@client_ca_known_hosts_file}"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have a known_hosts file with cert authorities listed, should we change StrictHostKeyChecking to yes? To me, it doesn't make much sense to have it otherwise.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO yes. Otherwise, we get "if you trust this cert, connect and if you don't, connect anyway".

Copy link
Contributor

@adamruzicka adamruzicka Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For host keys, those should be all the combinations that can happen:

Proxy Host Result
No record for host plain SSH key TOFU - success + save key
Record for host plain SSH key, matching record success
Record for host plain SSH key, not matching record failure
CA cert plain SSH key Fallback to rows 1-3 failure
CA cert trusted certificate success
CA cert untrusted certificate failure
No record for host, no CA cert untrusted certificate Fallback to row 1
Record for host, no CA cert untrusted certificate Fallback to rows 2-3

Could we agree that this table describes the expected behaviour?

One could argue that proxy expecting a cert and host not providing any (row 4) should fail rather than falling back to traditional pubkey auth.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I too would say that row 4 could fail, but there is currently option to only have one host CA per smart proxy so it would be setting behavior for all hosts that use that proxy for REX. That is probably the only point that I see why it should fallback to plain SSH key instead of fail.

Copy link
Contributor Author

@adamlazik1 adamlazik1 Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until we decide on one or the other, I will treat the expected behavior of row 4 to be failure. I updated the StrictHostKeyChecking param accordingly. I will also make the same edits to ansible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So row 4 should indeed result in a failure, added two more rows to the table describing one other case that we previously missed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm that rows 7 and 8 also behave as intended, so the behavior of the whole feature should work as expected.

end
ssh_options << "-o LogLevel=#{ssh_log_level(true)}"
ssh_options << "-o ControlMaster=auto"
ssh_options << "-o ControlPath=#{socket_file}"
Expand Down
2 changes: 2 additions & 0 deletions lib/smart_proxy_remote_execution_ssh/plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class Plugin < Proxy::Plugin

settings_file "remote_execution_ssh.yml"
default_settings :ssh_identity_key_file => '~/.ssh/id_rsa_foreman_proxy',
# :ssh_ca_known_hosts_file => nil,
# :ssh_user_ca_public_key_file => nil,
:ssh_user => 'root',
:remote_working_dir => '/var/tmp',
:local_working_dir => '/var/tmp',
Expand Down
9 changes: 9 additions & 0 deletions settings.d/remote_execution_ssh.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
# Mode of operation, one of ssh, pull, pull-mqtt
:mode: ssh

# Enables the use of SSH certificate for smart proxy authentication
# The file should contain an SSH CA public key that the SSH public key of smart proxy is signed by
# :ssh_user_ca_public_key_file:

# Enables the use of SSH host certificates for host authentication
# The file should contain a list of trusted SSH CA authorities that the host certs can be signed by
# Example file content: @cert-authority * <SSH CA public key>
# :ssh_ca_known_hosts_file:

# Defines how often (in seconds) should the runner check
# for new data leave empty to use the runner's default
# :runner_refresh_interval: 1
Expand Down
7 changes: 7 additions & 0 deletions test/api_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ def setup
end
end

describe '/ca_pubkey' do
it 'returns the content of the CA public key' do
get '/ca_pubkey'
_(last_response.body).must_equal '===ca-public-key==='
end
end

describe 'job storage' do
let(:uuid) { SecureRandom.uuid }
let(:execution_plan_uuid) { SecureRandom.uuid }
Expand Down
3 changes: 3 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
DATA_DIR = File.expand_path('../data', __FILE__)
FAKE_PRIVATE_KEY_FILE = File.join(DATA_DIR, 'fake_id_rsa')
FAKE_PUBLIC_KEY_FILE = "#{FAKE_PRIVATE_KEY_FILE}.pub"
FAKE_CA_PUBLIC_KEY_FILE = File.join(DATA_DIR, 'fake_ca_cert.pub')

logdir = File.join(File.dirname(__FILE__), '..', 'logs')
FileUtils.mkdir_p(logdir) unless File.exist?(logdir)
Expand All @@ -24,9 +25,11 @@ def prepare_fake_keys
Proxy::RemoteExecution::Ssh::Plugin.settings.ssh_identity_key_file = FAKE_PRIVATE_KEY_FILE
# Workaround for Proxy::RemoteExecution::Ssh::Plugin.settings.ssh_identity_key_file returning nil
Proxy::RemoteExecution::Ssh::Plugin.settings.stubs(:ssh_identity_key_file).returns(FAKE_PRIVATE_KEY_FILE)
Proxy::RemoteExecution::Ssh::Plugin.settings.stubs(:ssh_user_ca_public_key_file).returns(FAKE_CA_PUBLIC_KEY_FILE)
FileUtils.mkdir_p(DATA_DIR) unless File.exist?(DATA_DIR)
File.write(FAKE_PRIVATE_KEY_FILE, '===private-key===')
File.write(FAKE_PUBLIC_KEY_FILE, '===public-key===')
File.write(FAKE_CA_PUBLIC_KEY_FILE, '===ca-public-key===')
end

class Minitest::Test
Expand Down