048aebbdf2
SeeRM #8754 Cast the results of the query to an array and perform the uniq function passing a block which provides uniqueness based on the return value, which in this instance is ‘fullname’ This was done because the uniq function in AREL cannot take a specific field for uniqueness, and the sophistication of the query make grouping nearly impossible. Initial testing showed negligible speed difference to the user.
689 lines
20 KiB
Ruby
689 lines
20 KiB
Ruby
# -*- coding: binary -*-
|
|
|
|
require 'msf/base/config'
|
|
require 'msf/core'
|
|
require 'msf/core/db'
|
|
require 'msf/core/db_manager/migration'
|
|
require 'msf/core/task_manager'
|
|
require 'fileutils'
|
|
require 'shellwords'
|
|
|
|
module Msf
|
|
|
|
###
|
|
#
|
|
# The db module provides persistent storage and events. This class should be instantiated LAST
|
|
# as the active_suppport library overrides Kernel.require, slowing down all future code loads.
|
|
#
|
|
###
|
|
|
|
class DBManager
|
|
# Provides :framework and other accessors
|
|
include Msf::DBManager::Migration
|
|
include Msf::Framework::Offspring
|
|
|
|
# Mainly, it's Ruby 1.9.1 that cause a lot of problems now, along with Ruby 1.8.6.
|
|
# Ruby 1.8.7 actually seems okay, but why tempt fate? Let's say 1.9.3 and beyond.
|
|
def warn_about_rubies
|
|
if ::RUBY_VERSION =~ /^1\.9\.[012]($|[^\d])/
|
|
$stderr.puts "**************************************************************************************"
|
|
$stderr.puts "Metasploit requires at least Ruby 1.9.3. For an easy upgrade path, see https://rvm.io/"
|
|
$stderr.puts "**************************************************************************************"
|
|
end
|
|
end
|
|
|
|
# Returns true if we are ready to load/store data
|
|
def active
|
|
return false if not @usable
|
|
# We have established a connection, some connection is active, and we have run migrations
|
|
(ActiveRecord::Base.connected? && ActiveRecord::Base.connection_pool.connected? && migrated)# rescue false
|
|
end
|
|
|
|
# Returns true if the prerequisites have been installed
|
|
attr_accessor :usable
|
|
|
|
# Returns the list of usable database drivers
|
|
attr_accessor :drivers
|
|
|
|
# Returns the active driver
|
|
attr_accessor :driver
|
|
|
|
# Stores the error message for why the db was not loaded
|
|
attr_accessor :error
|
|
|
|
# Stores a TaskManager for serializing database events
|
|
attr_accessor :sink
|
|
|
|
# Flag to indicate that modules are cached
|
|
attr_accessor :modules_cached
|
|
|
|
# Flag to indicate that the module cacher is running
|
|
attr_accessor :modules_caching
|
|
|
|
def initialize(framework, opts = {})
|
|
|
|
self.framework = framework
|
|
self.migrated = false
|
|
self.modules_cached = false
|
|
self.modules_caching = false
|
|
|
|
@usable = false
|
|
|
|
# Don't load the database if the user said they didn't need it.
|
|
if (opts['DisableDatabase'])
|
|
self.error = "disabled"
|
|
return
|
|
end
|
|
|
|
initialize_database_support
|
|
end
|
|
|
|
#
|
|
# Do what is necessary to load our database support
|
|
#
|
|
def initialize_database_support
|
|
begin
|
|
# Database drivers can reset our KCODE, do not let them
|
|
$KCODE = 'NONE' if RUBY_VERSION =~ /^1\.8\./
|
|
|
|
require "active_record"
|
|
|
|
initialize_metasploit_data_models
|
|
|
|
@usable = true
|
|
|
|
rescue ::Exception => e
|
|
self.error = e
|
|
elog("DB is not enabled due to load error: #{e}")
|
|
return false
|
|
end
|
|
|
|
# Only include Mdm if we're not using Metasploit commercial versions
|
|
# If Mdm::Host is defined, the dynamically created classes
|
|
# are already in the object space
|
|
begin
|
|
unless defined? Mdm::Host
|
|
MetasploitDataModels.require_models
|
|
end
|
|
rescue NameError => e
|
|
warn_about_rubies
|
|
raise e
|
|
end
|
|
|
|
#
|
|
# Determine what drivers are available
|
|
#
|
|
initialize_drivers
|
|
|
|
#
|
|
# Instantiate the database sink
|
|
#
|
|
initialize_sink
|
|
|
|
true
|
|
end
|
|
|
|
#
|
|
# Scan through available drivers
|
|
#
|
|
def initialize_drivers
|
|
self.drivers = []
|
|
tdrivers = %W{ postgresql }
|
|
tdrivers.each do |driver|
|
|
begin
|
|
ActiveRecord::Base.default_timezone = :utc
|
|
ActiveRecord::Base.establish_connection(:adapter => driver)
|
|
if(self.respond_to?("driver_check_#{driver}"))
|
|
self.send("driver_check_#{driver}")
|
|
end
|
|
ActiveRecord::Base.remove_connection
|
|
self.drivers << driver
|
|
rescue ::Exception
|
|
end
|
|
end
|
|
|
|
if(not self.drivers.empty?)
|
|
self.driver = self.drivers[0]
|
|
end
|
|
|
|
# Database drivers can reset our KCODE, do not let them
|
|
$KCODE = 'NONE' if RUBY_VERSION =~ /^1\.8\./
|
|
end
|
|
|
|
# Loads Metasploit Data Models and adds its migrations to migrations paths.
|
|
#
|
|
# @return [void]
|
|
def initialize_metasploit_data_models
|
|
# Provide access to ActiveRecord models shared w/ commercial versions
|
|
require "metasploit_data_models"
|
|
|
|
metasploit_data_model_migrations_pathname = MetasploitDataModels.root.join(
|
|
'db',
|
|
'migrate'
|
|
)
|
|
metasploit_data_model_migrations_path = metasploit_data_model_migrations_pathname.to_s
|
|
|
|
# Since ActiveRecord::Migrator.migrations_paths can persist between
|
|
# instances of Msf::DBManager, such as in specs,
|
|
# metasploit_data_models_migrations_path may already be part of
|
|
# migrations_paths, in which case it should not be added or multiple
|
|
# migrations with the same version number errors will occur.
|
|
unless ActiveRecord::Migrator.migrations_paths.include? metasploit_data_model_migrations_path
|
|
ActiveRecord::Migrator.migrations_paths << metasploit_data_model_migrations_path
|
|
end
|
|
end
|
|
|
|
#
|
|
# Create a new database sink and initialize it
|
|
#
|
|
def initialize_sink
|
|
self.sink = TaskManager.new(framework)
|
|
self.sink.start
|
|
end
|
|
|
|
#
|
|
# Add a new task to the sink
|
|
#
|
|
def queue(proc)
|
|
self.sink.queue_proc(proc)
|
|
end
|
|
|
|
#
|
|
# Connects this instance to a database
|
|
#
|
|
def connect(opts={})
|
|
|
|
return false if not @usable
|
|
|
|
nopts = opts.dup
|
|
if (nopts['port'])
|
|
nopts['port'] = nopts['port'].to_i
|
|
end
|
|
|
|
# Prefer the config file's pool setting
|
|
nopts['pool'] ||= 75
|
|
|
|
# Prefer the config file's wait_timeout setting too
|
|
nopts['wait_timeout'] ||= 300
|
|
|
|
begin
|
|
self.migrated = false
|
|
create_db(nopts)
|
|
|
|
# Configure the database adapter
|
|
ActiveRecord::Base.establish_connection(nopts)
|
|
|
|
# Migrate the database, if needed
|
|
migrate
|
|
|
|
# Set the default workspace
|
|
framework.db.workspace = framework.db.default_workspace
|
|
|
|
# Flag that migration has completed
|
|
self.migrated = true
|
|
rescue ::Exception => e
|
|
self.error = e
|
|
elog("DB.connect threw an exception: #{e}")
|
|
dlog("Call stack: #{$@.join"\n"}", LEV_1)
|
|
return false
|
|
ensure
|
|
# Database drivers can reset our KCODE, do not let them
|
|
$KCODE = 'NONE' if RUBY_VERSION =~ /^1\.8\./
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
#
|
|
# Attempt to create the database
|
|
#
|
|
# If the database already exists this will fail and we will continue on our
|
|
# merry way, connecting anyway. If it doesn't, we try to create it. If
|
|
# that fails, then it wasn't meant to be and the connect will raise a
|
|
# useful exception so the user won't be in the dark; no need to raise
|
|
# anything at all here.
|
|
#
|
|
def create_db(opts)
|
|
begin
|
|
case opts["adapter"]
|
|
when 'postgresql'
|
|
# Try to force a connection to be made to the database, if it succeeds
|
|
# then we know we don't need to create it :)
|
|
ActiveRecord::Base.establish_connection(opts)
|
|
# Do the checkout, checkin dance here to make sure this thread doesn't
|
|
# hold on to a connection we don't need
|
|
conn = ActiveRecord::Base.connection_pool.checkout
|
|
ActiveRecord::Base.connection_pool.checkin(conn)
|
|
end
|
|
rescue ::Exception => e
|
|
errstr = e.to_s
|
|
if errstr =~ /does not exist/i or errstr =~ /Unknown database/
|
|
ilog("Database doesn't exist \"#{opts['database']}\", attempting to create it.")
|
|
ActiveRecord::Base.establish_connection(opts.merge('database' => nil))
|
|
ActiveRecord::Base.connection.create_database(opts['database'])
|
|
else
|
|
ilog("Trying to continue despite failed database creation: #{e}")
|
|
end
|
|
end
|
|
ActiveRecord::Base.remove_connection
|
|
end
|
|
|
|
#
|
|
# Disconnects a database session
|
|
#
|
|
def disconnect
|
|
begin
|
|
ActiveRecord::Base.remove_connection
|
|
self.migrated = false
|
|
self.modules_cached = false
|
|
rescue ::Exception => e
|
|
self.error = e
|
|
elog("DB.disconnect threw an exception: #{e}")
|
|
ensure
|
|
# Database drivers can reset our KCODE, do not let them
|
|
$KCODE = 'NONE' if RUBY_VERSION =~ /^1\.8\./
|
|
end
|
|
end
|
|
|
|
def workspace=(workspace)
|
|
@workspace_name = workspace.name
|
|
end
|
|
|
|
def workspace
|
|
framework.db.find_workspace(@workspace_name)
|
|
end
|
|
|
|
|
|
# @note Does nothing unless {#migrated} is +true+ and {#modules_caching} is
|
|
# +false+.
|
|
#
|
|
# Destroys all Mdm::Module::Details in the database.
|
|
#
|
|
# @return [void]
|
|
def purge_all_module_details
|
|
return if not self.migrated
|
|
return if self.modules_caching
|
|
|
|
::ActiveRecord::Base.connection_pool.with_connection do
|
|
Mdm::Module::Detail.destroy_all
|
|
end
|
|
end
|
|
|
|
# Destroys the old Mdm::Module::Detail and creates a new Mdm::Module::Detail for
|
|
# any module with an Mdm::Module::Detail where the modification time of the
|
|
# Mdm::Module::Detail#file differs from the Mdm::Module::Detail#mtime. If the
|
|
# Mdm::Module::Detail#file no only exists on disk, then the Mdm::Module::Detail
|
|
# is just destroyed without a new one being created.
|
|
#
|
|
# @return [void]
|
|
def update_all_module_details
|
|
return if not self.migrated
|
|
return if self.modules_caching
|
|
|
|
self.framework.cache_thread = Thread.current
|
|
|
|
self.modules_cached = false
|
|
self.modules_caching = true
|
|
|
|
ActiveRecord::Base.connection_pool.with_connection do
|
|
|
|
refresh = []
|
|
skipped = []
|
|
|
|
Mdm::Module::Detail.find_each do |md|
|
|
|
|
unless md.ready
|
|
refresh << md
|
|
next
|
|
end
|
|
|
|
unless md.file and ::File.exists?(md.file)
|
|
refresh << md
|
|
next
|
|
end
|
|
|
|
if ::File.mtime(md.file).to_i != md.mtime.to_i
|
|
refresh << md
|
|
next
|
|
end
|
|
|
|
skipped << [md.mtype, md.refname]
|
|
end
|
|
|
|
refresh.each { |md| md.destroy }
|
|
|
|
[
|
|
['exploit', framework.exploits],
|
|
['auxiliary', framework.auxiliary],
|
|
['post', framework.post],
|
|
['payload', framework.payloads],
|
|
['encoder', framework.encoders],
|
|
['nop', framework.nops]
|
|
].each do |mt|
|
|
mt[1].keys.sort.each do |mn|
|
|
next if skipped.include?([mt[0], mn])
|
|
obj = mt[1].create(mn)
|
|
next if not obj
|
|
begin
|
|
update_module_details(obj)
|
|
rescue ::Exception
|
|
elog("Error updating module details for #{obj.fullname}: #{$!.class} #{$!}")
|
|
end
|
|
end
|
|
end
|
|
|
|
self.framework.cache_initialized = true
|
|
end
|
|
|
|
# in reverse order of section before with_connection block
|
|
self.modules_caching = false
|
|
self.modules_cached = true
|
|
self.framework.cache_thread = nil
|
|
end
|
|
|
|
# Creates an Mdm::Module::Detail from a module instance.
|
|
#
|
|
# @param module_instance [Msf::Module] a metasploit module instance.
|
|
# @raise [ActiveRecord::RecordInvalid] if Hash from {#module_to_details_hash} is invalid attributes for
|
|
# Mdm::Module::Detail.
|
|
# @return [void]
|
|
def update_module_details(module_instance)
|
|
return if not self.migrated
|
|
|
|
ActiveRecord::Base.connection_pool.with_connection do
|
|
info = module_to_details_hash(module_instance)
|
|
bits = info.delete(:bits) || []
|
|
module_detail = Mdm::Module::Detail.create!(info)
|
|
|
|
bits.each do |args|
|
|
otype, vals = args
|
|
|
|
case otype
|
|
when :action
|
|
module_detail.add_action(vals[:name])
|
|
when :arch
|
|
module_detail.add_arch(vals[:name])
|
|
when :author
|
|
module_detail.add_author(vals[:name], vals[:email])
|
|
when :platform
|
|
module_detail.add_platform(vals[:name])
|
|
when :ref
|
|
module_detail.add_ref(vals[:name])
|
|
when :target
|
|
module_detail.add_target(vals[:index], vals[:name])
|
|
end
|
|
end
|
|
|
|
module_detail.ready = true
|
|
module_detail.save!
|
|
end
|
|
end
|
|
|
|
# Destroys Mdm::Module::Detail if one exists for the given
|
|
# Mdm::Module::Detail#mtype and Mdm::Module::Detail#refname.
|
|
#
|
|
# @param mtype [String] module type.
|
|
# @param refname [String] module reference name.
|
|
# @return [void]
|
|
def remove_module_details(mtype, refname)
|
|
return if not self.migrated
|
|
|
|
ActiveRecord::Base.connection_pool.with_connection do
|
|
Mdm::Module::Detail.where(:mtype => mtype, :refname => refname).destroy_all
|
|
end
|
|
end
|
|
|
|
def module_to_details_hash(m)
|
|
res = {}
|
|
bits = []
|
|
|
|
res[:mtime] = ::File.mtime(m.file_path) rescue Time.now
|
|
res[:file] = m.file_path
|
|
res[:mtype] = m.type
|
|
res[:name] = m.name.to_s
|
|
res[:refname] = m.refname
|
|
res[:fullname] = m.fullname
|
|
res[:rank] = m.rank.to_i
|
|
res[:license] = m.license.to_s
|
|
|
|
res[:description] = m.description.to_s.strip
|
|
|
|
m.arch.map{ |x|
|
|
bits << [ :arch, { :name => x.to_s } ]
|
|
}
|
|
|
|
m.platform.platforms.map{ |x|
|
|
bits << [ :platform, { :name => x.to_s.split('::').last.downcase } ]
|
|
}
|
|
|
|
m.author.map{|x|
|
|
bits << [ :author, { :name => x.to_s } ]
|
|
}
|
|
|
|
m.references.map do |r|
|
|
bits << [ :ref, { :name => [r.ctx_id.to_s, r.ctx_val.to_s].join("-") } ]
|
|
end
|
|
|
|
res[:privileged] = m.privileged?
|
|
|
|
|
|
if m.disclosure_date
|
|
begin
|
|
res[:disclosure_date] = m.disclosure_date.to_datetime.to_time
|
|
rescue ::Exception
|
|
res.delete(:disclosure_date)
|
|
end
|
|
end
|
|
|
|
if(m.type == "exploit")
|
|
|
|
m.targets.each_index do |i|
|
|
bits << [ :target, { :index => i, :name => m.targets[i].name.to_s } ]
|
|
if m.targets[i].platform
|
|
m.targets[i].platform.platforms.each do |name|
|
|
bits << [ :platform, { :name => name.to_s.split('::').last.downcase } ]
|
|
end
|
|
end
|
|
if m.targets[i].arch
|
|
bits << [ :arch, { :name => m.targets[i].arch.to_s } ]
|
|
end
|
|
end
|
|
|
|
if (m.default_target)
|
|
res[:default_target] = m.default_target
|
|
end
|
|
|
|
# Some modules are a combination, which means they are actually aggressive
|
|
res[:stance] = m.stance.to_s.index("aggressive") ? "aggressive" : "passive"
|
|
|
|
|
|
m.class.mixins.each do |x|
|
|
bits << [ :mixin, { :name => x.to_s } ]
|
|
end
|
|
end
|
|
|
|
if(m.type == "auxiliary")
|
|
|
|
m.actions.each_index do |i|
|
|
bits << [ :action, { :name => m.actions[i].name.to_s } ]
|
|
end
|
|
|
|
if (m.default_action)
|
|
res[:default_action] = m.default_action.to_s
|
|
end
|
|
|
|
res[:stance] = m.passive? ? "passive" : "aggressive"
|
|
end
|
|
|
|
res[:bits] = bits.uniq
|
|
|
|
res
|
|
end
|
|
|
|
# Wraps values in +'%'+ for Arel::Prediciation#matches_any and other match* methods that map to SQL +'LIKE'+ or
|
|
# +'ILIKE'+
|
|
#
|
|
# @param values [Set<String>, #each] a list of strings.
|
|
# @return [Arrray<String>] strings wrapped like %<string>%
|
|
def match_values(values)
|
|
wrapped_values = values.collect { |value|
|
|
"%#{value}%"
|
|
}
|
|
|
|
wrapped_values
|
|
end
|
|
|
|
# This provides a standard set of search filters for every module.
|
|
#
|
|
# Supported keywords with the format <keyword>:<search_value>:
|
|
# +app+:: If +client+ then matches +'passive'+ stance modules, otherwise matches +'active' stance modules.
|
|
# +author+:: Matches modules with the given author email or name.
|
|
# +bid+:: Matches modules with the given Bugtraq ID.
|
|
# +cve+:: Matches modules with the given CVE ID.
|
|
# +edb+:: Matches modules with the given Exploit-DB ID.
|
|
# +name+:: Matches modules with the given full name or name.
|
|
# +os+, +platform+:: Matches modules with the given platform or target name.
|
|
# +osvdb+:: Matches modules with the given OSVDB ID.
|
|
# +ref+:: Matches modules with the given reference ID.
|
|
# +type+:: Matches modules with the given type.
|
|
#
|
|
# Any text not associated with a keyword is matched against the description,
|
|
# the full name, and the name of the module; the name of the module actions;
|
|
# the name of the module archs; the name of the module authors; the name of
|
|
# module platform; the module refs; or the module target.
|
|
#
|
|
# @param search_string [String] a string of space separated keyword pairs or
|
|
# free form text.
|
|
# @return [[]] if search_string is +nil+
|
|
# @return [ActiveRecord::Relation] module details that matched
|
|
# +search_string+
|
|
def search_modules(search_string)
|
|
search_string ||= ''
|
|
search_string += " "
|
|
|
|
# Split search terms by space, but allow quoted strings
|
|
terms = Shellwords.shellwords(search_string)
|
|
terms.delete('')
|
|
|
|
# All terms are either included or excluded
|
|
value_set_by_keyword = Hash.new { |hash, keyword|
|
|
hash[keyword] = Set.new
|
|
}
|
|
|
|
terms.each do |term|
|
|
keyword, value = term.split(':', 2)
|
|
|
|
unless value
|
|
value = keyword
|
|
keyword = 'text'
|
|
end
|
|
|
|
unless value.empty?
|
|
keyword.downcase!
|
|
|
|
value_set = value_set_by_keyword[keyword]
|
|
value_set.add value
|
|
end
|
|
end
|
|
|
|
query = Mdm::Module::Detail.scoped
|
|
|
|
ActiveRecord::Base.connection_pool.with_connection do
|
|
# Although AREL supports taking the union or two queries, the ActiveRecord where syntax only supports
|
|
# intersection, so creating the where clause has to be delayed until all conditions can be or'd together and
|
|
# passed to one call ot where.
|
|
union_conditions = []
|
|
|
|
value_set_by_keyword.each do |keyword, value_set|
|
|
case keyword
|
|
when 'author'
|
|
formatted_values = match_values(value_set)
|
|
|
|
query = query.includes(:authors)
|
|
module_authors = Mdm::Module::Author.arel_table
|
|
union_conditions << module_authors[:email].matches_any(formatted_values)
|
|
union_conditions << module_authors[:name].matches_any(formatted_values)
|
|
when 'name'
|
|
formatted_values = match_values(value_set)
|
|
|
|
module_details = Mdm::Module::Detail.arel_table
|
|
union_conditions << module_details[:fullname].matches_any(formatted_values)
|
|
union_conditions << module_details[:name].matches_any(formatted_values)
|
|
when 'os', 'platform'
|
|
formatted_values = match_values(value_set)
|
|
|
|
query = query.includes(:platforms)
|
|
union_conditions << Mdm::Module::Platform.arel_table[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:targets)
|
|
union_conditions << Mdm::Module::Target.arel_table[:name].matches_any(formatted_values)
|
|
when 'text'
|
|
formatted_values = match_values(value_set)
|
|
|
|
module_details = Mdm::Module::Detail.arel_table
|
|
union_conditions << module_details[:description].matches_any(formatted_values)
|
|
union_conditions << module_details[:fullname].matches_any(formatted_values)
|
|
union_conditions << module_details[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:actions)
|
|
union_conditions << Mdm::Module::Action.arel_table[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:archs)
|
|
union_conditions << Mdm::Module::Arch.arel_table[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:authors)
|
|
union_conditions << Mdm::Module::Author.arel_table[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:platforms)
|
|
union_conditions << Mdm::Module::Platform.arel_table[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:refs)
|
|
union_conditions << Mdm::Module::Ref.arel_table[:name].matches_any(formatted_values)
|
|
|
|
query = query.includes(:targets)
|
|
union_conditions << Mdm::Module::Target.arel_table[:name].matches_any(formatted_values)
|
|
when 'type'
|
|
formatted_values = match_values(value_set)
|
|
union_conditions << Mdm::Module::Detail.arel_table[:mtype].matches_any(formatted_values)
|
|
when 'app'
|
|
formatted_values = value_set.collect { |value|
|
|
formatted_value = 'aggressive'
|
|
|
|
if value == 'client'
|
|
formatted_value = 'passive'
|
|
end
|
|
|
|
formatted_value
|
|
}
|
|
|
|
union_conditions << Mdm::Module::Detail.arel_table[:stance].eq_any(formatted_values)
|
|
when 'ref'
|
|
formatted_values = match_values(value_set)
|
|
|
|
query = query.includes(:refs)
|
|
union_conditions << Mdm::Module::Ref.arel_table[:name].matches_any(formatted_values)
|
|
when 'cve', 'bid', 'osvdb', 'edb'
|
|
formatted_values = value_set.collect { |value|
|
|
prefix = keyword.upcase
|
|
|
|
"#{prefix}-%#{value}%"
|
|
}
|
|
|
|
query = query.includes(:refs)
|
|
union_conditions << Mdm::Module::Ref.arel_table[:name].matches_any(formatted_values)
|
|
end
|
|
end
|
|
|
|
unioned_conditions = union_conditions.inject { |union, condition|
|
|
union.or(condition)
|
|
}
|
|
|
|
query = query.where(unioned_conditions).to_a.uniq { |m| m.fullname }
|
|
end
|
|
|
|
query
|
|
end
|
|
|
|
end
|
|
end
|