cfn-vpn 0.5.1 → 1.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.
- checksums.yaml +4 -4
- data/.github/workflows/build-gem.yml +25 -0
- data/.github/workflows/release-gem.yml +31 -0
- data/.github/workflows/release-image.yml +33 -0
- data/Gemfile.lock +30 -38
- data/README.md +1 -247
- data/cfn-vpn.gemspec +3 -2
- data/docs/README.md +44 -0
- data/docs/certificate-users.md +89 -0
- data/docs/getting-started.md +87 -0
- data/docs/modifying.md +67 -0
- data/docs/routes.md +82 -0
- data/docs/scheduling.md +32 -0
- data/docs/sessions.md +27 -0
- data/lib/cfnvpn.rb +31 -27
- data/lib/cfnvpn/{client.rb → actions/client.rb} +5 -6
- data/lib/cfnvpn/{embedded.rb → actions/embedded.rb} +15 -15
- data/lib/cfnvpn/actions/init.rb +130 -0
- data/lib/cfnvpn/actions/modify.rb +149 -0
- data/lib/cfnvpn/actions/params.rb +73 -0
- data/lib/cfnvpn/{revoke.rb → actions/revoke.rb} +6 -6
- data/lib/cfnvpn/actions/routes.rb +144 -0
- data/lib/cfnvpn/{sessions.rb → actions/sessions.rb} +5 -5
- data/lib/cfnvpn/{share.rb → actions/share.rb} +10 -10
- data/lib/cfnvpn/actions/subnets.rb +78 -0
- data/lib/cfnvpn/certificates.rb +5 -5
- data/lib/cfnvpn/clientvpn.rb +34 -68
- data/lib/cfnvpn/compiler.rb +23 -0
- data/lib/cfnvpn/config.rb +34 -78
- data/lib/cfnvpn/{cloudformation.rb → deployer.rb} +47 -19
- data/lib/cfnvpn/log.rb +26 -26
- data/lib/cfnvpn/s3.rb +4 -4
- data/lib/cfnvpn/string.rb +29 -0
- data/lib/cfnvpn/templates/helper.rb +14 -0
- data/lib/cfnvpn/templates/vpn.rb +344 -0
- data/lib/cfnvpn/version.rb +1 -1
- metadata +55 -22
- data/lib/cfnvpn/cfhighlander.rb +0 -49
- data/lib/cfnvpn/init.rb +0 -109
- data/lib/cfnvpn/modify.rb +0 -103
- data/lib/cfnvpn/routes.rb +0 -84
- data/lib/cfnvpn/templates/cfnvpn.cfhighlander.rb.tt +0 -27
data/lib/cfnvpn/clientvpn.rb
CHANGED
@@ -4,7 +4,7 @@ require 'netaddr'
|
|
4
4
|
|
5
5
|
module CfnVpn
|
6
6
|
class ClientVpn
|
7
|
-
|
7
|
+
|
8
8
|
|
9
9
|
def initialize(name,region)
|
10
10
|
@client = Aws::EC2::Client.new(region: region)
|
@@ -16,7 +16,7 @@ module CfnVpn
|
|
16
16
|
filters: [{ name: "tag:cfnvpn:name", values: [@name] }]
|
17
17
|
})
|
18
18
|
if resp.client_vpn_endpoints.empty?
|
19
|
-
Log.logger.error "unable to find endpoint with tag Key: cfnvpn:name with Value: #{@name}"
|
19
|
+
CfnVpn::Log.logger.error "unable to find endpoint with tag Key: cfnvpn:name with Value: #{@name}"
|
20
20
|
raise "Unable to find client vpn"
|
21
21
|
end
|
22
22
|
return resp.client_vpn_endpoints.first
|
@@ -68,53 +68,6 @@ module CfnVpn
|
|
68
68
|
})
|
69
69
|
end
|
70
70
|
|
71
|
-
def get_target_networks(endpoint_id)
|
72
|
-
resp = @client.describe_client_vpn_target_networks({
|
73
|
-
client_vpn_endpoint_id: endpoint_id
|
74
|
-
})
|
75
|
-
return resp.client_vpn_target_networks.first
|
76
|
-
end
|
77
|
-
|
78
|
-
def add_route(cidr,description)
|
79
|
-
endpoint_id = get_endpoint_id()
|
80
|
-
subnet_id = get_target_networks(endpoint_id).target_network_id
|
81
|
-
|
82
|
-
@client.create_client_vpn_route({
|
83
|
-
client_vpn_endpoint_id: endpoint_id,
|
84
|
-
destination_cidr_block: cidr,
|
85
|
-
target_vpc_subnet_id: subnet_id,
|
86
|
-
description: description
|
87
|
-
})
|
88
|
-
|
89
|
-
resp = @client.authorize_client_vpn_ingress({
|
90
|
-
client_vpn_endpoint_id: endpoint_id,
|
91
|
-
target_network_cidr: cidr,
|
92
|
-
authorize_all_groups: true,
|
93
|
-
description: description
|
94
|
-
})
|
95
|
-
|
96
|
-
return resp.status
|
97
|
-
end
|
98
|
-
|
99
|
-
def del_route(cidr)
|
100
|
-
endpoint_id = get_endpoint_id()
|
101
|
-
subnet_id = get_target_networks(endpoint_id).target_network_id
|
102
|
-
|
103
|
-
revoke = @client.revoke_client_vpn_ingress({
|
104
|
-
revoke_all_groups: true,
|
105
|
-
client_vpn_endpoint_id: endpoint_id,
|
106
|
-
target_network_cidr: cidr
|
107
|
-
})
|
108
|
-
|
109
|
-
route = @client.delete_client_vpn_route({
|
110
|
-
client_vpn_endpoint_id: endpoint_id,
|
111
|
-
target_vpc_subnet_id: subnet_id,
|
112
|
-
destination_cidr_block: cidr
|
113
|
-
})
|
114
|
-
|
115
|
-
return route.status, revoke.status
|
116
|
-
end
|
117
|
-
|
118
71
|
def get_routes()
|
119
72
|
endpoint_id = get_endpoint_id()
|
120
73
|
resp = @client.describe_client_vpn_routes({
|
@@ -124,30 +77,43 @@ module CfnVpn
|
|
124
77
|
return resp.routes
|
125
78
|
end
|
126
79
|
|
127
|
-
def
|
128
|
-
|
129
|
-
|
130
|
-
|
80
|
+
def get_groups_for_route(endpoint, cidr)
|
81
|
+
auth_resp = @client.describe_client_vpn_authorization_rules({
|
82
|
+
client_vpn_endpoint_id: endpoint,
|
83
|
+
filters: [
|
84
|
+
{
|
85
|
+
name: 'destination-cidr',
|
86
|
+
values: [cidr]
|
87
|
+
}
|
88
|
+
]
|
89
|
+
})
|
90
|
+
return auth_resp.authorization_rules.map {|rule| rule.group_id }
|
131
91
|
end
|
132
92
|
|
133
|
-
def
|
134
|
-
|
135
|
-
resp = @client.
|
136
|
-
client_vpn_endpoint_id:
|
137
|
-
max_results: 20
|
93
|
+
def get_associations(endpoint)
|
94
|
+
associations = []
|
95
|
+
resp = @client.describe_client_vpn_target_networks({
|
96
|
+
client_vpn_endpoint_id: endpoint
|
138
97
|
})
|
139
|
-
return resp.routes
|
140
|
-
end
|
141
98
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
99
|
+
resp.client_vpn_target_networks.each do |net|
|
100
|
+
subnet_resp = @client.describe_subnets({
|
101
|
+
subnet_ids: [net.target_network_id]
|
102
|
+
})
|
103
|
+
subnet = subnet_resp.subnets.first
|
104
|
+
groups = get_groups_for_route(endpoint, subnet.cidr_block)
|
105
|
+
|
106
|
+
associations.push({
|
107
|
+
association_id: net.association_id,
|
108
|
+
target_network_id: net.target_network_id,
|
109
|
+
status: net.status.code,
|
110
|
+
cidr: subnet.cidr_block,
|
111
|
+
az: subnet.availability_zone,
|
112
|
+
groups: groups.join(' ')
|
113
|
+
})
|
114
|
+
end
|
148
115
|
|
149
|
-
|
150
|
-
return !(cidr =~ /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$/).nil?
|
116
|
+
return associations
|
151
117
|
end
|
152
118
|
|
153
119
|
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'cfnvpn/log'
|
2
|
+
require 'cfnvpn/templates/vpn'
|
3
|
+
|
4
|
+
module CfnVpn
|
5
|
+
class Compiler
|
6
|
+
|
7
|
+
def initialize(name, config)
|
8
|
+
@name = name
|
9
|
+
@config = config
|
10
|
+
end
|
11
|
+
|
12
|
+
def compile
|
13
|
+
CfnVpn::Log.logger.debug "Compiling cloudformation"
|
14
|
+
template = CfnVpn::Templates::Vpn.new()
|
15
|
+
template.render(@name, @config)
|
16
|
+
CfnVpn::Log.logger.debug "Validating cloudformation"
|
17
|
+
valid = template.validate
|
18
|
+
CfnVpn::Log.logger.debug "Clouformation Template\n\n#{JSON.parse(valid.to_json).to_yaml}"
|
19
|
+
return JSON.parse(valid.to_json).to_yaml
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
data/lib/cfnvpn/config.rb
CHANGED
@@ -1,89 +1,45 @@
|
|
1
|
-
require '
|
2
|
-
require '
|
3
|
-
require 'cfnvpn/
|
1
|
+
require 'aws-sdk-ssm'
|
2
|
+
require 'json'
|
3
|
+
require 'cfnvpn/deployer'
|
4
4
|
|
5
5
|
module CfnVpn
|
6
|
-
class Config
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
class_option :bucket, required: true, desc: 's3 bucket'
|
16
|
-
class_option :client_cn, required: true, desc: "client certificates to download"
|
17
|
-
class_option :easyrsa_local, type: :boolean, default: false, desc: 'run the easyrsa executable from your local rather than from docker'
|
18
|
-
class_option :ignore_routes, alias: :i, type: :boolean, desc: "Ignore client VPN pushed routes and set routes in config file"
|
19
|
-
|
20
|
-
def self.source_root
|
21
|
-
File.dirname(__FILE__)
|
22
|
-
end
|
23
|
-
|
24
|
-
def set_loglevel
|
25
|
-
Log.logger.level = Logger::DEBUG if @options['verbose']
|
26
|
-
end
|
27
|
-
|
28
|
-
def create_config_directory
|
29
|
-
@build_dir = "#{CfnVpn.cfnvpn_path}/#{@name}"
|
30
|
-
@config_dir = "#{@build_dir}/config"
|
31
|
-
Log.logger.debug("Creating config directory #{@config_dir}")
|
32
|
-
FileUtils.mkdir_p(@config_dir)
|
33
|
-
end
|
34
|
-
|
35
|
-
def download_config
|
36
|
-
vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
|
37
|
-
@endpoint_id = vpn.get_endpoint_id()
|
38
|
-
Log.logger.info "downloading client config for #{@endpoint_id}"
|
39
|
-
@config = vpn.get_config(@endpoint_id)
|
40
|
-
end
|
41
|
-
|
42
|
-
def download_certificates
|
43
|
-
download = true
|
44
|
-
if File.exists?("#{@config_dir}/#{@options['client_cn']}.crt")
|
45
|
-
download = yes? "Certificates for #{@options['client_cn']} already exist in #{@config_dir}. Do you want to download again? ", :green
|
46
|
-
end
|
47
|
-
|
48
|
-
if download
|
49
|
-
Log.logger.info "Downloading certificates for #{@options['client_cn']} to #{@config_dir}"
|
50
|
-
s3 = CfnVpn::S3.new(@options['region'],@options['bucket'],@name)
|
51
|
-
s3.get_object("#{@config_dir}/#{@options['client_cn']}.tar.gz")
|
52
|
-
cert = CfnVpn::Certificates.new(@build_dir,@name,@options['easyrsa_local'])
|
53
|
-
Log.logger.debug cert.extract_certificate(@options['client_cn'])
|
6
|
+
class Config
|
7
|
+
|
8
|
+
def self.get_config(region, name)
|
9
|
+
client = Aws::SSM::Client.new(region: region)
|
10
|
+
begin
|
11
|
+
resp = client.get_parameter({name: "/cfnvpn/config/#{name}"})
|
12
|
+
return JSON.parse(resp.parameter.value, {:symbolize_names => true})
|
13
|
+
rescue Aws::SSM::Errors::ParameterNotFound => e
|
14
|
+
return self.get_config_from_parameter(region, name)
|
54
15
|
end
|
55
16
|
end
|
56
17
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
18
|
+
# to support upgrade from <=0.5.1 to >1.0
|
19
|
+
def self.get_config_from_parameter(region, name)
|
20
|
+
deployer = CfnVpn::Deployer.new(region, name)
|
21
|
+
parameters = deployer.get_parameters_from_stack_hash()
|
22
|
+
{
|
23
|
+
type: 'certificate',
|
24
|
+
server_cert_arn: parameters[:ServerCertificateArn],
|
25
|
+
client_cert_arn: parameters[:ClientCertificateArn],
|
26
|
+
region: region,
|
27
|
+
subnet_ids: [parameters[:AssociationSubnetId]],
|
28
|
+
cidr: parameters[:ClientCidrBlock],
|
29
|
+
dns_servers: parameters[:DnsServers].split(','),
|
30
|
+
split_tunnel: parameters[:SplitTunnel] == "true",
|
31
|
+
internet_route: parameters[:InternetRoute] == "true" ? parameters[:AssociationSubnetId] : nil,
|
32
|
+
protocol: parameters[:Protocol],
|
33
|
+
routes: []
|
34
|
+
}
|
62
35
|
end
|
63
36
|
|
64
|
-
def
|
65
|
-
|
66
|
-
Log.logger.debug "Ignoring routes pushed by the client vpn"
|
67
|
-
@config.concat("\nroute-nopull\n")
|
68
|
-
vpn = CfnVpn::ClientVpn.new(@name,@options['region'])
|
69
|
-
routes = vpn.get_route_with_mask
|
70
|
-
Log.logger.debug "Found routes #{routes}"
|
71
|
-
routes.each do |r|
|
72
|
-
@config.concat("route #{r[:route]} #{r[:mask]}\n")
|
73
|
-
end
|
74
|
-
dns_servers = vpn.get_dns_servers()
|
75
|
-
if dns_servers.any?
|
76
|
-
Log.logger.debug "Found DNS servers #{dns_servers.join(' ')}"
|
77
|
-
@config.concat("dhcp-option DNS #{dns_servers.first}\n")
|
78
|
-
end
|
79
|
-
end
|
37
|
+
def self.get_config_from_yaml_file(file)
|
38
|
+
YAML.load(File.read(file), symbolize_names: true)
|
80
39
|
end
|
81
40
|
|
82
|
-
def
|
83
|
-
|
84
|
-
File.write(config_file, @config)
|
85
|
-
Log.logger.info "downloaded client config #{config_file}"
|
41
|
+
def self.dump_config_to_yaml_file(name, params)
|
42
|
+
File.write(File.join(Dir.pwd, "cfnvpn.#{name}.yaml"), Hash[params.collect{|k,v| [k.to_s, v]}].to_yaml)
|
86
43
|
end
|
87
|
-
|
88
44
|
end
|
89
|
-
end
|
45
|
+
end
|
@@ -2,10 +2,10 @@ require 'aws-sdk-cloudformation'
|
|
2
2
|
require 'fileutils'
|
3
3
|
require 'cfnvpn/version'
|
4
4
|
require 'cfnvpn/log'
|
5
|
+
require 'cfnvpn/string'
|
5
6
|
|
6
7
|
module CfnVpn
|
7
|
-
class
|
8
|
-
include CfnVpn::Log
|
8
|
+
class Deployer
|
9
9
|
|
10
10
|
def initialize(region,name)
|
11
11
|
@name = name
|
@@ -29,28 +29,25 @@ module CfnVpn
|
|
29
29
|
return does_cf_stack_exist() ? 'UPDATE' : 'CREATE'
|
30
30
|
end
|
31
31
|
|
32
|
-
def create_change_set(
|
32
|
+
def create_change_set(template_body: nil, parameters: {})
|
33
33
|
change_set_name = "#{@stack_name}-#{CfnVpn::CHANGE_SET_VERSION}-#{Time.now.utc.strftime("%Y%m%d%H%M%S")}"
|
34
34
|
change_set_type = get_change_set_type()
|
35
35
|
|
36
|
-
if
|
37
|
-
params = get_parameters_from_template(
|
36
|
+
if !template_body.nil?
|
37
|
+
params = get_parameters_from_template(template_body)
|
38
38
|
else
|
39
39
|
params = get_parameters_from_stack()
|
40
40
|
end
|
41
|
-
|
41
|
+
|
42
42
|
params.each do |param|
|
43
43
|
if !parameters[param[:parameter_key]].nil?
|
44
44
|
param[:parameter_value] = parameters[param[:parameter_key]]
|
45
45
|
param[:use_previous_value] = false
|
46
46
|
end
|
47
47
|
end
|
48
|
-
|
49
|
-
|
50
|
-
Log.logger.debug "Creating changeset"
|
51
|
-
change_set = @client.create_change_set({
|
48
|
+
|
49
|
+
changeset_args = {
|
52
50
|
stack_name: @stack_name,
|
53
|
-
template_body: template_body,
|
54
51
|
parameters: params,
|
55
52
|
tags: [
|
56
53
|
{
|
@@ -63,18 +60,28 @@ module CfnVpn
|
|
63
60
|
}
|
64
61
|
],
|
65
62
|
change_set_name: change_set_name,
|
66
|
-
change_set_type: change_set_type
|
67
|
-
|
63
|
+
change_set_type: change_set_type,
|
64
|
+
capabilities: ['CAPABILITY_IAM']
|
65
|
+
}
|
66
|
+
|
67
|
+
if !template_body.nil?
|
68
|
+
changeset_args[:template_body] = template_body
|
69
|
+
else
|
70
|
+
changeset_args[:use_previous_template] = true
|
71
|
+
end
|
72
|
+
|
73
|
+
CfnVpn::Log.logger.debug "Creating changeset"
|
74
|
+
change_set = @client.create_change_set(changeset_args)
|
68
75
|
return change_set, change_set_type
|
69
76
|
end
|
70
77
|
|
71
78
|
def wait_for_changeset(change_set_id)
|
72
|
-
Log.logger.debug "Waiting for changeset to be created"
|
79
|
+
CfnVpn::Log.logger.debug "Waiting for changeset to be created"
|
73
80
|
begin
|
74
81
|
@client.wait_until :change_set_create_complete, change_set_name: change_set_id
|
75
82
|
rescue Aws::Waiters::Errors::FailureStateError => e
|
76
83
|
change_set = get_change_set(change_set_id)
|
77
|
-
Log.logger.error("change set status: #{change_set.status} reason: #{change_set.status_reason}")
|
84
|
+
CfnVpn::Log.logger.error("change set status: #{change_set.status} reason: #{change_set.status_reason}")
|
78
85
|
exit 1
|
79
86
|
end
|
80
87
|
end
|
@@ -86,7 +93,7 @@ module CfnVpn
|
|
86
93
|
end
|
87
94
|
|
88
95
|
def execute_change_set(change_set_id)
|
89
|
-
Log.logger.debug "Executing the changeset"
|
96
|
+
CfnVpn::Log.logger.debug "Executing the changeset"
|
90
97
|
stack = @client.execute_change_set({
|
91
98
|
change_set_name: change_set_id
|
92
99
|
})
|
@@ -94,7 +101,7 @@ module CfnVpn
|
|
94
101
|
|
95
102
|
def wait_for_execute(change_set_type)
|
96
103
|
waiter = change_set_type == 'CREATE' ? :stack_create_complete : :stack_update_complete
|
97
|
-
Log.logger.info "Waiting for changeset to #{change_set_type}"
|
104
|
+
CfnVpn::Log.logger.info "Waiting for changeset to #{change_set_type}"
|
98
105
|
resp = @client.wait_until waiter, stack_name: @stack_name
|
99
106
|
end
|
100
107
|
|
@@ -103,11 +110,32 @@ module CfnVpn
|
|
103
110
|
return resp.parameters.collect { |p| { parameter_key: p.parameter_key, use_previous_value: true } }
|
104
111
|
end
|
105
112
|
|
106
|
-
def get_parameters_from_template(
|
107
|
-
template_body = File.read(template_path)
|
113
|
+
def get_parameters_from_template(template_body)
|
108
114
|
resp = @client.get_template_summary({ template_body: template_body })
|
109
115
|
return resp.parameters.collect { |p| { parameter_key: p.parameter_key, parameter_value: p.default_value } }
|
110
116
|
end
|
111
117
|
|
118
|
+
def get_parameter_value(parameter)
|
119
|
+
resp = @client.describe_stacks({ stack_name: @stack_name })
|
120
|
+
stack = resp.stacks.first
|
121
|
+
parameter = stack.parameters.detect {|p| p.parameter_key == parameter}
|
122
|
+
return parameter ? parameter.parameter_value : nil
|
123
|
+
end
|
124
|
+
|
125
|
+
def get_parameters_from_stack_hash()
|
126
|
+
resp = @client.describe_stacks({
|
127
|
+
stack_name: @stack_name,
|
128
|
+
})
|
129
|
+
stack = resp.stacks.first
|
130
|
+
return Hash[stack.parameters.collect {|parameter| [parameter.parameter_key.to_sym, parameter.parameter_value]}]
|
131
|
+
end
|
132
|
+
|
133
|
+
def get_outputs_from_stack()
|
134
|
+
resp = @client.describe_stacks({
|
135
|
+
stack_name: @stack_name,
|
136
|
+
})
|
137
|
+
stack = resp.stacks.first
|
138
|
+
return Hash[stack.outputs.collect {|output| [output.output_key.underscore.to_sym, output.output_value]}]
|
139
|
+
end
|
112
140
|
end
|
113
141
|
end
|
data/lib/cfnvpn/log.rb
CHANGED
@@ -1,38 +1,38 @@
|
|
1
1
|
require 'logger'
|
2
2
|
|
3
3
|
module CfnVpn
|
4
|
-
module Log
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
4
|
+
module Log
|
5
|
+
class << self
|
6
|
+
def colors
|
7
|
+
@colors ||= {
|
8
|
+
ERROR: 31, # red
|
9
|
+
WARN: 33, # yellow
|
10
|
+
INFO: 0,
|
11
|
+
DEBUG: 32 # grenn
|
12
|
+
}
|
13
|
+
end
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
15
|
+
def logger
|
16
|
+
if @logger.nil?
|
17
|
+
@logger = Logger.new(STDOUT)
|
18
|
+
@logger.level = Logger::INFO
|
19
|
+
@logger.formatter = proc do |severity, datetime, progname, msg|
|
20
|
+
"\e[#{colors[severity.to_sym]}m#{severity}: - #{msg}\e[0m\n"
|
21
|
+
end
|
21
22
|
end
|
23
|
+
@logger
|
22
24
|
end
|
23
|
-
@logger
|
24
|
-
end
|
25
25
|
|
26
|
-
|
27
|
-
|
28
|
-
|
26
|
+
def logger=(logger)
|
27
|
+
@logger = logger
|
28
|
+
end
|
29
29
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
30
|
+
levels = %w(debug info warn error fatal)
|
31
|
+
levels.each do |level|
|
32
|
+
define_method("#{level.to_sym}") do |msg|
|
33
|
+
self.logger.send(level, msg)
|
34
|
+
end
|
34
35
|
end
|
35
36
|
end
|
36
|
-
|
37
37
|
end
|
38
38
|
end
|