Inspiriert von postwhite habe ich beschlossen, auch für mich privat mal damit zu spielen, für postscreen eine Whitelist zu erstellen und selbige aus den SPF-Records großer Anbieter zu befüllen.

Anmerkung: Das ganze hat in meinem Setup keinerlei Sinn.

Das mal vorangestellt, der folgende Code erzeugt ein CIDR-File, welches man dann in Postfix, genauer der Datei main.cf, z.B. folgendermaßen einbinden kann:

1
2
3
postscreen_access_list =
  permit_mynetworks,
  cidr:${maps_dir}/spf_whitelist.cidr

Der Code selber benötigt die Gems ipaddress und dnsruby. Ausgeführt wird er bei mir mittels

1
chronic spf_whitelist.rb -o /etc/postfix/maps/spf_whitelist.cidr

einmal am Tag. Zu beachten ist, dass postscreen ein recht langelebiger Dienst ist und deswegen die Änderungen an der Datei nicht von selber mitbekommen. Man kann sich hier beliebig verkünsteln und z.B. jedesmal, wenn sich die Ergebnisse unterscheiden, ein postfix reload ausführen, mir ist das egal.

Und anbei dann der Code, Verwendung auf eigene Gefahr:

  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
#!/usr/bin/env ruby

require "rubygems"
require 'dnsruby'
require 'ipaddress'
require 'optparse'
require 'pp'

domains = [
  # freemail provider
  "gmail.com",
  "googlemail.com",
  "gmx.net",
  "gmx.com",
  "gmx.de",
  "web.de",
  "google.com",
  "aol.com",
  "microsoft.com",
  # social stuff
  "facebook.com",
  "twitter.com",
  "pinterest.com",
  "instagram.com",
  "reddit.com",
  "linkedin.com",
  "xing.com",
  "xing.de",
  # commerce hosts
  "amazon.com",
  "amazon.de",
  "ebay.de",
  "ebay.com",
  "paypal.com",
  "paypal.de",
  # bulk sender
  "sendgrid.com",
  "sendgrid.net",
  "mailchimp.com",
  "exacttarget.com",
  "cust-spf.exacttarget.com",
  "constantcontact.com",
  "icontact.com",
  "mailgun.com",
  "fishbowl.com",
  "fbmta.com",
  "mailjet.com",
  "sparkpost.com",
  "sparkpostmail.com",
  # misc stuff
  "github.com",
]

# collect networks to whitelist
networks = []

# parse parameters
params = ARGV.getopts("o:f")

# output file
if params.has_key?("o") and params["o"].is_a?(String)
  outfile = params["o"]
else
  outfile = "/tmp/spf_whitelist.cidr"
end

# get amount of lines in original file
old_lines = 0
begin
  old_lines = File.read(outfile).lines.count
rescue Exception => e
  true
end

def a(names, resolver)
  result = []
  names.each do |name|
    begin
      records = resolver.getresources(name, "AAAA") + resolver.getresources(name, "A")
    rescue Dnsruby::ResolvError, Timeout::Error
      records = []
    end
    result += records.collect{|r| r.address.to_s.downcase}
  end
  return result
end

def ptr(name, resolver)
  begin
    records = resolver.getresources(name, "PTR")
  rescue Dnsruby::ResolvError, Timeout::Error
    records = []
  end
  return a(records.collect{|r| r.name}, resolver)
end

def mx(name, resolver)
  begin
    records = resolver.getresources(name, "MX")
  rescue Dnsruby::ResolvError, Timeout::Error
    records = []
  end
  return a(records.collect{|r| r.exchange}, resolver)
end

def get_spf_results(domain, resolver)
  result = []
  begin
    records = resolver.getresources(domain, "TXT") + resolver.getresources(domain, "SPF")
  rescue Dnsruby::ResolvError, Timeout::Error
    records = []
  end
  records = records.collect{|r| r.strings.join}.uniq.select{|r| r.match(/^v=spf1/)}
  records.each do |line|
    line.split(/\s+/).each do |entry|
      next if entry == "v=spf1"
      if m = entry.match(/^redirect=(?<redirect>.*)/)
        return get_spf_results(m[:redirect], resolver)
      elsif m = entry.match(/^\??include:(?<include>.*)/)
        result += get_spf_results(m[:include], resolver)
      elsif m = entry.match(/^\??ip4:(?<ip4>.*)/)
        result += [m[:ip4]]
      elsif m = entry.match(/^\??ip6:(?<ip6>.*)/)
        result += [m[:ip6]]
      elsif m = entry.match(/^\??mx$/)          # mx
        result += mx(domain, resolver)
      elsif m = entry.match(/^\??mx:(?<mx>.*)/) # mx: <- ":"!!!
        result += mx(m[:mx], resolver)
      elsif m = entry.match(/^\??a$/)         # a
        result += a([domain], resolver)
      elsif m = entry.match(/^\??a:(?<a>.*)/) # a: <- ":"!!!
        result += a([m[:a]], resolver)
      elsif entry.match(/.all/)
        true
      else
        # puts "ERROR: domain #{domain}, entry #{entry}"
      end
    end
  end
  # some people don't seem to get netmasks right, so fix this
  result = result.map do |r|
    if m = r.match(/^(?<network>\d+\.\d+\.\d+\.\d+)\/(?<prefix>\d+)$/)
      i = IPAddress(r)
      i.network.address.to_s + "/" + i.network.prefix.to_s
    else
      r
    end
  end
  return result.sort.uniq
end

# generate resolver
resolver = Dnsruby::DNS.open

# collect results, format as Postfix CIDR style map
spf_results = domains.collect{|d| get_spf_results(d, resolver)}.flatten.uniq.sort

# compare number of results
if ((old_lines / spf_results.count) < 0.9 or (old_lines / spf_results.count) > 1.1) and old_lines > 0
  puts "WARNING: More than 10% difference in number of results detected, old: #{old_lines}, new: #{spf_results.count}"
  puts "Call with -f to force writing (this error message will be displayed anyways)"
  unless params.has_key?("f") and params["f"]
    exit 1
  end
end

# write file
File.write(outfile, spf_results.join(" permit\n") + " permit\n")