puffy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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