reyes 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- NzY1ODgzMDM4Yjg5ODIwZjMxNjAwNmY0OTQ5MTQ5NDM1YjExYjEyNw==
4
+ N2YwMTQ0NDA3OWVjMjQ3MTRkZWI4MTA3YTBlNWU3MTlhMDU5NzA2NA==
5
5
  data.tar.gz: !binary |-
6
- ODFmZTNiYTg5ZTU1YjI4NTk2NmE0MmQwN2U2ZTMxMWJlNjM3NDZjNQ==
6
+ MTM4NmQwZmVhY2EwOWJkZGQwODYzM2FkMDJiZGJkNDYyOTQ4NDFkZg==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- NmEzNmNiZmM5ZWZiOTA1YzgxN2I5ZjIwNDkxYTExZWZkYzVmNTQzMWM2OGE5
10
- YjkxYWFlZTkyY2JmYWY3ZmViZWQxOWMxMzY0NjI1ZWZkOTg3OGQwNzkxMDIz
11
- OTMyZDIwZDAwYjg2NGQwMmJiZTkxMTc1YzdiOGQ2MjdhMWY3YjA=
9
+ NzNlN2IwMmMxNmUyNjkxMGYzYjljMTgyMTUwMDU5MjIwOTlmYTdiZmVhODBi
10
+ OWVhYzJiM2FiNjMzM2U5N2Q0NzIwYmEyNzAwYjQyOWNjMGQ2ZWYwZDE0MDFm
11
+ NzgyZTcwNzlkMzY3NDhjMTYxMWY1YTViMTU1MzVhZjc2NzBmZTc=
12
12
  data.tar.gz: !binary |-
13
- YTM4YWVhYzhkOGUyNTcyNDgzZDQ5ZjdhZmViMzU2OGU0YzExY2RhZTI4M2Iz
14
- ZTBmYjg4ZDEzMDczOGQ5ODA1ZDI3NWVkMDg5ZWZjN2VhOGFlMjk0Nzk3OWU0
15
- OTM3YzIxYTZjNDEwYjhjNDkzY2Y5MzNjMWZmYjNmOTk2ODc3ODc=
13
+ YjY5MTg0Y2RlYjBhMzlkZjA5Yjc1ZDkyZDgwMzdmMDVjYWNlNjEzNmE2MDVj
14
+ MDMwMzBiZDg4Nzc5NWM1ZjUzNDkzNzQ3YzQwMWY1NGMxMmU4MzJjMTcyYWY5
15
+ ODY5YzIwYmVkYWNmYWI4NjZiZWZjNDM1ZmZkZjFiOGExYjNhZmY=
@@ -0,0 +1,2 @@
1
+ /pkg/
2
+ Gemfile.lock
@@ -0,0 +1,11 @@
1
+ Style/Documentation:
2
+ Enabled: false
3
+
4
+ Style/BracesAroundHashParameters:
5
+ Enabled: false
6
+
7
+ Style/EmptyLines:
8
+ Enabled: false
9
+
10
+ Style/EmptyLinesAroundClassBody:
11
+ Enabled: false
@@ -0,0 +1,17 @@
1
+ inherit_from:
2
+ - .rubocop-disables.yml
3
+
4
+ Style/SpaceAroundEqualsInParameterDefault:
5
+ EnforcedStyle: no_space
6
+
7
+ Style/TrailingComma:
8
+ EnforcedStyleForMultiline: comma
9
+
10
+ Style/SignalException:
11
+ EnforcedStyle: only_raise
12
+
13
+ AllCops:
14
+ Include:
15
+ - '**/Rakefile'
16
+ Exclude:
17
+ - Gemfile
data/README.md CHANGED
@@ -1,7 +1,17 @@
1
1
  Reyes
2
2
  =====
3
3
 
4
- ![Pt. Reyes Lighthouse](http://upload.wikimedia.org/wikipedia/commons/5/56/Point_Reyes_Lighthouse_%28April_2012%29.jpg)
4
+ ![Pt. Reyes Lighthouse](http://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Point_Reyes_Lighthouse_%28April_2012%29.jpg/1266px-Point_Reyes_Lighthouse_%28April_2012%29.jpg)
5
5
 
6
6
  Reyes populates IPTables firewall rules based on EC2 security group rules.
7
+ Named after the Pt. Reyes Lighthouse, which shines light through the fog,
8
+ preventing your ships from crashing on the rocks as they make their way to
9
+ port.
7
10
 
11
+ Use Case
12
+ --------
13
+
14
+ Reyes is designed to apply security group rules to IPsec VPN traffic that would
15
+ otherwise be injected past security group protection. This is useful for
16
+ enforcing firewalls on VPNs between EC2 instances and security groups in other
17
+ VPCs, even in other regions.
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require_relative '../lib/reyes'
4
+
5
+ def command_dump(options)
6
+ instance_id = options.fetch(:instance_id)
7
+ region = options.fetch(:region)
8
+ g = Reyes::GroupManager.new(region, instance_id, options[:config])
9
+
10
+ AWS.memoize do
11
+ puts g.do_stuff.to_yaml
12
+ end
13
+ end
14
+
15
+ def command_install(options)
16
+ instance_id = options.fetch(:instance_id)
17
+ region = options.fetch(:region)
18
+ AWS.memoize do
19
+ g = Reyes::GroupManager.new(region, instance_id, options[:config])
20
+
21
+ options[:run_options][:interactive] = true # TODO
22
+ options[:run_options][:log_accept] = true # TODO
23
+
24
+ g.run!(options.fetch(:run_options))
25
+
26
+ if options[:prune]
27
+ g.prune_ipsets
28
+ # g.prune_iptables_rules
29
+ end
30
+ end
31
+
32
+ end
33
+
34
+
35
+ def parse_args
36
+ options = {
37
+ :region => 'us-west-1',
38
+ :run_options => {},
39
+ }
40
+
41
+ optparse = OptionParser.new do |opts|
42
+ opts.banner = <<-EOM
43
+ usage: #{File.basename($0)} [options]
44
+
45
+ Manipulate IPTables rules based on EC2 security groups.
46
+
47
+ For example:
48
+
49
+ #{File.basename($0)} --prune --install $(facter -p ec2_instance_id)
50
+
51
+ Options:
52
+ EOM
53
+
54
+ opts.on('-c', '--config CONFIG', 'YAML config file') do |config|
55
+ options[:config] = config
56
+ end
57
+
58
+ opts.on('--region REGION', 'Set EC2 region') do |region|
59
+ options[:region] = region
60
+ end
61
+
62
+ opts.on('--prune', 'Prune old generations after creating rules') do
63
+ options[:prune] = true
64
+ end
65
+
66
+ opts.on('--install INSTANCE_ID', 'Configure this instance as INSTANCE_ID') do |id|
67
+ options[:command] = :install
68
+ options[:instance_id] = id
69
+ end
70
+
71
+ opts.on('--empty', 'Generate empty (default DROP) rules') do
72
+ options[:run_options][:empty] = true
73
+ end
74
+
75
+ opts.on('-h', '--help', 'Display this help message') do
76
+ STDERR.puts opts
77
+ exit 0
78
+ end
79
+ end
80
+
81
+ optparse.parse!
82
+
83
+ case options[:command]
84
+ when :dump
85
+ command_dump(options)
86
+ when :install
87
+ command_install(options)
88
+ else
89
+ STDERR.puts optparse
90
+ STDERR.puts "\nError: Must provide a command"
91
+ exit 1
92
+ end
93
+ end
94
+
95
+ parse_args
@@ -0,0 +1,13 @@
1
+ ---
2
+ aws:
3
+ credentials:
4
+ :access_key_id: AKIAAAAAAAAAAAAAAAAA
5
+ :secret_access_key: zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
6
+
7
+ regions:
8
+ - us-west-1
9
+ - us-west-2
10
+
11
+ vpcs:
12
+ - [us-west-2, vpc-abcdef12]
13
+
@@ -1,4 +1,19 @@
1
+ require 'set'
2
+ require 'yaml'
3
+
4
+ require 'chalk-log'
5
+ require 'subprocess'
6
+
1
7
  module Reyes
2
8
  end
3
9
 
4
10
  require_relative './reyes/version'
11
+ require_relative './reyes/errors'
12
+
13
+ require_relative './reyes/aws_manager'
14
+ require_relative './reyes/config'
15
+ require_relative './reyes/group_manager'
16
+ require_relative './reyes/group_tools'
17
+ require_relative './reyes/ipset'
18
+ require_relative './reyes/iptables'
19
+ require_relative './reyes/run_generation'
@@ -0,0 +1,116 @@
1
+ require 'aws-sdk'
2
+
3
+ module Reyes
4
+ class AwsManager
5
+ class Error < Reyes::Error; end
6
+
7
+ include Chalk::Log
8
+
9
+ def self.with_retries(retries=5, delay=2)
10
+ raise ArgumentError.new('Block is required') unless block_given?
11
+ begin
12
+ yield
13
+ rescue AWS::EC2::Errors::RequestLimitExceeded => err
14
+ log.warn(err.inspect)
15
+ if retries > 0
16
+ retries -= 1
17
+ sleep delay
18
+ retry
19
+ else
20
+ raise
21
+ end
22
+ end
23
+ end
24
+
25
+ def initialize(config_path=nil)
26
+ @config ||= Reyes::Config.new(config_path)
27
+ end
28
+
29
+ def ec2(region)
30
+ connections.fetch(:ec2).fetch(region)
31
+ end
32
+
33
+ def connections
34
+ @connections ||= connect!
35
+ end
36
+
37
+ def regions
38
+ aws_config.fetch('regions')
39
+ end
40
+
41
+ def vpcs
42
+ return @vpcs if @vpcs
43
+
44
+ @vpcs = aws_config.fetch('vpcs').map do |row|
45
+ unless row.length == 2
46
+ raise Error.new("Invalid VPC row: #{row.inspect}")
47
+ end
48
+
49
+ region, vpc_id = row
50
+ vpc = ec2(region).vpcs[vpc_id]
51
+
52
+ # warm cidr_block cache & ensure VPC exists
53
+ vpc.cidr_block
54
+
55
+ vpc
56
+ end
57
+ end
58
+
59
+ def security_groups(region)
60
+ ec2(region).security_groups.to_a
61
+ end
62
+
63
+ # Look up foreign VPC security groups by name.
64
+ #
65
+ # Does not use native AWS API filtering to promote better cache behavior.
66
+ #
67
+ # @param name [String]
68
+ # @return [Array<AWS::EC2::SecurityGroup>]
69
+ #
70
+ def vpc_security_groups_by_name(name)
71
+ unless name.is_a?(String)
72
+ raise ArgumentError.new("#{name.inspect} must be a String")
73
+ end
74
+
75
+ vpcs.map { |vpc|
76
+ vpc.security_groups.find_all {|sg| sg.name == name }
77
+ }.flatten
78
+ end
79
+
80
+ def warm_sg_cache
81
+ connections.fetch(:ec2).each_pair do |region, ec2|
82
+ log.debug("Warming security group cache for #{region}")
83
+ ec2.security_groups.to_a
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def aws_config
90
+ @config.aws_config
91
+ end
92
+
93
+ def connect!
94
+ conns = {
95
+ ec2: {},
96
+ }
97
+
98
+ regions.each do |region|
99
+ conns[:ec2][region] = connect_class(AWS::EC2, region)
100
+ end
101
+
102
+ conns
103
+ end
104
+
105
+ def connect_class(klass, region)
106
+ opts = {
107
+ region: region,
108
+ access_key_id: @config.aws_credentials.fetch(:access_key_id),
109
+ secret_access_key: @config.aws_credentials.fetch(:secret_access_key),
110
+ logger: Chalk::Log::Logger.new("#{klass.name}<#{region}>"),
111
+ }
112
+
113
+ klass.new(opts)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,21 @@
1
+ require 'yaml'
2
+
3
+ module Reyes
4
+ class Config
5
+ def initialize(path=nil)
6
+ @path = path || File.expand_path('../../../config.yaml', __FILE__)
7
+ end
8
+
9
+ def aws_config
10
+ config.fetch('aws')
11
+ end
12
+
13
+ def aws_credentials
14
+ aws_config.fetch('credentials')
15
+ end
16
+
17
+ def config
18
+ @config ||= YAML.load_file(@path)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module Reyes
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,414 @@
1
+ module Reyes
2
+
3
+ # TODO: use a more precise name
4
+ class GroupManager
5
+ include Chalk::Log
6
+
7
+ # Short names for AWS regions to save space in ipset names
8
+ RegionShortNames = {
9
+ 'us-east-1' => 'VA',
10
+ 'us-west-2' => 'OR',
11
+ 'us-west-1' => 'CA',
12
+ 'eu-west-1' => 'IE',
13
+ 'eu-central-1' => 'DE',
14
+ 'ap-southeast-1' => 'SG',
15
+ 'ap-southeast-2' => 'AU',
16
+ 'ap-northeast-1' => 'JP',
17
+ 'sa-east-1' => 'BR',
18
+ 'us-gov-west-1' => 'GV',
19
+ 'cn-north-1' => 'CN',
20
+ }
21
+
22
+ ReyesInputChain = 'reyes-ipsec-input'
23
+
24
+ attr_reader :aws
25
+
26
+ def initialize(region, instance_id, config_file=nil)
27
+ log.info("Initializing #{self.class.name} for #{region} #{instance_id}")
28
+
29
+ @aws = Reyes::AwsManager.new(config_file)
30
+ @instance_id = instance_id
31
+ @instance = @aws.ec2(region).instances[instance_id]
32
+ end
33
+
34
+ def our_groups
35
+ @our_groups ||= Reyes::AwsManager.with_retries {
36
+ @instance.security_groups
37
+ }
38
+ end
39
+
40
+ # @param [Hash] options
41
+ #
42
+ # @option options :empty [Boolean] (false) Generate an empty (default DROP)
43
+ # rule sets without actually looking up security groups
44
+ # @option options :interactive [Boolean] (false) Whether to prompt for
45
+ # confirmation before applying rules
46
+ # @option options :log_accept [Boolean] (false) Whether to log packets on
47
+ # ACCEPT
48
+ #
49
+ def run!(options={})
50
+ options = {
51
+ empty: false,
52
+ interactive: false,
53
+ log_accept: false,
54
+ }.merge(options)
55
+
56
+ log_accept = options.fetch(:log_accept)
57
+
58
+ log.info("Starting iptables rule generation run!")
59
+
60
+ if options.fetch(:empty)
61
+ log.warn("Generating empty (default DROP) rule set")
62
+ data = generate_rules_empty
63
+ else
64
+ data = generate_rules
65
+ end
66
+
67
+ new_rules = generate_iptables_script_file(data, log_accept: log_accept)
68
+ new_ipsets = generate_ipsets(data)
69
+
70
+ show_iptables_diff(new_rules)
71
+ show_ipsets_diff(new_ipsets)
72
+
73
+ if options.fetch(:interactive)
74
+ puts 'Press enter to continue...'
75
+ STDIN.gets
76
+ end
77
+
78
+ materialize_ipsets(new_ipsets)
79
+ iptables_restore(new_rules)
80
+
81
+ # XXX(richo) Should we be pruning inside run! ?
82
+ log.info('Finished firewall configuration run')
83
+ end
84
+
85
+ # TODO: actually do some kind of diff or logging here?
86
+ def show_iptables_diff(new_rules)
87
+ log.info("Old rules:")
88
+ Subprocess.check_call(%w{iptables-save})
89
+
90
+ log.info("New rules:")
91
+ puts new_rules
92
+ end
93
+
94
+ def iptables_restore(new_rules)
95
+ log.info("Restoring #{new_rules.count("\n")} lines of iptables rules")
96
+
97
+ log.info('+ iptables-restore')
98
+ Subprocess.check_call(['iptables-restore'],
99
+ stdin: Subprocess::PIPE) do |p|
100
+ p.communicate(new_rules)
101
+ end
102
+ log.info('restored')
103
+ end
104
+
105
+ def generate_rules_empty
106
+ {:groups => {}, :ipsets => {}}
107
+ end
108
+
109
+ # Given our instance ID and security group rules, generate IPTables rules
110
+ # needed to emulate security group behavior for foreign VPCs.
111
+ #
112
+ # Look up the set of instance IP addresses in any remote VPC security
113
+ # groups referenced by our rules, and add them to a list of ipsets to
114
+ # create.
115
+ #
116
+ # Also create a list of IPTables rules that will reference these ipsets.
117
+ #
118
+ # This method generates the data and returns it as a hash without making
119
+ # any changes to the system beyond incrementing the run generation (used
120
+ # for ipset garbage collection).
121
+ #
122
+ def generate_rules
123
+ run_generation_increment!
124
+ log.info("Generating rules for generation #{run_generation}")
125
+
126
+ data = generate_rules_empty
127
+
128
+ needed_groups = {}
129
+
130
+ our_groups.each do |group|
131
+ group_key = "#{group.group_id}:#{group.name}"
132
+ data[:groups][group_key] = []
133
+
134
+ # we only work with ingress permissions
135
+ group.ingress_ip_permissions.each do |perm|
136
+ perm_hash = {
137
+ protocol: perm.protocol,
138
+ port: perm.port_range,
139
+ remote_addrs: [],
140
+ remote_sets: [],
141
+ }
142
+
143
+ case perm.protocol
144
+ when :icmp
145
+ log.info("Skipping ICMP rule")
146
+ next
147
+ when :tcp, :udp
148
+
149
+ # append IP ranges
150
+ perm_hash[:remote_addrs] += perm.ip_ranges
151
+
152
+ # list the security groups we'll need to look up
153
+ perm.groups.each do |g|
154
+ foreign = foreign_groups_by_name(g.name)
155
+ foreign.each do |fg|
156
+ needed_groups[fg.group_id] = fg
157
+ perm_hash[:remote_sets] << ipset_name_for_group(fg)
158
+ end
159
+ end
160
+
161
+ else
162
+ raise Error.new("Unexpected protocol #{perm.protocol.inspect}")
163
+ end
164
+
165
+ data[:groups][group_key] << perm_hash
166
+ end
167
+ end
168
+
169
+ data[:ipsets] = {}
170
+ needed_groups.each_value do |group|
171
+ data[:ipsets][ipset_name_for_group(group)] = \
172
+ addresses_for_group(group)
173
+ end
174
+
175
+ data
176
+ end
177
+
178
+ def generate_ipsets(data)
179
+ data.fetch(:ipsets).map do |name, hosts|
180
+ log.info "Creating IPSet for #{name.inspect}"
181
+ builder = IPSetBuilder.new(name)
182
+ hosts.each do |host|
183
+ log.info " - #{host.inspect}"
184
+ builder << host
185
+ end
186
+
187
+ builder
188
+ end
189
+ end
190
+
191
+ def show_ipsets_diff(new_ipsets)
192
+ # TODO(richo)
193
+ end
194
+
195
+ def materialize_ipsets(new_ipsets)
196
+ new_ipsets.each do |ipset|
197
+ ipset.build
198
+ end
199
+ end
200
+
201
+ # TODO: delurk
202
+ def create_iptables_rules(data)
203
+ data.fetch(:groups).each do |cluster, items|
204
+ log.info "Creating rules for #{cluster}"
205
+ items.each do |item|
206
+ rules = Reyes::IPTables.generate_rules_from_hash(item)
207
+ rules.each do |rule|
208
+ log.info(" #{rule.cmd.join(" ")}")
209
+ rule.materialize
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ def prune_ipsets
216
+ log.info('Pruning old IPSets')
217
+ old_ipsets = []
218
+ current_ipsets = []
219
+
220
+ current_gen = run_generation
221
+ Reyes::IPSet.load_all.each do |set|
222
+ s = Reyes::GroupManager.parse_ipset_name(set.name)
223
+ unless s
224
+ log.warn("Skipping unparseable ipset name #{set.name.inspect}")
225
+ next
226
+ end
227
+
228
+ if s[:generation] < current_gen
229
+ old_ipsets << set
230
+ elsif s[:generation] == current_gen
231
+ current_ipsets << set
232
+ else
233
+ log.error("IPSet from a future generation detected: #{set.inspect}")
234
+ log.error("Cowardly refusing to proceed")
235
+ raise Reyes::Error.new("IPSet from future generation detected")
236
+ end
237
+ end
238
+
239
+ if current_ipsets.empty?
240
+ log.error("No IPSets from current generation.")
241
+ log.error("Cowardly refusing to proceed")
242
+ raise Reyes::Error.new("Pruning would remove all IPSets")
243
+ end
244
+
245
+ old_ipsets.each do |set|
246
+ log.info("Pruning IPSet: #{set.name}")
247
+ set.drop!
248
+ end
249
+ end
250
+
251
+ # @return [Integer]
252
+ def run_generation
253
+ @generation ||= Reyes::RunGeneration.new
254
+ @generation.value
255
+ end
256
+
257
+ # Increment the run generation and persist it to disk
258
+ def run_generation_increment!
259
+ run_generation
260
+ @generation.increment!
261
+ end
262
+
263
+ # @return [String] A string title, at most 31 characters long
264
+ def ipset_name_for_group(group)
265
+ [
266
+ run_generation.to_s,
267
+ RegionShortNames.fetch(group.client.instance_variable_get(:@region)),
268
+ group.group_id,
269
+ group.name,
270
+ ].join(':')[0...31]
271
+ end
272
+
273
+ # @return [Hash] the parsed names, or nil in case of error
274
+ def self.parse_ipset_name(name)
275
+ parts = name.split(":")
276
+ return nil unless parts.length == 4
277
+
278
+ generation, region, group_id, group_name = parts
279
+ return {
280
+ generation: Integer(generation),
281
+ region: region,
282
+ group_id: group_id,
283
+ group_name: group_name
284
+ }
285
+ end
286
+
287
+ # Look up addresses for given group in remote VPCs.
288
+ #
289
+ # @param group [AWS::EC2::SecurityGroup]
290
+ #
291
+ # @return [Array<String>] A list of private instance IP addresses
292
+ #
293
+ def addresses_for_group(group)
294
+ groups = foreign_groups_by_name(group.name)
295
+ groups.map { |g| g.instances.map(&:private_ip_address) }.flatten
296
+ end
297
+
298
+ # Look up remote VPC security groups by name.
299
+ #
300
+ # @param name [String]
301
+ #
302
+ # @return [Array<AWS::EC2::SecurityGroup>]
303
+ #
304
+ def foreign_groups_by_name(name)
305
+ aws.vpc_security_groups_by_name(name)
306
+ end
307
+
308
+ # @param [Hash] data
309
+ #
310
+ # @param [Hash] options
311
+ #
312
+ # @option options [Boolean] log_drop (true)
313
+ # @option options [Boolean] log_accept (false)
314
+ #
315
+ def generate_iptables_script_file(data, options={})
316
+ log.info("Generating script for iptables-restore")
317
+
318
+ options = {
319
+ log_drop: true,
320
+ log_accept: false,
321
+ }.merge(options)
322
+
323
+ log_drop = options.fetch(:log_drop)
324
+ log_accept = options.fetch(:log_accept)
325
+
326
+ lines = []
327
+
328
+ lines << "# Generated by Reyes v#{Reyes::VERSION} at #{Time.now.to_s}"
329
+
330
+ # we use the default filter table
331
+ lines << '*filter'
332
+
333
+ lines << ':INPUT ACCEPT'
334
+ lines << ':FORWARD DROP'
335
+ lines << ':OUTPUT ACCEPT'
336
+ lines << ''
337
+
338
+ lines << ":#{ReyesInputChain} -"
339
+ lines << ':reyes-log-drop -'
340
+ lines << ':reyes-log-accept -'
341
+ lines << ':reyes-accept -'
342
+ lines << ':reyes-drop -'
343
+ lines << ''
344
+
345
+ lines << IPTables.log_rule_string('reyes-log-drop', 'REYES BLOCK')
346
+ lines << IPTables.log_rule_string('reyes-log-accept', 'REYES ACCEPT')
347
+
348
+ lines << '-A reyes-drop -j reyes-log-drop' if log_drop
349
+ lines << '-A reyes-drop -j DROP'
350
+ lines << '-A reyes-accept -j reyes-log-accept' if log_accept
351
+ lines << '-A reyes-accept -j ACCEPT'
352
+
353
+ # filter all ipsec tunneled traffic through reyes
354
+ lines << "-A INPUT -m policy --pol ipsec --dir in -j #{ReyesInputChain}"
355
+
356
+ # allow normal ICMP traffic
357
+ IPTables.innocuous_icmp_rules(ReyesInputChain).each do |r|
358
+ lines << r.join(' ')
359
+ end
360
+
361
+ # allow established connections without logging
362
+ lines << "-A #{ReyesInputChain} -m conntrack --ctstate " \
363
+ "RELATED,ESTABLISHED -j ACCEPT"
364
+
365
+ # drop invalid connections
366
+ lines << "-A #{ReyesInputChain} -m conntrack --ctstate " \
367
+ "INVALID -j reyes-drop"
368
+
369
+ # add dynamic rules
370
+ lines << ''
371
+ lines << '# dynamic rules from security groups'
372
+ dynamic_rules_from_data(data).each do |rule|
373
+ lines << rule
374
+ end
375
+
376
+ # add reyes-drop to very end of reyes-ipsec-input chain
377
+ lines << ''
378
+ lines << '# default drop'
379
+ lines << "-A #{ReyesInputChain} -j reyes-drop"
380
+
381
+ # commit the filter table
382
+ lines << ''
383
+ lines << 'COMMIT'
384
+
385
+ text = lines.join("\n")
386
+ text << "\n"
387
+
388
+ text
389
+ end
390
+
391
+ private
392
+
393
+ def dynamic_rules_from_data(data)
394
+ log.info("Generating dynamic iptables rules")
395
+
396
+ rule_list = []
397
+
398
+ data.fetch(:groups).each do |cluster, items|
399
+ log.info "Generating rules for #{cluster}"
400
+ items.each do |item|
401
+ rules = Reyes::IPTables.generate_rules_from_hash(item,
402
+ ReyesInputChain,
403
+ 'reyes-accept')
404
+ rules.each do |rule|
405
+ log.debug(' ' + rule.inspect)
406
+ rule_list << rule.join(' ')
407
+ end
408
+ end
409
+ end
410
+
411
+ rule_list
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,145 @@
1
+ module Reyes
2
+ # A few methods for manipulating EC2 SecurityGroup and IpPermission objects
3
+ # that really should be in the aws-sdk. These are taken directly from
4
+ # space-commander.
5
+ module GroupTools
6
+
7
+ # Print a pretty representation of an AWS::EC2::SecurityGroup::IpPermission
8
+ # object. May issue API calls to resolve security group names.
9
+ #
10
+ # @param [AWS::EC2::SecurityGroup::IpPermission] perm Object to inspect
11
+ #
12
+ # @param [Hash] options
13
+ #
14
+ # @option options [Integer] :indent Indentation level (default 0)
15
+ #
16
+ # @option options [Boolean] :include_group Whether to print the security
17
+ # group to which the IpPermission belongs. (default true)
18
+ #
19
+ # @option options [SpaceCommander::SecurityGroup::Cache] :cache A cache to
20
+ # use for resolving security group names as needed.
21
+ #
22
+ def self.pretty_perm(perm, options={})
23
+ options = {:indent => 0, :include_group => true}.merge(options)
24
+ indent = options.fetch(:indent)
25
+ include_group = options.fetch(:include_group)
26
+
27
+ # use cache to find groups if one was provided
28
+ if options[:cache]
29
+ groups = options[:cache].unsafe_get_many(perm.groups)
30
+ else
31
+ groups = perm.groups
32
+ end
33
+
34
+ lines = []
35
+ group = perm.security_group
36
+ lines << "security_group: #{group.name} (#{group.id})" if include_group
37
+ lines.concat([
38
+ "type: #{(perm.egress? ? :egress : :ingress)}",
39
+ "proto: #{perm.protocol.inspect}",
40
+ "ports: #{perm.port_range.inspect}",
41
+ "cidr: #{perm.ip_ranges.inspect}",
42
+ "groups: [#{groups.map{|g| "<#{g.name} #{g.id}>"}.join(", ")}]"
43
+ ])
44
+ return lines.map{|line| ' '*indent + line}.join("\n")
45
+ end
46
+
47
+ # Inspect an AWS::EC2::SecurityGroup::IpPermission object. May issue API
48
+ # calls to resolve security group names.
49
+ #
50
+ # @param [AWS::EC2::SecurityGroup::IpPermission] perm Object to inspect
51
+ #
52
+ # @param [Hash] options
53
+ #
54
+ # @option options [SpaceCommander::SecurityGroup::Cache] :cache A cache to
55
+ # use for resolving security group names as needed.
56
+ #
57
+ def self.inspect_perm(perm, options={})
58
+ unless perm.is_a?(AWS::EC2::SecurityGroup::IpPermission)
59
+ raise ArgumentError.new("Not an IpPermission: #{perm.inspect}")
60
+ end
61
+
62
+ s = "#<IpPermission #{perm.egress? ? :egress : :ingress}"
63
+
64
+ s << " @security_group=<#{perm.security_group.id} #{perm.security_group.name}>"
65
+ s << " @protocol=#{perm.protocol.inspect}"
66
+ s << " @port_range=#{perm.port_range.inspect}"
67
+
68
+ unless perm.ip_ranges.empty?
69
+ s << " @ip_ranges=#{perm.ip_ranges.inspect}"
70
+ end
71
+
72
+ unless perm.groups.empty?
73
+ # use cache to find groups if one was provided
74
+ if options[:cache]
75
+ groups = options[:cache].unsafe_get_many(perm.groups)
76
+ else
77
+ groups = perm.groups
78
+ end
79
+
80
+ s << " @groups=[#{groups.map{|g| "<#{g.name} #{g.id}>"}.join(", ")}]"
81
+ end
82
+ s
83
+ end
84
+
85
+ def self.perm_to_hash(perm)
86
+ h = {}
87
+
88
+ h[:protocol] = perm.protocol.to_s
89
+ h[:label] = ''
90
+
91
+ port = perm.port_range
92
+ if port == all_ports(perm.protocol)
93
+ h[:port] = 'all'
94
+ else
95
+ if port.first == port.last
96
+ h[:port] = port.first
97
+ else
98
+ h[:port] = "#{port.first}-#{port.last}"
99
+ end
100
+ end
101
+
102
+ # TODO: convert /32s back into hostname from config.yaml if possible
103
+ if perm.ip_ranges.length > 1
104
+ h[:cidr] = perm.ip_ranges
105
+ elsif perm.ip_ranges.length == 1
106
+ h[:cidr] = perm.ip_ranges.first
107
+ if h[:cidr] == '0.0.0.0/0'
108
+ h[:cidr] = 'all'
109
+ end
110
+ end
111
+
112
+ if perm.groups.length > 0
113
+ h[:groups] = perm.groups.map(&:name)
114
+ end
115
+
116
+ h
117
+ end
118
+
119
+ def self.group_to_hash(group)
120
+ h = {
121
+ :name => group.name,
122
+ :description => group.description,
123
+ :inbound => group.ingress_ip_permissions.map {|p| perm_to_hash(p)},
124
+ :outbound => group.egress_ip_permissions.map {|p| perm_to_hash(p)},
125
+ }
126
+
127
+ h
128
+ end
129
+
130
+ def self.all_ports(protocol)
131
+ case protocol
132
+ when :icmp
133
+ -1..-1
134
+ when :tcp, :udp
135
+ 0..65535
136
+ when :any, :'-1', -1
137
+ nil
138
+ else
139
+ msg = "Don't know how to allow 'all' ports for " + protocol.inspect
140
+ raise NotImplementedError.new(msg)
141
+ end
142
+ end
143
+
144
+ end
145
+ end
@@ -0,0 +1,118 @@
1
+ module Reyes
2
+ class IPSetBuilder
3
+ TYPE = "hash:ip"
4
+ attr_reader :name
5
+
6
+ # Constructor for ipsets, maintaining the invariant that once constructed,
7
+ # ipsets will not be altered.
8
+ # @param [String] name of the IPSet to create
9
+ def initialize(name)
10
+ @name = name
11
+ @members = []
12
+ end
13
+
14
+ # Add a new member to this set. `member` should be something an ipset can
15
+ # understand, eg an ip address or cidr range
16
+ # @param [String] entity to add to the set
17
+ def <<(entity)
18
+ @members << entity
19
+ end
20
+
21
+ # Builds the ipset, retuning an IPSet
22
+ # @returns [IPSet]
23
+ def build
24
+ create
25
+ populate
26
+ IPSet.load(@name)
27
+ end
28
+
29
+ private
30
+
31
+ def create
32
+ begin
33
+ Subprocess.check_call(["ipset", "create", @name, TYPE])
34
+ rescue Subprocess::NonZeroExit
35
+ raise Error.new("Couldn't create ipset #@name")
36
+ end
37
+ end
38
+
39
+ def populate
40
+ @members.each do |e|
41
+ begin
42
+ Subprocess.check_call(["ipset", "add", @name, e])
43
+ rescue Subprocess::NonZeroExit
44
+ raise Error.new("Couldn't add #{e} to ipset #@name")
45
+ end
46
+ end
47
+ end
48
+
49
+ class Error < StandardError
50
+ end
51
+ end
52
+
53
+ class IPSet < Struct.new(:name, :type, :header, :size, :references, :members)
54
+ # An immutable view into an existing IPSet
55
+
56
+ # @param [Name] name of the set to load
57
+ # @returns IPSet
58
+ def self.load(name)
59
+ new(*load_by_name(name))
60
+ end
61
+
62
+ # Loads all IPSets from the local system
63
+ # @returns [Array<IPSet>]
64
+ def self.load_all
65
+ cmd = ['ipset', 'list']
66
+ output = Subprocess.check_output(cmd)
67
+ return output.split("\n\n").map { |x| new(*parse(x)) }
68
+ end
69
+
70
+ # Drops the currect IPSet, destroying it on the underlying system
71
+ def drop!
72
+ begin
73
+ Subprocess.check_call(["ipset", "destroy", name])
74
+ rescue Subprocess::NonZeroExit
75
+ raise Error.new("Couldn't destroy #{name}")
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def self.load_by_name(name)
82
+ cmd = ['ipset', 'list', name]
83
+ output = Subprocess.check_output(cmd)
84
+ entries = output.split("\n\n")
85
+ unless entries.length == 1
86
+ raise IPSetError.new("`ipset list #{name}` returned too many entries")
87
+ end
88
+
89
+ return parse(entries.first)
90
+ end
91
+
92
+ KEYS = ["Name", "Header", "Size in memory", "References", "Members", "Type"]
93
+ def self.parse(entry)
94
+ out = {:members => []}
95
+ entry.split(?\n).each do |line|
96
+ k, v = line.split(":", 2)
97
+ if KEYS.include?(k)
98
+ out[k] = v.strip
99
+ else
100
+ raise Error.new("Invalid data: #{entry.inspect}") unless out.include?("Members")
101
+ out[:members] << line.strip
102
+ end
103
+ end
104
+
105
+ return [
106
+ out["Name"],
107
+ out["Type"],
108
+ out["Header"],
109
+ Integer(out["Size in memory"]),
110
+ Integer(out["References"]),
111
+ out[:members],
112
+ ]
113
+ end
114
+
115
+ class Error < StandardError
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,87 @@
1
+ module Reyes
2
+ # A collection of functionality related to generating IPTables rule sets.
3
+ module IPTables
4
+
5
+ # Generate IPTables rule arguments based on a specification and yield a
6
+ # series of arguments appropriate for passing to IPTables.
7
+ #
8
+ # @param [Symbol] protocol
9
+ # @param [Range] dport_range
10
+ # @param [Array<String>] remote_addrs
11
+ # @param [Array<String>] remote_sets
12
+ # @param [String] input_chain
13
+ # @param [String] accept_chain
14
+ #
15
+ # @yield [Array<String>] IPTables rules argument array
16
+ #
17
+ def self.generate_rules(protocol, dport_range, remote_addrs, remote_sets,
18
+ input_chain, accept_chain)
19
+ raise 'Unsupported protocol' unless [:tcp, :udp].include?(protocol)
20
+
21
+ unless block_given?
22
+ return enum_for(__method__, protocol, dport_range, remote_addrs,
23
+ remote_sets, input_chain, accept_chain)
24
+ end
25
+
26
+ cmd = ['-A', input_chain, '-p', protocol.to_s]
27
+
28
+ if dport_range.first == dport_range.last
29
+ # single port match
30
+ cmd += ['--dport', dport_range.first.to_s]
31
+ else
32
+ # port range match
33
+ cmd += ['-m', 'multiport', '--dports',
34
+ "#{dport_range.first}:#{dport_range.last}"]
35
+ end
36
+
37
+ jump_args = ['-j', accept_chain]
38
+
39
+ remote_addrs.each do |addr|
40
+ yield cmd + ['-s', addr] + jump_args
41
+ end
42
+
43
+ remote_sets.each do |set|
44
+ yield cmd + ['-m', 'set', '--match-set', set, 'src,dst'] + jump_args
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ # Generate IPTables rules from a hash. This is a thin wrapper around
51
+ # {.generate_rules}.
52
+ #
53
+ # @param [Hash] hash
54
+ # @param [String] input_chain
55
+ # @param [String] accept_chain
56
+ #
57
+ def self.generate_rules_from_hash(hash, input_chain, accept_chain)
58
+ generate_rules(hash.fetch(:protocol), hash.fetch(:port),
59
+ hash.fetch(:remote_addrs), hash.fetch(:remote_sets),
60
+ input_chain, accept_chain)
61
+ end
62
+
63
+ class Rule
64
+ IPTABLES = "iptables"
65
+ attr_reader :cmd
66
+ def initialize(cmd)
67
+ @cmd = cmd
68
+ end
69
+
70
+ def materialize
71
+ cmd = [IPTABLES] + @cmd
72
+ Subprocess.check_call(cmd)
73
+ end
74
+ end
75
+
76
+ def self.log_rule_string(chain, message, limit='3/min', limit_burst=10)
77
+ "-A #{chain} -m limit --limit #{limit} --limit-burst #{limit_burst}" \
78
+ " -j LOG --log-prefix \"[#{message}] \""
79
+ end
80
+
81
+ def self.innocuous_icmp_rules(chain)
82
+ [3, 4, 11, 12, 8].map do |type|
83
+ %W{-A #{chain} -p icmp -m icmp --icmp-type #{type} -j ACCEPT}
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,75 @@
1
+ module Reyes
2
+ # An abstraction over tmpfile based generation number storage.
3
+ class RunGeneration
4
+ class LoadError < Reyes::Error; end
5
+
6
+ include Chalk::Log
7
+
8
+ DefaultGenerationPath = "/tmp/reyes.u#{Process.euid}.generation"
9
+
10
+ attr_reader :value
11
+
12
+ # @param path [String] Path to generation file
13
+ #
14
+ # If the file at `path` does not exist, it will be created. Attempting to
15
+ # open a symbolic link will raise Errno::ELOOP to avoid security
16
+ # vulnerabilities.
17
+ #
18
+ def initialize(path=DefaultGenerationPath)
19
+ @path = path
20
+ reload
21
+ end
22
+
23
+ # Increment the generation value and save it to disk.
24
+ #
25
+ # @return [Integer] new generation value
26
+ #
27
+ def increment!
28
+ @value += 1
29
+ log.debug("Incrementing generation to #{@value}")
30
+ save
31
+ @value
32
+ end
33
+
34
+ private
35
+
36
+ def reload
37
+ log.debug("Opening generation file at #{@path.inspect}")
38
+ @fh = open(@path)
39
+ if @fh.size == 0
40
+ @value = 0
41
+ else
42
+ @value = load_value
43
+ end
44
+ log.debug("Loaded generation value #{@value.inspect}")
45
+ end
46
+
47
+ # Open `path` read-write, creating it if it doesn't exist, refusing to
48
+ # follow symbolic links in the final path component.
49
+ #
50
+ # @param [String] path
51
+ #
52
+ # @raise [SystemCallError] in various circumstances involving failure to
53
+ # open or create the file.
54
+ #
55
+ def open(path)
56
+ File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW)
57
+ end
58
+
59
+ def load_value
60
+ @fh.rewind
61
+ content = @fh.read
62
+ if content.empty?
63
+ 0
64
+ else
65
+ Integer(content)
66
+ end
67
+ end
68
+
69
+ def save
70
+ @fh.rewind
71
+ @fh.puts(@value.to_s)
72
+ @fh.flush
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,3 @@
1
1
  module Reyes
2
- VERSION = '0.0.1' unless defined?(self::VERSION)
2
+ VERSION = '0.0.2' unless defined?(self::VERSION)
3
3
  end
@@ -35,7 +35,10 @@ Gem::Specification.new do |gem|
35
35
  gem.version = Reyes::VERSION
36
36
 
37
37
  gem.add_dependency 'aws-sdk', '~> 1.60'
38
+ gem.add_dependency 'chalk-log'
39
+ gem.add_dependency 'subprocess'
38
40
 
39
41
  gem.add_development_dependency 'pry'
40
42
  gem.add_development_dependency 'rake'
43
+ gem.add_development_dependency 'rubocop'
41
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reyes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Brody
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2015-01-29 00:00:00.000000000 Z
12
+ date: 2015-02-12 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: aws-sdk
@@ -25,6 +25,34 @@ dependencies:
25
25
  - - ~>
26
26
  - !ruby/object:Gem::Version
27
27
  version: '1.60'
28
+ - !ruby/object:Gem::Dependency
29
+ name: chalk-log
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ! '>='
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ! '>='
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: subprocess
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ! '>='
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ! '>='
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
28
56
  - !ruby/object:Gem::Dependency
29
57
  name: pry
30
58
  requirement: !ruby/object:Gem::Requirement
@@ -53,18 +81,46 @@ dependencies:
53
81
  - - ! '>='
54
82
  - !ruby/object:Gem::Version
55
83
  version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rubocop
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ! '>='
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ! '>='
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
56
98
  description: ! " Reyes is a gem...\n\n TO DO\n"
57
99
  email:
58
100
  - security@stripe.com
59
- executables: []
101
+ executables:
102
+ - reyes
60
103
  extensions: []
61
104
  extra_rdoc_files: []
62
105
  files:
106
+ - .gitignore
107
+ - .rubocop-disables.yml
108
+ - .rubocop.yml
63
109
  - .ruby-version
64
110
  - Gemfile
65
111
  - README.md
66
112
  - Rakefile
113
+ - bin/reyes
114
+ - config.yaml.example
67
115
  - lib/reyes.rb
116
+ - lib/reyes/aws_manager.rb
117
+ - lib/reyes/config.rb
118
+ - lib/reyes/errors.rb
119
+ - lib/reyes/group_manager.rb
120
+ - lib/reyes/group_tools.rb
121
+ - lib/reyes/ipset.rb
122
+ - lib/reyes/iptables.rb
123
+ - lib/reyes/run_generation.rb
68
124
  - lib/reyes/version.rb
69
125
  - reyes.gemspec
70
126
  homepage: ''