reyes 0.0.1 → 0.0.2

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.
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: ''