File: apt_key.rb

package info (click to toggle)
puppet-module-puppetlabs-apt 9.4.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 572 kB
  • sloc: ruby: 438; sh: 31; makefile: 2
file content (240 lines) | stat: -rw-r--r-- 8,013 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# frozen_string_literal: true

require 'open-uri'
begin
  require 'net/ftp'
rescue LoadError
  # Ruby 3.0 changed net-ftp to a default gem
end
require 'tempfile'

Puppet::Type.type(:apt_key).provide(:apt_key) do
  desc 'apt-key provider for apt_key resource'

  confine    osfamily: :debian
  defaultfor osfamily: :debian
  commands   apt_key: 'apt-key'
  commands   gpg: '/usr/bin/gpg'

  def self.instances
    key_array = []

    cli_args = ['adv', '--no-tty', '--list-keys', '--with-colons', '--fingerprint', '--fixed-list-mode']

    key_output = apt_key(cli_args).encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')

    pub_line = nil
    fpr_lines = []
    sub_lines = []

    lines = key_output.split("\n")

    lines.each_index do |i|
      if lines[i].start_with?('pub')
        pub_line = lines[i]
        # starting a new public key, so reset fpr_lines and sub_lines
        fpr_lines = []
        sub_lines = []
      elsif lines[i].start_with?('fpr')
        fpr_lines << lines[i]
      elsif lines[i].start_with?('sub')
        sub_lines << lines[i]
      end

      next unless (pub_line && !fpr_lines.empty?) && (!lines[i + 1] || lines[i + 1].start_with?('pub'))

      line_hash = key_line_hash(pub_line, fpr_lines)

      expired = line_hash[:key_expired] || subkeys_all_expired(sub_lines)

      key_array << new(
        name: line_hash[:key_fingerprint],
        id: line_hash[:key_long],
        fingerprint: line_hash[:key_fingerprint],
        short: line_hash[:key_short],
        long: line_hash[:key_long],
        ensure: :present,
        expired: expired,
        expiry: line_hash[:key_expiry].nil? ? nil : line_hash[:key_expiry].strftime('%Y-%m-%d'),
        size: line_hash[:key_size],
        type: line_hash[:key_type],
        created: line_hash[:key_created].strftime('%Y-%m-%d'),
      )
    end
    key_array
  end

  def self.prefetch(resources)
    apt_keys = instances
    resources.each_key do |name|
      case name.length
      when 40
        provider = apt_keys.find { |key| key.fingerprint == name }
        resources[name].provider = provider if provider
      when 16
        provider = apt_keys.find { |key| key.long == name }
        resources[name].provider = provider if provider
      when 8
        provider = apt_keys.find { |key| key.short == name }
        resources[name].provider = provider if provider
      end
    end
  end

  def self.subkeys_all_expired(sub_lines)
    return false if sub_lines.empty?

    sub_lines.each do |line|
      return false if line.split(':')[1] == '-'
    end
    true
  end

  def self.key_line_hash(pub_line, fpr_lines)
    pub_split = pub_line.split(':')
    fpr_split = fpr_lines.first.split(':')

    fingerprint = fpr_split.last
    return_hash = {
      key_fingerprint: fingerprint,
      key_long: fingerprint[-16..], # last 16 characters of fingerprint
      key_short: fingerprint[-8..], # last 8 characters of fingerprint
      key_size: pub_split[2],
      key_type: nil,
      key_created: Time.at(pub_split[5].to_i),
      key_expired: pub_split[1] == 'e',
      key_expiry: pub_split[6].empty? ? nil : Time.at(pub_split[6].to_i)
    }

    # set key type based on types defined in /usr/share/doc/gnupg/DETAILS.gz
    case pub_split[3]
    when '1'
      return_hash[:key_type] = :rsa
    when '17'
      return_hash[:key_type] = :dsa
    when '18'
      return_hash[:key_type] = :ecc
    when '19'
      return_hash[:key_type] = :ecdsa
    end

    return_hash
  end

  def source_to_file(value)
    parsed_value = URI.parse(value)
    if parsed_value.scheme.nil?
      raise(_('The file %{_value} does not exist') % { _value: value }) unless File.exist?(value)

      # Because the tempfile method has to return a live object to prevent GC
      # of the underlying file from occuring too early, we also have to return
      # a file object here.  The caller can still call the #path method on the
      # closed file handle to get the path.
      f = File.open(value, 'r')
      f.close
      f
    else
      exceptions = [OpenURI::HTTPError]
      exceptions << Net::FTPPermError if defined?(Net::FTPPermError)

      begin
        # Only send basic auth if URL contains userinfo
        # Some webservers (e.g. Amazon S3) return code 400 if empty basic auth is sent
        if parsed_value.userinfo.nil?
          key = if parsed_value.scheme == 'https' && resource[:weak_ssl] == true
                  URI.open(parsed_value, ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE).read
                else
                  parsed_value.read
                end
        else
          user_pass = parsed_value.userinfo.split(':')
          parsed_value.userinfo = ''
          key = URI.open(parsed_value, http_basic_authentication: user_pass).read
        end
      rescue *exceptions => e
        raise(_('%{_e} for %{_resource}') % { _e: e.message, _resource: resource[:source] })
      rescue SocketError
        raise(_('could not resolve %{_resource}') % { _resource: resource[:source] })
      else
        tempfile(key)
      end
    end
  end

  # The tempfile method needs to return the tempfile object to the caller, so
  # that it doesn't get deleted by the GC immediately after it returns.  We
  # want the caller to control when it goes out of scope.
  def tempfile(content)
    file = Tempfile.new('apt_key')
    file.write content
    file.close
    # confirm that the fingerprint from the file, matches the long key that is in the manifest
    if name.size == 40
      if File.executable? command(:gpg)
        extracted_key = execute(["#{command(:gpg)} --no-tty --with-fingerprint --with-colons #{file.path} | awk -F: '/^fpr:/ { print $10 }'"], failonfail: false)
        extracted_key = extracted_key.chomp

        found_match = false
        extracted_key.each_line do |line|
          found_match = true if line.chomp == name
        end
        unless found_match
          raise(_('The id in your manifest %{_resource} and the fingerprint from content/source don\'t match. Check for an error in the id and content/source is legitimate.') % { _resource: resource[:name] }) # rubocop:disable Layout/LineLength
        end
      else
        warning('/usr/bin/gpg cannot be found for verification of the id.')
      end
    end
    file
  end

  def exists?
    # report expired keys as non-existing when refresh => true
    @property_hash[:ensure] == :present && !(resource[:refresh] && @property_hash[:expired])
  end

  def create
    command = []
    if resource[:source].nil? && resource[:content].nil?
      # Breaking up the command like this is needed because it blows up
      # if --recv-keys isn't the last argument.
      command.push('adv', '--no-tty', '--keyserver', resource[:server])
      command.push('--keyserver-options', resource[:options]) unless resource[:options].nil?
      command.push('--recv-keys', resource[:id])
    elsif resource[:content]
      key_file = tempfile(resource[:content])
      command.push('add', key_file.path)
    elsif resource[:source]
      key_file = source_to_file(resource[:source])
      command.push('add', key_file.path)
    # In case we really screwed up, better safe than sorry.
    else
      raise(_('an unexpected condition occurred while trying to add the key: %{_resource}') % { _resource: resource[:id] })
    end
    apt_key(command)
    @property_hash[:ensure] = :present
  end

  def destroy
    loop do
      apt_key('del', resource.provider.short)
      r = execute(["#{command(:apt_key)} list | grep '/#{resource.provider.short}\s'"], failonfail: false)
      break unless r.exitstatus.zero?
    end
    @property_hash.clear
  end

  def read_only(_value)
    raise(_('This is a read-only property.'))
  end

  mk_resource_methods

  # Alias the setters of read-only properties
  # to the read_only function.
  alias_method :created=, :read_only
  alias_method :expired=, :read_only
  alias_method :expiry=, :read_only
  alias_method :size=, :read_only
  alias_method :type=, :read_only
end