httpd_configmap_generator 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +1 -0
- data/.travis.yml +14 -0
- data/Dockerfile +16 -0
- data/Gemfile +4 -0
- data/LICENSE +201 -0
- data/README-active-directory.md +38 -0
- data/README-ipa.md +41 -0
- data/README-saml.md +70 -0
- data/README.md +386 -0
- data/Rakefile +8 -0
- data/bin/httpd_configmap_generator +101 -0
- data/httpd_configmap_generator.gemspec +34 -0
- data/lib/httpd_configmap_generator.rb +29 -0
- data/lib/httpd_configmap_generator/active_directory.rb +114 -0
- data/lib/httpd_configmap_generator/base.rb +83 -0
- data/lib/httpd_configmap_generator/base/command.rb +29 -0
- data/lib/httpd_configmap_generator/base/config.rb +13 -0
- data/lib/httpd_configmap_generator/base/config_map.rb +183 -0
- data/lib/httpd_configmap_generator/base/file.rb +66 -0
- data/lib/httpd_configmap_generator/base/kerberos.rb +13 -0
- data/lib/httpd_configmap_generator/base/network.rb +37 -0
- data/lib/httpd_configmap_generator/base/pam.rb +9 -0
- data/lib/httpd_configmap_generator/base/principal.rb +33 -0
- data/lib/httpd_configmap_generator/base/sssd.rb +51 -0
- data/lib/httpd_configmap_generator/export.rb +31 -0
- data/lib/httpd_configmap_generator/ipa.rb +122 -0
- data/lib/httpd_configmap_generator/options.rb +13 -0
- data/lib/httpd_configmap_generator/saml.rb +104 -0
- data/lib/httpd_configmap_generator/update.rb +39 -0
- data/lib/httpd_configmap_generator/version.rb +3 -0
- data/templates/etc/pam.d/httpd-auth +2 -0
- data/templates/httpd-configmap-generator-template.yaml +113 -0
- metadata +203 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
require "pathname"
|
2
|
+
|
3
|
+
module HttpdConfigmapGenerator
|
4
|
+
class Base
|
5
|
+
def template_directory
|
6
|
+
@template_directory ||= begin
|
7
|
+
Pathname.new(Bundler.locked_gems.specs.select { |g| g.name == "httpd_configmap_generator" }.first.gem_dir).join("templates")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def cp_template(file, src_dir, dest_dir = "/")
|
12
|
+
src_path = path_join(src_dir, file)
|
13
|
+
dest_path = path_join(dest_dir, file.gsub(".erb", ""))
|
14
|
+
if src_path.to_s.include?(".erb")
|
15
|
+
File.write(dest_path, ERB.new(File.read(src_path), nil, '-').result(binding))
|
16
|
+
else
|
17
|
+
FileUtils.cp(src_path, dest_path)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def delete_target_file(file_path)
|
22
|
+
if File.exist?(file_path)
|
23
|
+
if opts[:force]
|
24
|
+
info_msg("File #{file_path} exists, forcing a delete")
|
25
|
+
File.delete(file_path)
|
26
|
+
else
|
27
|
+
raise "File #{file_path} already exist"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def create_target_directory(file_path)
|
33
|
+
dirname = File.dirname(file_path)
|
34
|
+
return if File.exist?(dirname)
|
35
|
+
debug_msg("Creating directory #{dirname} ...")
|
36
|
+
FileUtils.mkdir_p(dirname)
|
37
|
+
end
|
38
|
+
|
39
|
+
def rm_file(file, dir = "/")
|
40
|
+
path = path_join(dir, file)
|
41
|
+
File.delete(path) if File.exist?(path)
|
42
|
+
end
|
43
|
+
|
44
|
+
def path_join(*args)
|
45
|
+
path = Pathname.new(args.shift)
|
46
|
+
args.each { |path_seg| path = path.join("./#{path_seg}") }
|
47
|
+
path
|
48
|
+
end
|
49
|
+
|
50
|
+
def file_binary?(file)
|
51
|
+
data = File.read(file)
|
52
|
+
ascii = control = binary = total = 0
|
53
|
+
data[0..512].each_byte do |c|
|
54
|
+
total += 1
|
55
|
+
if c < 32
|
56
|
+
control += 1
|
57
|
+
elsif c >= 32 && c <= 128
|
58
|
+
ascii += 1
|
59
|
+
else
|
60
|
+
binary += 1
|
61
|
+
end
|
62
|
+
end
|
63
|
+
control.to_f / ascii > 0.1 || binary.to_f / ascii > 0.05
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module HttpdConfigmapGenerator
|
2
|
+
class Base
|
3
|
+
def enable_kerberos_dns_lookups
|
4
|
+
info_msg("Configuring Kerberos DNS Lookups")
|
5
|
+
config_file_backup(KERBEROS_CONFIG_FILE)
|
6
|
+
krb5config = File.read(KERBEROS_CONFIG_FILE)
|
7
|
+
krb5config[/(\s*)dns_lookup_kdc(\s*)=(\s*)(.*)/, 4] = 'true' if krb5config[/(\s*)dns_lookup_kdc(\s*)=/]
|
8
|
+
krb5config[/(\s*)dns_lookup_realm(\s*)=(\s*)(.*)/, 4] = 'true' if krb5config[/(\s*)dns_lookup_realm(\s*)=/]
|
9
|
+
debug_msg("- Updating #{KERBEROS_CONFIG_FILE}")
|
10
|
+
File.write(KERBEROS_CONFIG_FILE, krb5config)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module HttpdConfigmapGenerator
|
2
|
+
class Base
|
3
|
+
HOSTNAME_COMMAND = "/usr/bin/hostname".freeze
|
4
|
+
|
5
|
+
def realm
|
6
|
+
domain.upcase
|
7
|
+
end
|
8
|
+
|
9
|
+
def domain
|
10
|
+
domain_from_host(opts[:host])
|
11
|
+
end
|
12
|
+
|
13
|
+
def domain_from_host(host)
|
14
|
+
host.gsub(/^([^.]+\.)/, '') if host.present? && host.include?('.')
|
15
|
+
end
|
16
|
+
|
17
|
+
def host_reachable?(host)
|
18
|
+
require "net/ping"
|
19
|
+
Net::Ping::External.new(host).ping
|
20
|
+
end
|
21
|
+
|
22
|
+
def update_hostname(host)
|
23
|
+
command_run!(HOSTNAME_COMMAND, :params => [host]) if command_run(HOSTNAME_COMMAND).output.strip != host
|
24
|
+
end
|
25
|
+
|
26
|
+
def fetch_network_file(source_file, target_file)
|
27
|
+
require "net/http"
|
28
|
+
|
29
|
+
delete_target_file(target_file)
|
30
|
+
create_target_directory(target_file)
|
31
|
+
info_msg("Downloading #{source_file} ...")
|
32
|
+
result = Net::HTTP.get_response(URI(source_file))
|
33
|
+
raise "Failed to fetch URL file source #{source_file}" unless result.kind_of?(Net::HTTPSuccess)
|
34
|
+
File.write(target_file, result.body)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require "awesome_spawn"
|
2
|
+
|
3
|
+
module HttpdConfigmapGenerator
|
4
|
+
class Principal < Base
|
5
|
+
attr_accessor :hostname
|
6
|
+
attr_accessor :realm # EXAMPLE.COM
|
7
|
+
attr_accessor :service # HTTP
|
8
|
+
|
9
|
+
attr_accessor :name # Kerberos principal name generated
|
10
|
+
|
11
|
+
def initialize(options = {})
|
12
|
+
options.each { |n, v| public_send("#{n}=", v) }
|
13
|
+
@realm = @realm.upcase if @realm
|
14
|
+
@name ||= "#{service}/#{hostname}@#{realm}"
|
15
|
+
@name
|
16
|
+
end
|
17
|
+
|
18
|
+
def register
|
19
|
+
request unless exist?
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def exist?
|
25
|
+
command_run(IPA_COMMAND, :params => ["-e", "skip_version_check=1", "service-find", "--principal", name]).success?
|
26
|
+
end
|
27
|
+
|
28
|
+
def request
|
29
|
+
# Using --force because these services tend not to be in dns. This is like VERIFY_NONE.
|
30
|
+
command_run!(IPA_COMMAND, :params => ["-e", "skip_version_check=1", "service-add", "--force", name])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'iniparse'
|
2
|
+
|
3
|
+
module HttpdConfigmapGenerator
|
4
|
+
class Sssd < Base
|
5
|
+
attr_accessor :sssd
|
6
|
+
attr_accessor :opts
|
7
|
+
|
8
|
+
def initialize(opts = {})
|
9
|
+
@opts = opts
|
10
|
+
@sssd = nil
|
11
|
+
end
|
12
|
+
|
13
|
+
def load(file_path)
|
14
|
+
@sssd = IniParse.open(file_path)
|
15
|
+
end
|
16
|
+
|
17
|
+
def save(file_path)
|
18
|
+
return unless sssd
|
19
|
+
config_file_backup(file_path)
|
20
|
+
info_msg("Saving SSSD to #{file_path}")
|
21
|
+
sssd.save(file_path)
|
22
|
+
end
|
23
|
+
|
24
|
+
def configure_domain(domain)
|
25
|
+
domain = section("domain/#{domain}")
|
26
|
+
domain["ldap_user_extra_attrs"] = LDAP_ATTRS.keys.join(", ")
|
27
|
+
domain["entry_cache_timeout"] = 600
|
28
|
+
end
|
29
|
+
|
30
|
+
def add_service(service)
|
31
|
+
services = section("sssd")["services"]
|
32
|
+
services = (services.split(",").map(&:strip) | [service]).join(", ")
|
33
|
+
sssd.section("sssd")["services"] = services
|
34
|
+
sssd.section(service)
|
35
|
+
end
|
36
|
+
|
37
|
+
def configure_ifp
|
38
|
+
add_service("ifp")
|
39
|
+
ifp = section("ifp")
|
40
|
+
ifp["allowed_uids"] = "#{APACHE_USER}, root"
|
41
|
+
ifp["user_attributes"] = LDAP_ATTRS.keys.collect { |k| "+#{k}" }.join(", ")
|
42
|
+
end
|
43
|
+
|
44
|
+
def section(key)
|
45
|
+
if key =~ /^domain\/.*$/
|
46
|
+
key = sssd.entries.collect(&:key).select { |k| k.downcase == key.downcase }.first
|
47
|
+
end
|
48
|
+
sssd.section(key)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module HttpdConfigmapGenerator
|
2
|
+
class Export < Base
|
3
|
+
def required_options
|
4
|
+
{
|
5
|
+
:input => { :description => "Input config map file",
|
6
|
+
:short => "-i" },
|
7
|
+
:file => { :description => "Config map file to export",
|
8
|
+
:short => "-l" },
|
9
|
+
:output => { :description => "The output file being exported",
|
10
|
+
:short => "-o" }
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
def export(opts)
|
15
|
+
validate_options(opts)
|
16
|
+
@opts = opts
|
17
|
+
config_map = ConfigMap.new(opts)
|
18
|
+
config_map.load(opts[:input])
|
19
|
+
config_map.export_file(opts[:file], opts[:output])
|
20
|
+
rescue => err
|
21
|
+
log_command_error(err)
|
22
|
+
raise err
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def validate_options(options)
|
28
|
+
raise "Input configuration map #{options[:input]} does not exist" unless File.exist?(options[:input])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module HttpdConfigmapGenerator
|
2
|
+
class Ipa < Base
|
3
|
+
IPA_INSTALL_COMMAND = "/usr/sbin/ipa-client-install".freeze
|
4
|
+
IPA_GETKEYTAB = "/usr/sbin/ipa-getkeytab".freeze
|
5
|
+
AUTH = {
|
6
|
+
:type => "external",
|
7
|
+
:subtype => "ipa"
|
8
|
+
}.freeze
|
9
|
+
|
10
|
+
def required_options
|
11
|
+
super.merge(
|
12
|
+
:ipa_server => { :description => "IPA Server Fqdn" },
|
13
|
+
:ipa_password => { :description => "IPA Server Password" }
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
def optional_options
|
18
|
+
super.merge(
|
19
|
+
:ipa_principal => { :description => "IPA Server Principal", :default => "admin" },
|
20
|
+
:ipa_domain => { :description => "Domain of IPA Server" },
|
21
|
+
:ipa_realm => { :description => "Realm of IPA Server" }
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
def persistent_files
|
26
|
+
%w(
|
27
|
+
/etc/http.keytab
|
28
|
+
/etc/ipa/ca.crt
|
29
|
+
/etc/ipa/default.conf
|
30
|
+
/etc/ipa/nssdb/cert8.db
|
31
|
+
/etc/ipa/nssdb/key3.db
|
32
|
+
/etc/ipa/nssdb/pwdfile.txt
|
33
|
+
/etc/ipa/nssdb/secmod.db
|
34
|
+
/etc/krb5.conf
|
35
|
+
/etc/krb5.keytab
|
36
|
+
/etc/nsswitch.conf
|
37
|
+
/etc/openldap/ldap.conf
|
38
|
+
/etc/pam.d/fingerprint-auth-ac
|
39
|
+
/etc/pam.d/httpd-auth
|
40
|
+
/etc/pam.d/password-auth-ac
|
41
|
+
/etc/pam.d/postlogin-ac
|
42
|
+
/etc/pam.d/smartcard-auth-ac
|
43
|
+
/etc/pam.d/system-auth-ac
|
44
|
+
/etc/pki/ca-trust/source/ipa.p11-kit
|
45
|
+
/etc/sssd/sssd.conf
|
46
|
+
/etc/sysconfig/authconfig
|
47
|
+
/etc/sysconfig/network
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def configure(opts)
|
52
|
+
update_hostname(opts[:host])
|
53
|
+
command_run!(IPA_INSTALL_COMMAND,
|
54
|
+
:params => [
|
55
|
+
"-N", :force_join, :fixed_primary, :unattended, {
|
56
|
+
:realm= => realm,
|
57
|
+
:domain= => domain,
|
58
|
+
:server= => opts[:ipa_server],
|
59
|
+
:principal= => opts[:ipa_principal],
|
60
|
+
:password= => opts[:ipa_password]
|
61
|
+
}
|
62
|
+
])
|
63
|
+
configure_ipa_http_service
|
64
|
+
configure_pam
|
65
|
+
configure_sssd
|
66
|
+
enable_kerberos_dns_lookups
|
67
|
+
config_map = ConfigMap.new(opts)
|
68
|
+
config_map.generate(AUTH[:type], realm, persistent_files)
|
69
|
+
config_map.save(opts[:output])
|
70
|
+
rescue => err
|
71
|
+
log_command_error(err)
|
72
|
+
raise err
|
73
|
+
end
|
74
|
+
|
75
|
+
def configured?
|
76
|
+
File.exist?(SSSD_CONFIG)
|
77
|
+
end
|
78
|
+
|
79
|
+
def unconfigure
|
80
|
+
return unless configured?
|
81
|
+
command_run(IPA_INSTALL_COMMAND, :params => [:uninstall, :unattended])
|
82
|
+
end
|
83
|
+
|
84
|
+
def realm
|
85
|
+
@realm ||= opts[:ipa_realm] if opts[:ipa_realm].present?
|
86
|
+
@realm ||= domain
|
87
|
+
@realm ||= super
|
88
|
+
@realm = @realm.upcase
|
89
|
+
end
|
90
|
+
|
91
|
+
def domain
|
92
|
+
@domain ||= opts[:ipa_domain] if opts[:ipa_domain].present?
|
93
|
+
@domain ||= domain_from_host(opts[:ipa_server]) if opts[:ipa_server].present?
|
94
|
+
@domain ||= super
|
95
|
+
@domain
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def configure_sssd
|
101
|
+
info_msg("Configuring SSSD Service")
|
102
|
+
sssd = Sssd.new(opts)
|
103
|
+
sssd.load(SSSD_CONFIG)
|
104
|
+
sssd.configure_domain(domain)
|
105
|
+
sssd.add_service("pam")
|
106
|
+
sssd.configure_ifp
|
107
|
+
debug_msg("- Creating #{SSSD_CONFIG}")
|
108
|
+
sssd.save(SSSD_CONFIG)
|
109
|
+
end
|
110
|
+
|
111
|
+
def configure_ipa_http_service
|
112
|
+
info_msg("Configuring IPA HTTP Service")
|
113
|
+
command_run!("/usr/bin/kinit", :params => [opts[:ipa_principal]], :stdin_data => opts[:ipa_password])
|
114
|
+
service = Principal.new(:hostname => opts[:host], :realm => realm, :service => "HTTP")
|
115
|
+
service.register
|
116
|
+
debug_msg("- Fetching #{HTTP_KEYTAB}")
|
117
|
+
command_run!(IPA_GETKEYTAB, :params => {"-s" => opts[:ipa_server], "-k" => HTTP_KEYTAB, "-p" => service.name})
|
118
|
+
FileUtils.chown(APACHE_USER, nil, HTTP_KEYTAB)
|
119
|
+
FileUtils.chmod(0o600, HTTP_KEYTAB)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module HttpdConfigmapGenerator
|
2
|
+
def self.required_options
|
3
|
+
{
|
4
|
+
:host => { :description => "Application Domain" }
|
5
|
+
}
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.optional_options
|
9
|
+
{
|
10
|
+
:force => { :description => "Force configuration if configured already", :default => false }
|
11
|
+
}
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module HttpdConfigmapGenerator
|
2
|
+
class Saml < Base
|
3
|
+
MELLON_CREATE_METADATA_COMMAND = "/usr/libexec/mod_auth_mellon/mellon_create_metadata.sh".freeze
|
4
|
+
SAML2_CONFIG_DIRECTORY = "/etc/httpd/saml2".freeze
|
5
|
+
MIQSP_METADATA_FILE = "#{SAML2_CONFIG_DIRECTORY}/miqsp-metadata.xml".freeze
|
6
|
+
IDP_METADATA_FILE = "#{SAML2_CONFIG_DIRECTORY}/idp-metadata.xml".freeze
|
7
|
+
AUTH = {
|
8
|
+
:type => "saml",
|
9
|
+
:subtype => "saml"
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
def required_options
|
13
|
+
super
|
14
|
+
end
|
15
|
+
|
16
|
+
def optional_options
|
17
|
+
super.merge(
|
18
|
+
:keycloak_add_metadata => { :description => "Download and add the Keycloak metadata file",
|
19
|
+
:default => false },
|
20
|
+
:keycloak_server => { :description => "Keycloak Server Fqdn or IP" },
|
21
|
+
:keycloak_realm => { :description => "Keycloak Realm for this client"}
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
def persistent_files
|
26
|
+
file_list = %w(
|
27
|
+
/etc/httpd/saml2/miqsp-key.key
|
28
|
+
/etc/httpd/saml2/miqsp-cert.cert
|
29
|
+
/etc/httpd/saml2/miqsp-metadata.xml
|
30
|
+
)
|
31
|
+
file_list += [IDP_METADATA_FILE] if opts[:keycloak_add_metadata]
|
32
|
+
file_list
|
33
|
+
end
|
34
|
+
|
35
|
+
def configure(opts)
|
36
|
+
update_hostname(opts[:host])
|
37
|
+
Dir.mkdir(SAML2_CONFIG_DIRECTORY)
|
38
|
+
Dir.chdir(SAML2_CONFIG_DIRECTORY) do
|
39
|
+
command_run!(MELLON_CREATE_METADATA_COMMAND,
|
40
|
+
:params => [
|
41
|
+
"https://#{opts[:host]}",
|
42
|
+
"https://#{opts[:host]}/saml2"
|
43
|
+
])
|
44
|
+
rename_mellon_configfiles
|
45
|
+
fetch_idp_metadata
|
46
|
+
end
|
47
|
+
config_map = ConfigMap.new(opts)
|
48
|
+
config_map.generate(AUTH[:type], realm, persistent_files)
|
49
|
+
config_map.save(opts[:output])
|
50
|
+
rescue => err
|
51
|
+
log_command_error(err)
|
52
|
+
raise err
|
53
|
+
end
|
54
|
+
|
55
|
+
def configured?
|
56
|
+
File.exist?(MIQSP_METADATA_FILE)
|
57
|
+
end
|
58
|
+
|
59
|
+
def unconfigure
|
60
|
+
return unless configured?
|
61
|
+
FileUtils.rm_rf(SAML2_CONFIG_DIRECTORY) if Dir.exist?(SAML2_CONFIG_DIRECTORY)
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def validate_options(options)
|
67
|
+
super(options)
|
68
|
+
if options[:keycloak_add_metadata]
|
69
|
+
if options[:keycloak_server] == "" || options[:keycloak_realm] == ""
|
70
|
+
raise "Must specify both keycloak-server and keycloak-realm for fetching the IdP metadata file"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def rename_mellon_configfiles
|
76
|
+
info_msg("Renaming mellon config files")
|
77
|
+
Dir.chdir(SAML2_CONFIG_DIRECTORY) do
|
78
|
+
Dir.glob("https_*.*") do |mellon_file|
|
79
|
+
miq_saml2_file = nil
|
80
|
+
case mellon_file
|
81
|
+
when /^https_.*\.key$/
|
82
|
+
miq_saml2_file = "miqsp-key.key"
|
83
|
+
when /^https_.*\.cert$/
|
84
|
+
miq_saml2_file = "miqsp-cert.cert"
|
85
|
+
when /^https_.*\.xml$/
|
86
|
+
miq_saml2_file = "miqsp-metadata.xml"
|
87
|
+
end
|
88
|
+
if miq_saml2_file
|
89
|
+
debug_msg("- renaming #{mellon_file} to #{miq_saml2_file}")
|
90
|
+
File.rename(mellon_file, miq_saml2_file)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def fetch_idp_metadata
|
97
|
+
if opts[:keycloak_add_metadata]
|
98
|
+
source_file = "http://#{opts[:keycloak_server]}:8080"
|
99
|
+
source_file += "/auth/realms/#{opts[:keycloak_realm]}/protocol/saml/descriptor"
|
100
|
+
fetch_network_file(source_file, IDP_METADATA_FILE)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|