diff --git a/REFERENCE.md b/REFERENCE.md index 986eb98..fe4102f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -190,6 +190,15 @@ other attributes): * Key headers, such as 'ssh-rsa' --- put these in the `type` attribute. +##### `marker` + +Valid values: `cert-authority`, `revoked` + +Optional marker for the `known_hosts` entry. Valid values are +`cert-authority` (the key is a CA that signs host certificates for the +listed patterns) and `revoked` (the key is explicitly denied). See the +`SSH_KNOWN_HOSTS FILE FORMAT` section of sshd(8). + ##### `target` The file in which to store the ssh key. Only used by diff --git a/lib/puppet/provider/sshkey/parsed.rb b/lib/puppet/provider/sshkey/parsed.rb index 985fa83..150a39c 100644 --- a/lib/puppet/provider/sshkey/parsed.rb +++ b/lib/puppet/provider/sshkey/parsed.rb @@ -12,15 +12,39 @@ record_line :parsed, fields: ['name', 'type', 'key'], post_parse: proc { |hash| - names = hash[:name].split(',', -1) - hash[:name] = names.shift - hash[:host_aliases] = names + # record_line takes a fixed field count; peel any leading @cert-authority/@revoked marker off by hand. + if hash[:name] && hash[:name].start_with?('@') + marker = hash[:name].delete_prefix('@') + key_field = hash[:key].is_a?(String) ? hash[:key] : '' + keytype, actual_key = key_field.split(%r{\s+}, 2) + if ['cert-authority', 'revoked'].include?(marker) && keytype && actual_key + hash[:marker] = marker.to_sym + hash[:name] = hash[:type] + hash[:type] = keytype + hash[:key] = actual_key + else + Puppet.warning(_('Ignoring malformed known_hosts entry starting with %{marker}') % ({ marker: hash[:name] })) + end + end + + # marker isn't a record_line field, so ParsedFile#mk_resource_methods will fall back to the desired value unless we set it explicitly. + hash[:marker] ||= :absent + + if hash[:name] + names = hash[:name].split(',', -1) + hash[:name] = names.shift + hash[:host_aliases] = names + end }, pre_gen: proc { |hash| if hash[:host_aliases] hash[:name] = [hash[:name], hash[:host_aliases]].flatten.join(',') hash.delete(:host_aliases) end + if hash[:marker] && hash[:marker] != :absent + hash[:name] = "@#{hash[:marker]} #{hash[:name]}" + hash.delete(:marker) + end } # Make sure to use mode 644 if ssh_known_hosts is newly created diff --git a/lib/puppet/type/sshkey.rb b/lib/puppet/type/sshkey.rb index c1fb65b..d75b963 100644 --- a/lib/puppet/type/sshkey.rb +++ b/lib/puppet/type/sshkey.rb @@ -51,6 +51,15 @@ def self.title_patterns aliasvalue(:'ed25519-sk', :'sk-ssh-ed25519@openssh.com') end + newproperty(:marker) do + desc "Optional marker for the `known_hosts` entry. Valid values are + `cert-authority` (the key is a CA that signs host certificates for the + listed patterns) and `revoked` (the key is explicitly denied). See the + `SSH_KNOWN_HOSTS FILE FORMAT` section of sshd(8)." + + newvalues :'cert-authority', :revoked + end + newproperty(:key) do desc "The key itself; generally a long string of unencoded characters. The `key` attribute may not contain any whitespace, including embedded newlines. diff --git a/spec/acceptance/tests/resource/sshkey/marker_spec.rb b/spec/acceptance/tests/resource/sshkey/marker_spec.rb new file mode 100644 index 0000000..041083b --- /dev/null +++ b/spec/acceptance/tests/resource/sshkey/marker_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper_acceptance' + +RSpec.context 'sshkeys: Marker (cert-authority / revoked)' do + let(:keyname) { "pl#{rand(999_999).to_i}" } + let(:sample_key) { 'how_about_the_key_of_c' } + let(:ssh_known_hosts) { '/etc/ssh/ssh_known_hosts' } + + before(:each) do + posix_agents.agents.each do |agent| + # The 'cp' might fail because the source file doesn't exist + on( + agent, + "cp -fv #{ssh_known_hosts} /tmp/ssh_known_hosts", + acceptable_exit_codes: [0, 1], + ) + end + end + + after(:each) do + posix_agents.each do |agent| + rc = on( + agent, + '[ -e /tmp/ssh_known_hosts ]', + accept_all_exit_codes: true, + ) + if rc.exit_code == 0 + on( + agent, + "mv -fv /tmp/ssh_known_hosts #{ssh_known_hosts}", + accept_all_exit_codes: true, + ) + else + on( + agent, + "rm -fv #{ssh_known_hosts}", + accept_all_exit_codes: true, + ) + end + end + end + + posix_agents.each do |agent| + it "#{agent} writes a cert-authority marker to ssh_known_hosts" do + on agent, puppet('apply'), stdin: < 'present', + type => 'ssh-rsa', + key => '#{sample_key}', + marker => 'cert-authority', + } +MANIFEST + + on(agent, "cat #{ssh_known_hosts}") do |res| + expect(res.stdout).to match(%r{^@cert-authority #{keyname} ssh-rsa #{sample_key}$}) + end + end + + it "#{agent} writes a revoked marker to ssh_known_hosts" do + on agent, puppet('apply'), stdin: < 'present', + type => 'ssh-rsa', + key => '#{sample_key}', + marker => 'revoked', + } +MANIFEST + + on(agent, "cat #{ssh_known_hosts}") do |res| + expect(res.stdout).to match(%r{^@revoked #{keyname} ssh-rsa #{sample_key}$}) + end + end + + it "#{agent} is idempotent when the marker already matches" do + manifest = < 'present', + type => 'ssh-rsa', + key => '#{sample_key}', + marker => 'cert-authority', + } +MANIFEST + + on(agent, puppet('apply'), stdin: manifest) + on(agent, puppet('apply', '--detailed-exitcodes'), stdin: manifest, acceptable_exit_codes: [0]) + end + + it "#{agent} adds a marker to an existing plain entry" do + on(agent, "echo '#{keyname} ssh-rsa #{sample_key}' >> #{ssh_known_hosts}") + + on agent, puppet('apply'), stdin: < 'present', + type => 'ssh-rsa', + key => '#{sample_key}', + marker => 'cert-authority', + } +MANIFEST + + on(agent, "cat #{ssh_known_hosts}") do |res| + expect(res.stdout).to match(%r{^@cert-authority #{keyname} ssh-rsa #{sample_key}$}) + expect(res.stdout.scan(%r{#{keyname} ssh-rsa}).length).to eq(1) + end + end + + it "#{agent} removes an entry with a marker when ensure => absent" do + on(agent, "echo '@revoked #{keyname} ssh-rsa #{sample_key}' >> #{ssh_known_hosts}") + + on agent, puppet('apply'), stdin: < 'absent', + type => 'ssh-rsa', + } +MANIFEST + + on(agent, "cat #{ssh_known_hosts}") do |res| + expect(res.stdout).not_to include(keyname.to_s) + end + end + end +end diff --git a/spec/fixtures/unit/provider/sshkey/parsed/sample b/spec/fixtures/unit/provider/sshkey/parsed/sample index 840ed19..aabcb9f 100644 --- a/spec/fixtures/unit/provider/sshkey/parsed/sample +++ b/spec/fixtures/unit/provider/sshkey/parsed/sample @@ -19,3 +19,5 @@ rh3b,192.168.0.56 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAufcvE7s1eRwhUwMBfZ6uFNxkdS centos1,192.168.0.57 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA0DXqYF+3Lf68GkWBAjjKBb6UITNnzm4wiDi/AGjv5+DoVXqDcqHvZ8rZFAMgUe1dVob4pWT2ZWLHW0gicoJCdr4UQbPXlWz1F62z8fo2PRRPlG6KN1wmF7pnyml8jr0wBX8lQZJsMqi4InGozf7wFHLH/7DNGRK3MD6tSp3Z4is= doorstop.cafes.net,205.241.238.186 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEApJKeB9/bN5t55zLETHs0MVo/vEkfQ3EzY7178GKLI/yiOFmcn+NvUvUtCQK/xKpod813LBHCODxZPG1Kb0SjlaC/EkFEenb74LNu0o1qXa1GWh3wfaIm0JRNjXqPqxAWTlMs43O2HXwOwmLVhl7SSP3xtTw6h9gREbVKmfBaRdsRfzD0etfz1NCnmGh/1Sh9+j4eeS+IBtwoR5JVhZVhuofHCqs5HZ8gLDgfn8HXP7pMbLkx54cf1R/tmFmn9JGLdTPtEGcSIiu7414XSbfChSC83rGZCDPKHq7ZodiE8GpbWLBnyPXi2AYxTPM7aZMqitIHv3MWf5suV0q0WLGdnQ== host.domain.com,host1.domain.com,192.168.0.1 dss thisismykey1 +@cert-authority *.example.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAy+f2t52cDMrYkgEKQ6juqfMf/a0nDFry3JAzl+SAWQ0gTklVxNcVbfHx2pkZk66EBGQfrK33Bx1BflZ/iEDyiCwmzVtNba0X9A6ELYjB9WSkWdIqZCfPlKZMu9N//aZ6+3SDVuz/BVFsAVmtqQ4Let2QjOFiSIKXrtPqWvVT/MM= +@revoked bad.example.com,alt.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleRevokedKeyForFixtureTestingOnly000000000 diff --git a/spec/fixtures/unit/provider/sshkey/parsed/sample_with_blank_lines b/spec/fixtures/unit/provider/sshkey/parsed/sample_with_blank_lines index 02535df..ab4c66c 100644 --- a/spec/fixtures/unit/provider/sshkey/parsed/sample_with_blank_lines +++ b/spec/fixtures/unit/provider/sshkey/parsed/sample_with_blank_lines @@ -6,3 +6,5 @@ hosting2.planetargon.com,64.34.164.77 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAy+f2t5 will.isawesome.com,192.168.0.5 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAw9iHuAa/wepHoUzWqsvhQvSkpE4K7agrdLOWHM9mvyRQ2X3HVq5GqzAvWu4J+f0FvcLPwA9tivpxt1oSt5MOtvDM6HoM+8m3P4daBp0nlNaYR8/vHCAmX6N3RyM8FWfp+VqWyux1SooQwxYxVFy86G78ApTqNsZ+p7cHmnBYqk0= will.isgreat.com,192.168.0.6 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAw9iHuAa/wepHoUzWqsvhQvSkpE4K7agrdLOWHM9mvyRQ2X3HVq5GqzAvWu4J+f0FvcLPwA9tivpxt1oSt5MOtvDM6HoM+8m3P4daBp0nlNaYR8/vHCAmX6N3RyM8FWfp+VqWyux1SooQwxYxVFy86G78ApTqNsZ+p7cHmnBasd= + +@cert-authority *.example.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAy+f2t52cDMrYkgEKQ6juqfMf/a0nDFry3JAzl+SAWQ0gTklVxNcVbfHx2pkZk66EBGQfrK33Bx1BflZ/iEDyiCwmzVtNba0X9A6ELYjB9WSkWdIqZCfPlKZMu9N//aZ6+3SDVuz/BVFsAVmtqQ4Let2QjOFiSIKXrtPqWvVT/MM= diff --git a/spec/integration/provider/sshkey_spec.rb b/spec/integration/provider/sshkey_spec.rb index 11e98b5..f8a6912 100644 --- a/spec/integration/provider/sshkey_spec.rb +++ b/spec/integration/provider/sshkey_spec.rb @@ -195,4 +195,78 @@ resource_app.main end end + + describe 'marker functionality' do + let(:provider_class) { described_class } + let(:type) { Puppet::Type.type(:sshkey) } + let(:sample_key) { 'AAAAB3NzaC1yc2EAAAABIwAAAQEAzwHhxXvIrtfIwrudFqc8yQcIfMudrgpnuh1F3AV6d2BrLgu/yQE7W5UyJMUjfj427sQudRwKW45O0Jsnr33F4mUw+GIMlAAmp9g24/OcrTiB8ZUKIjoPy/cO4coxGi8/NECtRzpD/ZUPFh6OEpyOwJPMb7/EC2Az6Otw4StHdXUYw22zHazBcPFnv6zCgPx1hA7QlQDWTu4YcL0WmTYQCtMUb3FUqrcFtzGDD0ytosgwSd+JyN5vj5UwIABjnNOHPZ62EY1OFixnfqX/+dUwrFSs5tPgBF/KkC6R7tmbUfnBON6RrGEmu+ajOTOLy23qUZB4CQ53V7nyAWhzqSK+hw==' } # rubocop:disable Layout/LineLength + + describe 'round-trip conversion' do + it 'parses and regenerates a cert-authority entry' do + line = "@cert-authority *.example.com ssh-rsa #{sample_key}" + + parsed = provider_class.parse_line(line) + expect(parsed[:marker]).to eq(:'cert-authority') + expect(parsed[:name]).to eq('*.example.com') + expect(parsed[:type]).to eq('ssh-rsa') + expect(parsed[:key]).to eq(sample_key) + + expect(provider_class.to_line(parsed)).to eq(line) + end + + it 'parses and regenerates a cert-authority entry with host aliases' do + line = "@cert-authority *.example.com,*.test.com ssh-rsa #{sample_key}" + + parsed = provider_class.parse_line(line) + expect(parsed[:marker]).to eq(:'cert-authority') + expect(parsed[:name]).to eq('*.example.com') + expect(parsed[:host_aliases]).to eq(['*.test.com']) + expect(parsed[:type]).to eq('ssh-rsa') + + expect(provider_class.to_line(parsed)).to eq(line) + end + + it 'parses and regenerates a revoked entry' do + line = "@revoked bad.example.com ssh-rsa #{sample_key}" + + parsed = provider_class.parse_line(line) + expect(parsed[:marker]).to eq(:revoked) + expect(parsed[:name]).to eq('bad.example.com') + expect(parsed[:type]).to eq('ssh-rsa') + + expect(provider_class.to_line(parsed)).to eq(line) + end + + it 'emits a plain line when marker is :absent' do + record = { + record_type: :parsed, + name: '*.example.com', + type: 'ssh-rsa', + key: sample_key, + marker: :absent, + } + expect(provider_class.to_line(record)).to eq("*.example.com ssh-rsa #{sample_key}") + end + end + + describe 'resource creation' do + it 'creates a cert-authority sshkey resource' do + expect { + type.new(name: '*.example.com', type: 'ssh-rsa', marker: 'cert-authority', key: sample_key) + }.not_to raise_error + end + + it 'creates a revoked sshkey resource' do + expect { + type.new(name: 'bad.example.com', type: 'ssh-rsa', marker: 'revoked', key: sample_key) + }.not_to raise_error + end + + it 'still resolves keytype aliases when a marker is set' do + resource = type.new(name: '*.example.com', type: :rsa, marker: :'cert-authority', key: sample_key) + expect(resource[:type]).to eq(:'ssh-rsa') + expect(resource[:marker]).to eq(:'cert-authority') + end + end + end end diff --git a/spec/unit/provider/sshkey/parsed_spec.rb b/spec/unit/provider/sshkey/parsed_spec.rb index 64da0da..2fe5d53 100644 --- a/spec/unit/provider/sshkey/parsed_spec.rb +++ b/spec/unit/provider/sshkey/parsed_spec.rb @@ -38,6 +38,45 @@ def key expect(subject.parse_line('test ssh-rsa ' + key)[:host_aliases]).to eq([]) end + it 'parses cert-authority entries into a marker with a clean type' do + result = subject.parse_line('@cert-authority *.example.com ssh-rsa ' + key) + expect(result[:marker]).to eq(:'cert-authority') + expect(result[:name]).to eq('*.example.com') + expect(result[:type]).to eq('ssh-rsa') + expect(result[:key]).to eq(key) + end + + it 'parses cert-authority entries with host aliases' do + result = subject.parse_line('@cert-authority *.example.com,*.test.com ssh-rsa ' + key) + expect(result[:marker]).to eq(:'cert-authority') + expect(result[:name]).to eq('*.example.com') + expect(result[:host_aliases]).to eq(['*.test.com']) + expect(result[:type]).to eq('ssh-rsa') + end + + it 'parses revoked entries' do + result = subject.parse_line('@revoked *.example.com ssh-rsa ' + key) + expect(result[:marker]).to eq(:revoked) + expect(result[:name]).to eq('*.example.com') + expect(result[:type]).to eq('ssh-rsa') + expect(result[:key]).to eq(key) + end + + it 'defaults marker to :absent on plain entries so Puppet detects marker transitions' do + expect(subject.parse_line('test ssh-rsa ' + key)[:marker]).to eq(:absent) + end + + it 'warns on a malformed marker line and does not set a marker' do + expect(Puppet).to receive(:warning).with(%r{malformed known_hosts entry}i) + result = subject.parse_line('@cert-authority *.example.com') + expect(result[:marker]).to eq(:absent) + end + + it 'warns on an unknown @-prefixed marker' do + expect(Puppet).to receive(:warning).with(%r{malformed known_hosts entry}i) + subject.parse_line('@bogus *.example.com ssh-rsa ' + key) + end + context 'with the sample file' do ['sample', 'sample_with_blank_lines'].each do |sample_file| let(:fixture) { my_fixture(sample_file) } diff --git a/spec/unit/type/sshkey_spec.rb b/spec/unit/type/sshkey_spec.rb index d21e5bf..478ecf9 100644 --- a/spec/unit/type/sshkey_spec.rb +++ b/spec/unit/type/sshkey_spec.rb @@ -13,7 +13,7 @@ end end - [:host_aliases, :ensure, :key].each do |property| + [:host_aliases, :ensure, :key, :marker].each do |property| it "has a #{property} property" do expect(described_class.attrtype(property)).to eq :property end @@ -56,6 +56,24 @@ expect(key.parameter(:type).value).to eq :'sk-ssh-ed25519@openssh.com' end + [:'cert-authority', :revoked].each do |mark| + it "accepts marker => #{mark}" do + key = described_class.new(name: 'foo', type: :'ssh-rsa', marker: mark, key: 'AAA') + expect(key[:marker]).to eq(mark) + end + end + + it 'rejects an unknown marker value' do + expect { + described_class.new(name: 'foo', type: :'ssh-rsa', marker: :bogus, key: 'AAA') + }.to raise_error(Puppet::Error, %r{Invalid value.*bogus}) + end + + it 'leaves marker unset by default' do + key = described_class.new(name: 'foo', type: :'ssh-rsa', key: 'AAA') + expect(key[:marker]).to be_nil + end + it "doesn't support values other than ssh-dss, ssh-rsa, dsa, rsa for type" do expect { described_class.new(name: 'whev', type: :'ssh-dsa')