cfn-vpn 0.5.1 → 1.3.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build-gem.yml +25 -0
  3. data/.github/workflows/release-gem.yml +34 -0
  4. data/.github/workflows/release-image.yml +33 -0
  5. data/Gemfile.lock +33 -39
  6. data/README.md +1 -247
  7. data/cfn-vpn.gemspec +4 -4
  8. data/docs/README.md +44 -0
  9. data/docs/certificate-users.md +89 -0
  10. data/docs/getting-started.md +128 -0
  11. data/docs/modifying.md +67 -0
  12. data/docs/routes.md +98 -0
  13. data/docs/scheduling.md +32 -0
  14. data/docs/sessions.md +27 -0
  15. data/lib/cfnvpn.rb +31 -27
  16. data/lib/cfnvpn/{client.rb → actions/client.rb} +5 -6
  17. data/lib/cfnvpn/{embedded.rb → actions/embedded.rb} +15 -15
  18. data/lib/cfnvpn/actions/init.rb +144 -0
  19. data/lib/cfnvpn/actions/modify.rb +169 -0
  20. data/lib/cfnvpn/actions/params.rb +73 -0
  21. data/lib/cfnvpn/{revoke.rb → actions/revoke.rb} +6 -6
  22. data/lib/cfnvpn/actions/routes.rb +196 -0
  23. data/lib/cfnvpn/{sessions.rb → actions/sessions.rb} +5 -5
  24. data/lib/cfnvpn/{share.rb → actions/share.rb} +10 -10
  25. data/lib/cfnvpn/actions/subnets.rb +78 -0
  26. data/lib/cfnvpn/certificates.rb +5 -5
  27. data/lib/cfnvpn/clientvpn.rb +49 -65
  28. data/lib/cfnvpn/compiler.rb +23 -0
  29. data/lib/cfnvpn/config.rb +34 -78
  30. data/lib/cfnvpn/{cloudformation.rb → deployer.rb} +47 -19
  31. data/lib/cfnvpn/log.rb +26 -26
  32. data/lib/cfnvpn/s3.rb +34 -4
  33. data/lib/cfnvpn/s3_bucket.rb +48 -0
  34. data/lib/cfnvpn/string.rb +33 -0
  35. data/lib/cfnvpn/templates/helper.rb +14 -0
  36. data/lib/cfnvpn/templates/lambdas.rb +35 -0
  37. data/lib/cfnvpn/templates/lambdas/auto_route_populator/app.py +175 -0
  38. data/lib/cfnvpn/templates/lambdas/scheduler/app.py +36 -0
  39. data/lib/cfnvpn/templates/vpn.rb +449 -0
  40. data/lib/cfnvpn/version.rb +1 -1
  41. metadata +73 -23
  42. data/lib/cfnvpn/cfhighlander.rb +0 -49
  43. data/lib/cfnvpn/init.rb +0 -109
  44. data/lib/cfnvpn/modify.rb +0 -103
  45. data/lib/cfnvpn/routes.rb +0 -84
  46. data/lib/cfnvpn/templates/cfnvpn.cfhighlander.rb.tt +0 -27
@@ -0,0 +1,73 @@
1
+ require 'yaml'
2
+ require 'cfnvpn/config'
3
+ require 'cfnvpn/log'
4
+
5
+ module CfnVpn::Actions
6
+ class Params < Thor::Group
7
+ include Thor::Actions
8
+
9
+
10
+ argument :name
11
+
12
+ class_option :region, default: ENV['AWS_REGION'], desc: 'AWS Region'
13
+ class_option :verbose, desc: 'set log level to debug', type: :boolean
14
+
15
+ class_option :dump, type: :boolean, desc: 'dump config to yaml file'
16
+ class_option :diff_yaml, desc: 'diff yaml file with deployed config'
17
+
18
+ def self.source_root
19
+ File.dirname(__FILE__)
20
+ end
21
+
22
+ def set_loglevel
23
+ CfnVpn::Log.logger.level = Logger::DEBUG if @options['verbose']
24
+ end
25
+
26
+ def get_config
27
+ @config = CfnVpn::Config.get_config(@options[:region], @name)
28
+ end
29
+
30
+ def dump
31
+ CfnVpn::Config.dump_config_to_yaml_file(name, @config) if @options[:dump]
32
+ end
33
+
34
+ def setup_display
35
+ @headings = ['Param', 'Deployed Value']
36
+ @rows = []
37
+ end
38
+
39
+ def diff
40
+ if @options[:diff_yaml]
41
+ yaml_params = CfnVpn::Config.get_config_from_yaml_file(@options[:diff_yaml])
42
+
43
+ @headings << 'YAML Value'
44
+ @config.each do |key, value|
45
+ row = [key, value]
46
+ if yaml_params.has_key? key
47
+ row << yaml_params[key]
48
+ else
49
+ row << nil
50
+ end
51
+
52
+ if row[1] != row[2]
53
+ row[1] = row[1].to_s.red
54
+ row[2] = row[2].to_s.red
55
+ end
56
+
57
+ @rows << row
58
+ end
59
+ else
60
+ @rows = @config.to_a
61
+ end
62
+ end
63
+
64
+ def display
65
+ table = Terminal::Table.new(
66
+ :title => 'Params',
67
+ :headings => @headings,
68
+ :rows => @rows)
69
+ puts table
70
+ end
71
+
72
+ end
73
+ end
@@ -3,10 +3,10 @@ require 'cfnvpn/log'
3
3
  require 'cfnvpn/s3'
4
4
  require 'cfnvpn/globals'
5
5
 
6
- module CfnVpn
6
+ module CfnVpn::Actions
7
7
  class Revoke < Thor::Group
8
8
  include Thor::Actions
9
- include CfnVpn::Log
9
+
10
10
 
11
11
  argument :name
12
12
 
@@ -23,7 +23,7 @@ module CfnVpn
23
23
  end
24
24
 
25
25
  def set_loglevel
26
- Log.logger.level = Logger::DEBUG if @options['verbose']
26
+ CfnVpn::Log.logger.level = Logger::DEBUG if @options['verbose']
27
27
  end
28
28
 
29
29
  def set_directory
@@ -36,15 +36,15 @@ module CfnVpn
36
36
  s3 = CfnVpn::S3.new(@options['region'],@options['bucket'],@name)
37
37
  s3.get_object("#{@cert_dir}/ca.tar.gz")
38
38
  s3.get_object("#{@cert_dir}/#{@options['client_cn']}.tar.gz")
39
- Log.logger.info "Generating new client certificate #{@options['client_cn']} using openvpn easy-rsa"
40
- Log.logger.debug cert.revoke_client(@options['client_cn'])
39
+ CfnVpn::Log.logger.info "Generating new client certificate #{@options['client_cn']} using openvpn easy-rsa"
40
+ CfnVpn::Log.logger.debug cert.revoke_client(@options['client_cn'])
41
41
  end
42
42
 
43
43
  def apply_rekocation_list
44
44
  vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
45
45
  endpoint_id = vpn.get_endpoint_id()
46
46
  vpn.put_revoke_list(endpoint_id,"#{@cert_dir}/crl.pem")
47
- Log.logger.info("revoked client #{@options['client_cn']} from #{endpoint_id}")
47
+ CfnVpn::Log.logger.info("revoked client #{@options['client_cn']} from #{endpoint_id}")
48
48
  end
49
49
 
50
50
  end
@@ -0,0 +1,196 @@
1
+ require 'thor'
2
+ require 'cfnvpn/log'
3
+ require 'cfnvpn/s3'
4
+ require 'cfnvpn/globals'
5
+
6
+ module CfnVpn::Actions
7
+ class Routes < Thor::Group
8
+ include Thor::Actions
9
+
10
+ argument :name
11
+
12
+ class_option :region, aliases: :r, default: ENV['AWS_REGION'], desc: 'AWS Region'
13
+ class_option :verbose, desc: 'set log level to debug', type: :boolean
14
+
15
+ class_option :cidr, desc: 'cidr range'
16
+ class_option :dns, desc: 'dns record to auto lookup ip'
17
+ class_option :subnet, desc: 'the target vpc subnet to route through, if none is supplied the default subnet is used'
18
+ class_option :desc, desc: 'description of the route'
19
+
20
+ class_option :groups, type: :array, desc: 'override all authorised groups on thr route'
21
+ class_option :add_groups, type: :array, desc: 'add authorised groups to an existing route'
22
+ class_option :del_groups, type: :array, desc: 'remove authorised groups from an existing route'
23
+
24
+ class_option :delete, type: :boolean, desc: 'delete the route from the client vpn'
25
+
26
+ def self.source_root
27
+ File.dirname(__FILE__)
28
+ end
29
+
30
+ def set_loglevel
31
+ CfnVpn::Log.logger.level = Logger::DEBUG if @options['verbose']
32
+ end
33
+
34
+ def set_config
35
+ @config = CfnVpn::Config.get_config(@options[:region], @name)
36
+
37
+ if @options[:cidr] && @options[:dns]
38
+ CfnVpn::Log.logger.error "only one of --dns or --cidr can be set"
39
+ exit 1
40
+ end
41
+
42
+ if @options[:dns]
43
+ if @options[:dns].include?("*")
44
+ CfnVpn::Log.logger.error("wild card DNS resolution is not supported, use a record that will be resolved by the wild card instead")
45
+ exit 1
46
+ end
47
+ @route = @config[:routes].detect {|route| route[:dns] == @options[:dns]}
48
+ elsif @options[:cidr]
49
+ @route = @config[:routes].detect {|route| route[:cidr] == @options[:cidr]}
50
+ end
51
+ end
52
+
53
+ def set_route
54
+ @skip_update = false
55
+ @dns_route_cleanup = nil
56
+ if @route && @options[:delete]
57
+ if @options[:dns]
58
+ CfnVpn::Log.logger.info "deleting auto lookup route for endpoint #{@options[:dns]}"
59
+ @config[:routes].reject! {|route| route[:dns] == @options[:dns]}
60
+ @dns_route_cleanup = @options[:dns]
61
+ elsif @options[:cidr]
62
+ CfnVpn::Log.logger.info "deleting route #{@options[:cidr]}"
63
+ @config[:routes].reject! {|route| route[:cidr] == @options[:cidr]}
64
+ end
65
+ elsif @route
66
+ CfnVpn::Log.logger.info "existing route for #{@options[:cidr] ? @options[:cidr] : @options[:dns]} found"
67
+ if @options[:groups]
68
+ CfnVpn::Log.logger.info "replacing groups #{@route[:groups]} with new #{@options[:groups]} for route authorization rule"
69
+ @route[:groups] = @options[:groups]
70
+ end
71
+
72
+ if @options[:add_groups]
73
+ CfnVpn::Log.logger.info "adding new group(s) #{@options[:add_groups]} to route authorization rule"
74
+ @route[:groups].concat(@options[:add_groups]).uniq!
75
+ end
76
+
77
+ if @options[:del_groups]
78
+ CfnVpn::Log.logger.info "removing new group(s) #{@options[:del_groups]} to route authorization rule"
79
+ @route[:groups].reject! {|group| @options[:del_groups].include? group}
80
+ end
81
+
82
+ if @options[:desc]
83
+ CfnVpn::Log.logger.warn "description for this route cannot be updated in place. To alter delete the route and add with the new description"
84
+ end
85
+
86
+ if @options[:subnet]
87
+ CfnVpn::Log.logger.warn "the target subnet for this route cannot be updated in place. To alter delete the route and add with the new target subnet"
88
+ end
89
+ elsif !@route && @options[:cidr]
90
+ CfnVpn::Log.logger.info "adding new route for #{@options[:cidr]}"
91
+ @config[:routes] << {
92
+ cidr: @options[:cidr],
93
+ desc: @options.fetch(:desc, ""),
94
+ subnet: @options.fetch(:subnet, @config[:subnet_ids].first),
95
+ groups: @options.fetch(:groups, []) + @options.fetch(:add_groups, [])
96
+ }
97
+ elsif !@route && @options[:dns]
98
+ CfnVpn::Log.logger.info "adding new route lookup for dns record #{@options[:dns]}"
99
+ @config[:routes] << {
100
+ dns: @options[:dns],
101
+ desc: @options.fetch(:desc, ""),
102
+ subnet: @options.fetch(:subnet, @config[:subnet_ids].first),
103
+ groups: @options.fetch(:groups, []) + @options.fetch(:add_groups, [])
104
+ }
105
+ else
106
+ @skip_update = true
107
+ end
108
+
109
+ CfnVpn::Log.logger.debug "CONFIG: #{@config}"
110
+ end
111
+
112
+ def create_bucket_if_bucket_not_set
113
+ if !@config.has_key?(:bucket)
114
+ CfnVpn::Log.logger.error "no bucket found in the config, run the cfn-vpn modify #{name} command to add a bucket"
115
+ exit 1
116
+ end
117
+ end
118
+
119
+ def deploy_vpn
120
+ unless @skip_update
121
+ compiler = CfnVpn::Compiler.new(@name, @config)
122
+ template_body = compiler.compile
123
+ CfnVpn::Log.logger.info "Creating cloudformation changeset for stack #{@name}-cfnvpn in #{@options['region']}"
124
+ @deployer = CfnVpn::Deployer.new(@options['region'],@name)
125
+ change_set, change_set_type = @deployer.create_change_set(template_body: template_body)
126
+ @deployer.wait_for_changeset(change_set.id)
127
+ changeset_response = @deployer.get_change_set(change_set.id)
128
+
129
+ changes = {"Add" => [], "Modify" => [], "Remove" => []}
130
+ change_colours = {"Add" => "green", "Modify" => 'yellow', "Remove" => 'red'}
131
+
132
+ changeset_response.changes.each do |change|
133
+ action = change.resource_change.action
134
+ changes[action].push([
135
+ change.resource_change.logical_resource_id,
136
+ change.resource_change.resource_type,
137
+ change.resource_change.replacement ? change.resource_change.replacement : 'N/A',
138
+ change.resource_change.details.collect {|detail| detail.target.name }.join(' , ')
139
+ ])
140
+ end
141
+
142
+ changes.each do |type, rows|
143
+ next if !rows.any?
144
+ puts "\n"
145
+ table = Terminal::Table.new(
146
+ :title => type,
147
+ :headings => ['Logical Resource Id', 'Resource Type', 'Replacement', 'Changes'],
148
+ :rows => rows)
149
+ puts table.to_s.send(change_colours[type])
150
+ end
151
+
152
+ CfnVpn::Log.logger.info "Cloudformation changeset changes:"
153
+ puts "\n"
154
+ continue = yes? "Continue?", :green
155
+ if !continue
156
+ CfnVpn::Log.logger.info("Cancelled cfn-vpn modifiy #{@name}")
157
+ exit 1
158
+ end
159
+
160
+ @deployer.execute_change_set(change_set.id)
161
+ @deployer.wait_for_execute(change_set_type)
162
+ CfnVpn::Log.logger.info "Changeset #{change_set_type} complete"
163
+ end
164
+ end
165
+
166
+ def cleanup_dns_routes
167
+ @vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
168
+ unless @dns_route_cleanup.nil?
169
+ routes = @vpn.get_routes()
170
+ CfnVpn::Log.logger.info("Cleaning up expired routes for #{@dns_route_cleanup}")
171
+ expired_routes = routes.select {|route| route.description.include?(@dns_route_cleanup) }
172
+ expired_routes.each do |route|
173
+ @vpn.delete_route(route.destination_cidr, route.target_subnet)
174
+ @vpn.revoke_auth(route.destination_cidr)
175
+ end
176
+ end
177
+ end
178
+
179
+ def get_routes
180
+ @endpoint = @vpn.get_endpoint_id()
181
+ @routes = @vpn.get_routes()
182
+ end
183
+
184
+ def display_routes
185
+ rows = @routes.collect do |s|
186
+ groups = @vpn.get_groups_for_route(@endpoint, s.destination_cidr)
187
+ [ s.destination_cidr, s.description, s.status.code, s.target_subnet, s.type, s.origin, (!groups.join("").empty? ? groups.join(' ') : 'AllowAll') ]
188
+ end
189
+ table = Terminal::Table.new(
190
+ :headings => ['Route', 'Description', 'Status', 'Target', 'Type', 'Origin', 'Groups'],
191
+ :rows => rows)
192
+ puts table
193
+ end
194
+
195
+ end
196
+ end
@@ -4,10 +4,10 @@ require 'cfnvpn/log'
4
4
  require 'cfnvpn/clientvpn'
5
5
  require 'cfnvpn/globals'
6
6
 
7
- module CfnVpn
7
+ module CfnVpn::Actions
8
8
  class Sessions < Thor::Group
9
9
  include Thor::Actions
10
- include CfnVpn::Log
10
+
11
11
 
12
12
  argument :name
13
13
 
@@ -22,7 +22,7 @@ module CfnVpn
22
22
  end
23
23
 
24
24
  def set_loglevel
25
- Log.logger.level = Logger::DEBUG if @options['verbose']
25
+ CfnVpn::Log.logger.level = Logger::DEBUG if @options['verbose']
26
26
  end
27
27
 
28
28
  def set_directory
@@ -41,11 +41,11 @@ module CfnVpn
41
41
  if session.any? && session.status.code == "active"
42
42
  terminate = yes? "Terminate connection #{@options['kill']} for #{session.common_name}?", :yellow
43
43
  if terminate
44
- Log.logger.info "Terminating connection #{@options['kill']} for #{session.common_name}"
44
+ CfnVpn::Log.logger.info "Terminating connection #{@options['kill']} for #{session.common_name}"
45
45
  @vpn.kill_session(@endpoint_id,@options['kill'])
46
46
  end
47
47
  else
48
- Log.logger.error "Connection id #{@options['kill']} doesn't exist or is not active"
48
+ CfnVpn::Log.logger.error "Connection id #{@options['kill']} doesn't exist or is not active"
49
49
  end
50
50
  end
51
51
  end
@@ -1,10 +1,10 @@
1
1
  require 'cfnvpn/log'
2
2
  require 'cfnvpn/s3'
3
3
 
4
- module CfnVpn
4
+ module CfnVpn::Actions
5
5
  class Share < Thor::Group
6
6
  include Thor::Actions
7
- include CfnVpn::Log
7
+
8
8
 
9
9
  argument :name
10
10
 
@@ -21,13 +21,13 @@ module CfnVpn
21
21
  end
22
22
 
23
23
  def set_loglevel
24
- Log.logger.level = Logger::DEBUG if @options['verbose']
24
+ CfnVpn::Log.logger.level = Logger::DEBUG if @options['verbose']
25
25
  end
26
26
 
27
27
  def copy_config_to_s3
28
28
  vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
29
29
  @endpoint_id = vpn.get_endpoint_id()
30
- Log.logger.debug "downloading client config for #{@endpoint_id}"
30
+ CfnVpn::Log.logger.debug "downloading client config for #{@endpoint_id}"
31
31
  @config = vpn.get_config(@endpoint_id)
32
32
  string = (0...8).map { (65 + rand(26)).chr.downcase }.join
33
33
  @config.sub!(@endpoint_id, "#{string}.#{@endpoint_id}")
@@ -35,17 +35,17 @@ module CfnVpn
35
35
 
36
36
  def add_routes
37
37
  if @options['ignore_routes']
38
- Log.logger.debug "Ignoring routes pushed by the client vpn"
38
+ CfnVpn::Log.logger.debug "Ignoring routes pushed by the client vpn"
39
39
  @config.concat("\nroute-nopull\n")
40
40
  vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
41
41
  routes = vpn.get_route_with_mask
42
- Log.logger.debug "Found routes #{routes}"
42
+ CfnVpn::Log.logger.debug "Found routes #{routes}"
43
43
  routes.each do |r|
44
44
  @config.concat("route #{r[:route]} #{r[:mask]}\n")
45
45
  end
46
46
  dns_servers = vpn.get_dns_servers()
47
47
  if dns_servers.any?
48
- Log.logger.debug "Found DNS servers #{dns_servers.join(' ')}"
48
+ CfnVpn::Log.logger.debug "Found DNS servers #{dns_servers.join(' ')}"
49
49
  @config.concat("dhcp-option DNS #{dns_servers.first}\n")
50
50
  end
51
51
  end
@@ -58,16 +58,16 @@ module CfnVpn
58
58
 
59
59
  def get_certificate_url
60
60
  @certificate_url = @s3.get_url("#{@options['client_cn']}.tar.gz")
61
- Log.logger.debug "Certificate presigned url: #{@certificate_url}"
61
+ CfnVpn::Log.logger.debug "Certificate presigned url: #{@certificate_url}"
62
62
  end
63
63
 
64
64
  def get_config_url
65
65
  @config_url = @s3.get_url("#{@name}.config.ovpn")
66
- Log.logger.debug "Config presigned url: #{@config_url}"
66
+ CfnVpn::Log.logger.debug "Config presigned url: #{@config_url}"
67
67
  end
68
68
 
69
69
  def display_instructions
70
- Log.logger.info "Share the bellow instruction with the user..."
70
+ CfnVpn::Log.logger.info "Share the bellow instruction with the user..."
71
71
  say "\nDownload the certificates and config from the bellow presigned URLs which will expire in 1 hour."
72
72
  say "\nCertificate:"
73
73
  say "\tcurl #{@certificate_url} > #{@options['client_cn']}.tar.gz", :cyan
@@ -0,0 +1,78 @@
1
+ require 'thor'
2
+ require 'fileutils'
3
+ require 'cfnvpn/log'
4
+
5
+ module CfnVpn::Actions
6
+ class Subnets < Thor::Group
7
+ include Thor::Actions
8
+
9
+
10
+ argument :name
11
+
12
+ class_option :region, aliases: :r, default: ENV['AWS_REGION'], desc: 'AWS Region'
13
+ class_option :verbose, desc: 'set log level to debug', type: :boolean
14
+
15
+ class_option :associate, aliases: :a, desc: 'associate all subnets with the client vpn', type: :boolean
16
+ class_option :disassociate, aliases: :d, desc: 'disassociate all subnets with the client vpn', type: :boolean
17
+
18
+ def set_loglevel
19
+ CfnVpn::Log.logger.level = Logger::DEBUG if @options['verbose']
20
+ end
21
+
22
+ def stack_exist
23
+ @deployer = CfnVpn::Deployer.new(@options['region'],@name)
24
+ if !@deployer.does_cf_stack_exist()
25
+ CfnVpn::Log.logger.error "#{@name}-cfnvpn stack doesn't exists in this account and region #{@options['region']}"
26
+ exit 1
27
+ end
28
+ end
29
+
30
+ def associated?
31
+ @associated = @deployer.get_parameter_value('AssociateSubnets') == 'true'
32
+ end
33
+
34
+ def associate
35
+ if @options[:associate]
36
+ if !@associated
37
+ CfnVpn::Log.logger.info "Associating subnets ..."
38
+ change_set, change_set_type = @deployer.create_change_set(parameters: {"AssociateSubnets" => 'true'})
39
+ @deployer.wait_for_changeset(change_set.id)
40
+ @deployer.execute_change_set(change_set.id)
41
+ @deployer.wait_for_execute(change_set_type)
42
+ CfnVpn::Log.logger.info "Association complete"
43
+ else
44
+ CfnVpn::Log.logger.warn "Client-VPN #{name} subnets are already associated"
45
+ end
46
+ end
47
+ end
48
+
49
+ def disassociate
50
+ if @options[:disassociate]
51
+ if @associated
52
+ CfnVpn::Log.logger.info "Disassociating subnets ..."
53
+ change_set, change_set_type = @deployer.create_change_set(parameters: {"AssociateSubnets" => 'false'})
54
+ @deployer.wait_for_changeset(change_set.id)
55
+ @deployer.execute_change_set(change_set.id)
56
+ @deployer.wait_for_execute(change_set_type)
57
+ CfnVpn::Log.logger.info "Disassociation complete"
58
+ else
59
+ CfnVpn::Log.logger.warn "Client-VPN #{name} subnets are already disassociated"
60
+ end
61
+ end
62
+ end
63
+
64
+ def get_endpoint
65
+ @vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
66
+ @endpoint_id = @vpn.get_endpoint_id()
67
+ end
68
+
69
+ def associations
70
+ associations = @vpn.get_associations(@endpoint_id)
71
+ table = Terminal::Table.new(
72
+ :headings => ['ID', 'Subnet', 'Status', 'CIDR', 'AZ', 'Groups'],
73
+ :rows => associations.map {|ass| ass.values})
74
+ puts table
75
+ end
76
+
77
+ end
78
+ end