puffy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Puffy
6
+ # Manage nodes rulesets as a tree of rules to serve via Puppet
7
+ class Puppet
8
+ # Setup an environment to store firewall rules to disk
9
+ #
10
+ # @param path [String] Root directory of the tree of firewall rules
11
+ # @param parser [Puffy::Parser] A parser with nodes and rules
12
+ def initialize(path, parser)
13
+ @path = path
14
+ @parser = parser
15
+
16
+ @formatters = [
17
+ Puffy::Formatters::Pf::Ruleset.new,
18
+ Puffy::Formatters::Netfilter4::Ruleset.new,
19
+ Puffy::Formatters::Netfilter6::Ruleset.new,
20
+ ]
21
+ end
22
+
23
+ # Saves rules to disk
24
+ #
25
+ # @return [void]
26
+ def save
27
+ each_fragment do |fragment_name, fragment_content|
28
+ FileUtils.mkdir_p(File.dirname(fragment_name))
29
+
30
+ next unless fragment_changed?(fragment_name, fragment_content)
31
+
32
+ File.open(fragment_name, 'w') do |f|
33
+ f.write(fragment_content)
34
+ end
35
+ end
36
+ end
37
+
38
+ # Show differences between saved and generated rules
39
+ #
40
+ # @return [void]
41
+ def diff
42
+ each_fragment do |fragment_name, fragment_content|
43
+ human_fragment_name = fragment_name.delete_prefix(@path).delete_prefix('/')
44
+ IO.popen("diff -u1 -N --unidirectional-new-file --ignore-matching-lines='^#' --label a/#{human_fragment_name} #{fragment_name} --label b/#{human_fragment_name} -", 'r+') do |io|
45
+ io.write(fragment_content)
46
+ io.close_write
47
+ out = io.read
48
+ $stdout.write out
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def each_fragment
56
+ @parser.nodes.each do |hostname|
57
+ rules = @parser.ruleset_for(hostname)
58
+ policy = @parser.policy_for(hostname)
59
+
60
+ @formatters.each do |formatter|
61
+ filename = File.join(@path, hostname, formatter.filename_fragment)
62
+ yield filename, formatter.emit_ruleset(rules, policy)
63
+ end
64
+ end
65
+ end
66
+
67
+ def fragment_changed?(fragment_name, fragment_content)
68
+ return true unless File.exist?(fragment_name)
69
+
70
+ File.read(fragment_name).split("\n").reject { |l| l =~ /^#/ } != fragment_content.split("\n").reject { |l| l =~ /^#/ }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'singleton'
5
+
6
+ module Puffy
7
+ # DNS resolution class.
8
+ class Resolver
9
+ include Singleton
10
+
11
+ # Resolve +hostname+ and return an Array of IPAddr.
12
+ #
13
+ # @example
14
+ # Resolver.instance.resolv('localhost')
15
+ # #=> [#<IPAddr:[::1]>, #<IPAddr:127.0.0.1>]
16
+ # Resolver.instance.resolv('localhost', :inet)
17
+ # #=> [#<IPAddr:127.0.0.1>]
18
+ # Resolver.instance.resolv('localhost', :inet6)
19
+ # #=> [#<IPAddr:[::1]>]
20
+ #
21
+ # @param hostname [String] The hostname to resolve
22
+ # @param address_family [Symbol] if set, limit search to +address_family+, +:inet+ or +:inet6+
23
+ # @return [Array<IPAddr>]
24
+ def resolv(hostname, address_family = nil)
25
+ if hostname.is_a?(IPAddr)
26
+ resolv_ipaddress(hostname, address_family)
27
+ else
28
+ resolv_hostname(hostname, address_family)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def resolv_ipaddress(address, address_family)
35
+ filter_af(address, address_family)
36
+ end
37
+
38
+ def filter_af(address, address_family)
39
+ return [] if address_family && !match_af?(address, address_family)
40
+
41
+ [address]
42
+ end
43
+
44
+ def match_af?(address, address_family)
45
+ (address.ipv6? && address_family == :inet6) ||
46
+ (address.ipv4? && address_family == :inet)
47
+ end
48
+
49
+ def resolv_hostname(hostname, address_family)
50
+ result = []
51
+ result += resolv_hostname_ipv6(hostname) if address_family.nil? || address_family == :inet6
52
+ result += resolv_hostname_ipv4(hostname) if address_family.nil? || address_family == :inet
53
+ raise "\"#{hostname}\" does not resolve to any valid IP#{@af_str[address_family]} address." if result.empty?
54
+
55
+ result
56
+ end
57
+
58
+ def resolv_hostname_ipv6(hostname)
59
+ resolv_hostname_record(hostname, Resolv::DNS::Resource::IN::AAAA)
60
+ end
61
+
62
+ def resolv_hostname_ipv4(hostname)
63
+ resolv_hostname_record(hostname, Resolv::DNS::Resource::IN::A)
64
+ end
65
+
66
+ def resolv_hostname_record(hostname, record)
67
+ @dns.getresources(hostname, record).collect { |r| IPAddr.new(r.address.to_s) }.sort
68
+ end
69
+
70
+ def initialize # :nodoc:
71
+ config = nil
72
+ @dns = Resolv::DNS.open(config)
73
+ @af_str = { inet: 'v4', inet6: 'v6' }
74
+ end
75
+ end
76
+ end
data/lib/puffy/rule.rb ADDED
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puffy
4
+ class AddressFamilyConflict < RuntimeError
5
+ end
6
+
7
+ # Abstract firewall rule.
8
+ class Rule
9
+ # @!attribute action
10
+ # The action to perform when the rule apply (+:accept+ or +:block+).
11
+ # @return [Symbol] Action
12
+ # @!attribute return
13
+ # Whether blocked packets must be returned to sender instead of being silently dropped.
14
+ # @return [Boolean] Return flag
15
+ # @!attribute dir
16
+ # The direction of the rule (+:in+ or +:out+).
17
+ # @return [Symbol] Direction
18
+ # @!attribute proto
19
+ # The protocol the Puffy::Rule applies to (+:tcp+, +:udp+, etc).
20
+ # @return [Symbol] Protocol
21
+ # @!attribute af
22
+ # The address family of the rule (+:inet6+ or +:inet+)
23
+ # @return [Symbol] Address family
24
+ # @!attribute on
25
+ # The interface the rule applies to.
26
+ # @return [String] Interface
27
+ # @!attribute in
28
+ # The interface packets must arrive on for the rule to apply in a forwarding context.
29
+ # @return [String] Interface
30
+ # @!attribute out
31
+ # The interface packets must be sent to for the rule to apply in a forwarding context.
32
+ # @return [String] Interface
33
+ # @!attribute from
34
+ # The packet source as a Hash for the rule to apply.
35
+ #
36
+ # :host:: address of the source host or network the rule apply to
37
+ # :port:: source port the rule apply to
38
+ # @return [Hash] Source
39
+ # @!attribute to
40
+ # The packet destination as a Hash for the rule to apply.
41
+ #
42
+ # :host:: address of the destination host or network the rule apply to
43
+ # :port:: destination port the rule apply to
44
+ # @return [Hash] Destination
45
+ # @!attribute nat_to
46
+ # The packet destination when peforming NAT.
47
+ # @return [IPAddr] IP Adress
48
+ # @!attribute rdr_to
49
+ # The destination as a Hash for redirections.
50
+ #
51
+ # :host:: address of the destination host or network the rule apply to
52
+ # :port:: destination port the rule apply to
53
+ # @return [Hash] Destination
54
+ # @!attribute no_quick
55
+ # Prevent the rule from being a quick one.
56
+ # @return [Boolean] Quick flag
57
+ attr_accessor :action, :return, :dir, :proto, :af, :on, :in, :out, :from, :to, :nat_to, :rdr_to, :no_quick
58
+
59
+ # Instanciate a firewall Puffy::Rule.
60
+ #
61
+ # +options+ is a Hash of the Puffy::Rule class attributes
62
+ #
63
+ # Rule.new({ action: :accept, dir: :in, proto: :tcp, to: { port: 80 } })
64
+ def initialize(options = {})
65
+ send_options(options)
66
+
67
+ @af = detect_af unless af
68
+
69
+ raise "unsupported action `#{options[:action]}'" unless valid_action?
70
+ raise 'if from_port or to_port is specified, the protocol must also be given' if port_without_protocol?
71
+ end
72
+
73
+ # Instanciate a forward Puffy::Rule.
74
+ #
75
+ # @param rule [Puffy::Rule] a NAT rule
76
+ #
77
+ # @return [Puffy::Rule]
78
+ def self.fwd_rule(rule)
79
+ res = rule.dup
80
+ res.on_to_in_out!
81
+ res.to.merge!(res.rdr_to.compact)
82
+ res.rdr_to = nil
83
+ res.dir = :fwd
84
+ res
85
+ end
86
+
87
+ # Return true if the rule is valid in an IPv4 context.
88
+ def ipv4?
89
+ af.nil? || af == :inet
90
+ end
91
+
92
+ # Return true if the rule has an IPv4 source or destination.
93
+ def implicit_ipv4?
94
+ from_ipv4? || to_ipv4? || rdr_to_ipv4? || (rdr_to && af == :inet)
95
+ end
96
+
97
+ # Return true if the rule is valid in an IPv6 context.
98
+ def ipv6?
99
+ af.nil? || af == :inet6
100
+ end
101
+
102
+ # Return true if the rule has an IPv6 source or destination.
103
+ def implicit_ipv6?
104
+ from_ipv6? || to_ipv6? || rdr_to_ipv6? || (rdr_to && af == :inet6)
105
+ end
106
+
107
+ # Return true if the rule is a filter rule.
108
+ def filter?
109
+ !nat? && !rdr?
110
+ end
111
+
112
+ # Returns whether the rule applies to incomming packets.
113
+ def in?
114
+ dir.nil? || dir == :in
115
+ end
116
+
117
+ # Returns whether the rule applies to outgoing packets.
118
+ def out?
119
+ dir.nil? || dir == :out
120
+ end
121
+
122
+ # Returns whether the rule performs Network Address Translation.
123
+ def nat?
124
+ nat_to
125
+ end
126
+
127
+ # Returns whether the rule is a redirection.
128
+ def rdr?
129
+ rdr_to_host || rdr_to_port
130
+ end
131
+
132
+ # Returns whether the rule performs forwarding.
133
+ def fwd?
134
+ dir == :fwd
135
+ end
136
+
137
+ # @!method from_host
138
+ # Returns the source host of the Puffy::Rule.
139
+ # @!method from_port
140
+ # Returns the source port of the Puffy::Rule.
141
+ # @!method to_host
142
+ # Returns the destination host of the Puffy::Rule.
143
+ # @!method to_port
144
+ # Returns the destination port of the Puffy::Rule.
145
+ # @!method rdr_to_host
146
+ # Returns the redirect destination host of the Puffy::Rule.
147
+ # @!method rdr_to_port
148
+ # Returns the redirect destination port of the Puffy::Rule.
149
+ %i[from to rdr_to].each do |destination|
150
+ %i[host port].each do |param|
151
+ define_method("#{destination}_#{param}") do
152
+ res = public_send(destination)
153
+ res && res[param]
154
+ end
155
+ end
156
+ end
157
+
158
+ # Setsthe #in / #out to #on depending on #dir.
159
+ #
160
+ # @return [void]
161
+ def on_to_in_out!
162
+ if dir == :in
163
+ self.in ||= on
164
+ else
165
+ self.out ||= on
166
+ end
167
+ self.on = nil
168
+ end
169
+
170
+ private
171
+
172
+ def valid_action?
173
+ [nil, :pass, :block].include?(action)
174
+ end
175
+
176
+ def port_without_protocol?
177
+ (from_port || to_port) && proto.nil?
178
+ end
179
+
180
+ def send_options(options)
181
+ options.each do |k, v|
182
+ send("#{k}=", v)
183
+ end
184
+ end
185
+
186
+ def detect_af
187
+ afs = collect_afs
188
+ return nil if afs.empty?
189
+ return afs.first if afs.one?
190
+
191
+ raise AddressFamilyConflict, "Incompatible address famlilies: #{afs}"
192
+ end
193
+
194
+ def collect_afs
195
+ %i[from_host to_host rdr_to_host].map do |method|
196
+ res = send(method)
197
+ if res.nil? then nil
198
+ elsif res.ipv4? then :inet
199
+ elsif res.ipv6? then :inet6
200
+ else raise 'Fail'
201
+ end
202
+ end.uniq.compact
203
+ end
204
+
205
+ # @!method from_ipv4?
206
+ # @!method from_ipv6?
207
+ # @!method to_ipv4?
208
+ # @!method to_ipv6?
209
+ # @!method rdr_to_ipv4?
210
+ # @!method rdr_to_ipv6?
211
+ %i[from to rdr_to].each do |destination|
212
+ %i[ipv4 ipv6].each do |ip_version|
213
+ define_method("#{destination}_#{ip_version}?") do
214
+ res = public_send("#{destination}_host")
215
+ res&.public_send("#{ip_version}?")
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puffy
4
+ # Puffy::Rule factory
5
+ class RuleFactory
6
+ # Initialize a Puffy::Rule factory.
7
+ def initialize
8
+ @af = nil
9
+ @resolver = Resolver.instance
10
+ load_services
11
+ end
12
+
13
+ # Limit the scope of a set of rules to IPv4 only.
14
+ def ipv4
15
+ raise 'Address familly already scopped' if @af
16
+
17
+ @af = :inet
18
+ yield
19
+ @af = nil
20
+ end
21
+
22
+ # Limit the scope of a set of rules to IPv6 only.
23
+ def ipv6
24
+ raise 'Address familly already scopped' if @af
25
+
26
+ @af = :inet6
27
+ yield
28
+ @af = nil
29
+ end
30
+
31
+ # Return an Array of Puffy::Rule for the provided +options+.
32
+ # @param [Hash] options
33
+ # @return [Array<Puffy::Rule>]
34
+ def build(options = {})
35
+ return [] if options == {}
36
+
37
+ options = { action: nil, return: false, dir: nil, af: nil, proto: nil, on: nil, from: { host: nil, port: nil }, to: { host: nil, port: nil }, nat_to: nil, rdr_to: { host: nil, port: nil } }.merge(options)
38
+
39
+ options = resolv_hostnames_and_ports(options)
40
+ instanciate_rules(options)
41
+ end
42
+
43
+ private
44
+
45
+ def resolv_hostnames_and_ports(options)
46
+ %i[from to rdr_to].each do |endpoint|
47
+ options[endpoint][:host] = host_lookup(options[endpoint][:host])
48
+ options[endpoint][:port] = port_lookup(options[endpoint][:port])
49
+ end
50
+ options[:nat_to] = host_lookup(options[:nat_to])
51
+ options
52
+ end
53
+
54
+ def instanciate_rules(options)
55
+ options.expand.map do |hash|
56
+ rule = Rule.new(hash)
57
+ rule if af_match_policy?(rule.af)
58
+ rescue AddressFamilyConflict
59
+ nil
60
+ end.compact
61
+ end
62
+
63
+ def load_services
64
+ @services = {}
65
+ File.readlines('/etc/services').each do |line|
66
+ line.sub!(/#.*/, '')
67
+ pieces = line.split
68
+ next if pieces.count < 2
69
+
70
+ port = pieces.delete_at(1).to_i
71
+ pieces.each do |piece|
72
+ @services[piece] = port
73
+ end
74
+ end
75
+ end
76
+
77
+ def af_match_policy?(af)
78
+ @af.nil? || af.nil? || af == @af
79
+ end
80
+
81
+ def host_lookup(host)
82
+ case host
83
+ when nil then nil
84
+ when IPAddr then host
85
+ when String then @resolver.resolv(host)
86
+ when Array then host.map { |x| @resolver.resolv(x) }.flatten
87
+ else
88
+ raise "Unexpected #{host.class.name}"
89
+ end
90
+ end
91
+
92
+ def port_lookup(port)
93
+ case port
94
+ when nil then nil
95
+ when Integer, Range then port
96
+ when String then real_port_lookup(port)
97
+ when Array then port.map { |x| port_lookup(x) }
98
+ else
99
+ raise "Unexpected #{port.class.name}"
100
+ end
101
+ end
102
+
103
+ def real_port_lookup(port)
104
+ res = port_is_a_number(port) || @services[port]
105
+
106
+ raise "unknown service \"#{port}\"" unless res
107
+
108
+ res
109
+ end
110
+
111
+ def port_is_a_number(port)
112
+ Integer(port)
113
+ rescue ArgumentError
114
+ nil
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puffy # :nodoc:
4
+ VERSION = '0.1.0'
5
+ end
data/lib/puffy.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'core_ext'
4
+
5
+ require 'puffy/parser.tab'
6
+ require 'puffy/formatters/base'
7
+ require 'puffy/formatters/netfilter'
8
+ require 'puffy/formatters/netfilter4'
9
+ require 'puffy/formatters/netfilter6'
10
+ require 'puffy/formatters/pf'
11
+ require 'puffy/puppet'
12
+ require 'puffy/resolver'
13
+ require 'puffy/rule'
14
+ require 'puffy/rule_factory'
15
+ require 'puffy/version'
16
+
17
+ module Puffy
18
+ class PuffyError < RuntimeError
19
+ def initialize(message, token)
20
+ super(message)
21
+ @token = token
22
+ end
23
+
24
+ def filename
25
+ @token[:filename]
26
+ end
27
+
28
+ def lineno
29
+ @token[:lineno]
30
+ end
31
+
32
+ def line
33
+ @token[:line]
34
+ end
35
+
36
+ def position
37
+ @token[:position]
38
+ end
39
+
40
+ def length
41
+ @token.fetch(:length, 1)
42
+ end
43
+
44
+ def extra
45
+ '~' * (length - 1)
46
+ end
47
+
48
+ def to_s
49
+ <<~MESSAGE
50
+ #{filename}:#{lineno}:#{position + 1}: #{super}
51
+ #{line}
52
+ #{' ' * position}^#{extra}
53
+ MESSAGE
54
+ end
55
+ end
56
+
57
+ class ParseError < PuffyError
58
+ end
59
+
60
+ class SyntaxError < PuffyError
61
+ end
62
+ end
data/puffy.gemspec ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/puffy/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'puffy'
7
+ spec.version = Puffy::VERSION
8
+ spec.authors = ['Romain Tartière']
9
+ spec.email = ['romain@blogreen.org']
10
+
11
+ spec.summary = 'Network firewall rules made easy!'
12
+ spec.homepage = 'https://github.com/opus-codium/puffy'
13
+ spec.license = 'MIT'
14
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0')
15
+
16
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org/'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = spec.homepage
20
+ spec.metadata['changelog_uri'] = spec.homepage
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } -
26
+ ['lib/puffy/parser.y'] +
27
+ ['lib/puffy/parser.tab.rb']
28
+ end
29
+ spec.bindir = 'bin'
30
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_runtime_dependency 'cri'
34
+ spec.add_runtime_dependency 'deep_merge'
35
+
36
+ spec.add_development_dependency 'aruba'
37
+ spec.add_development_dependency 'bundler'
38
+ spec.add_development_dependency 'cucumber'
39
+ spec.add_development_dependency 'racc'
40
+ spec.add_development_dependency 'rake'
41
+ spec.add_development_dependency 'rspec'
42
+ spec.add_development_dependency 'rubocop'
43
+ spec.add_development_dependency 'rubocop-rake'
44
+ spec.add_development_dependency 'rubocop-rspec'
45
+ spec.add_development_dependency 'simplecov'
46
+ spec.add_development_dependency 'timecop'
47
+ end