oxidized 0.29.1 → 0.37.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/.coderabbit.yaml +21 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +45 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +22 -0
- data/.github/ISSUE_TEMPLATE/support-request.md +39 -0
- data/.github/workflows/codeql.yml +4 -4
- data/.github/workflows/publishdocker.yml +108 -13
- data/.github/workflows/ruby.yml +3 -12
- data/.github/workflows/stale.yml +6 -1
- data/.gitignore +2 -0
- data/.rubocop.yml +69 -13
- data/.rubocop_todo.yml +13 -143
- data/CHANGELOG.md +431 -4
- data/CONTRIBUTING.md +189 -0
- data/Dockerfile +89 -34
- data/README.md +59 -188
- data/Rakefile +58 -12
- data/bin/oxidized +2 -5
- data/docs/Configuration.md +382 -149
- data/docs/Creating-Models.md +262 -10
- data/docs/DeviceSimulation.md +196 -0
- data/docs/Docker.md +246 -0
- data/docs/Hooks.md +146 -51
- data/docs/Inputs.md +237 -0
- data/docs/Issues.md +123 -0
- data/docs/Model-Notes/APC.md +72 -0
- data/docs/Model-Notes/Cumulus.md +5 -0
- data/docs/Model-Notes/EatonNetwork.md +18 -0
- data/docs/Model-Notes/ExaLink.md +43 -0
- data/docs/Model-Notes/FSOS.md +17 -0
- data/docs/Model-Notes/Fortinet.md +75 -0
- data/docs/Model-Notes/GrandstreamHT8xx.md +8 -0
- data/docs/Model-Notes/HPEAruba.md +32 -0
- data/docs/Model-Notes/IvantiConnectSecure.md +59 -0
- data/docs/Model-Notes/OS6.md +10 -0
- data/docs/Model-Notes/README.md +1 -20
- data/docs/Model-Notes/RouterOS.md +28 -0
- data/docs/Model-Notes/SikluMHTG.md +7 -0
- data/docs/Model-Notes/TrueNAS.md +23 -0
- data/docs/Model-Notes/XGS4600-Zyxel.md +5 -0
- data/docs/ModelUnitTests.md +219 -0
- data/docs/Outputs.md +102 -5
- data/docs/Release.md +102 -0
- data/docs/Ruby-API.md +135 -2
- data/docs/Sources.md +34 -0
- data/docs/Supported-OS-Types.md +207 -270
- data/docs/Troubleshooting.md +43 -1
- data/extra/device2yaml.rb +259 -0
- data/extra/gitdiff-msteams.sh +32 -5
- data/extra/hooks/modelrules.rb +55 -0
- data/extra/hooks/modelrulesadvanced.rb +167 -0
- data/extra/hooks/srcipmap.rb +54 -0
- data/extra/nagios_check_failing_nodes.rb +1 -1
- data/extra/oxidized.runit +1 -1
- data/extra/rest_client.rb +5 -4
- data/extra/syslog.rb +11 -6
- data/lib/oxidized/cli/support.rb +152 -0
- data/lib/oxidized/cli.rb +21 -6
- data/lib/oxidized/config/vars.rb +33 -14
- data/lib/oxidized/config.rb +28 -17
- data/lib/oxidized/core.rb +50 -8
- data/lib/oxidized/hook/ciscosparkdiff.rb +11 -9
- data/lib/oxidized/hook/exec.rb +5 -4
- data/lib/oxidized/hook/githubrepo.rb +55 -18
- data/lib/oxidized/hook/noophook.rb +2 -2
- data/lib/oxidized/hook/slackdiff.rb +50 -14
- data/lib/oxidized/hook/xmppdiff.rb +9 -9
- data/lib/oxidized/hook.rb +68 -16
- data/lib/oxidized/input/cli.rb +8 -3
- data/lib/oxidized/input/debugtext.rb +40 -0
- data/lib/oxidized/input/debugyaml.rb +82 -0
- data/lib/oxidized/input/exec.rb +2 -11
- data/lib/oxidized/input/ftp.rb +2 -19
- data/lib/oxidized/input/http.rb +67 -17
- data/lib/oxidized/input/input.rb +35 -14
- data/lib/oxidized/input/scp.rb +27 -0
- data/lib/oxidized/input/ssh.rb +59 -83
- data/lib/oxidized/input/sshbase.rb +102 -0
- data/lib/oxidized/input/telnet.rb +20 -19
- data/lib/oxidized/input/tftp.rb +8 -8
- data/lib/oxidized/job.rb +18 -7
- data/lib/oxidized/jobs.rb +11 -1
- data/lib/oxidized/logger.rb +51 -0
- data/lib/oxidized/manager.rb +18 -17
- data/lib/oxidized/model/acos.rb +2 -1
- data/lib/oxidized/model/addpack.rb +26 -0
- data/lib/oxidized/model/adtran.rb +5 -1
- data/lib/oxidized/model/adva.rb +3 -3
- data/lib/oxidized/model/airfiber.rb +1 -1
- data/lib/oxidized/model/aos7.rb +11 -0
- data/lib/oxidized/model/aoscx.rb +37 -29
- data/lib/oxidized/model/aosw.rb +39 -29
- data/lib/oxidized/model/apc_aos.rb +7 -2
- data/lib/oxidized/model/apcaos.rb +39 -0
- data/lib/oxidized/model/aricentiss.rb +9 -2
- data/lib/oxidized/model/arubainstant.rb +93 -0
- data/lib/oxidized/model/asa.rb +11 -10
- data/lib/oxidized/model/asternos.rb +22 -0
- data/lib/oxidized/model/asyncos.rb +3 -3
- data/lib/oxidized/model/audiocodes.rb +2 -2
- data/lib/oxidized/model/awplus.rb +15 -12
- data/lib/oxidized/model/axos.rb +1 -1
- data/lib/oxidized/model/bdcom.rb +1 -0
- data/lib/oxidized/model/c4cmts.rb +1 -2
- data/lib/oxidized/model/ciscosma.rb +1 -1
- data/lib/oxidized/model/ciscosmb.rb +6 -1
- data/lib/oxidized/model/cnos.rb +13 -10
- data/lib/oxidized/model/comtrol.rb +1 -1
- data/lib/oxidized/model/comware.rb +5 -3
- data/lib/oxidized/model/cumulus.rb +14 -2
- data/lib/oxidized/model/defacto.rb +26 -0
- data/lib/oxidized/model/dellx.rb +1 -1
- data/lib/oxidized/model/dlink.rb +5 -2
- data/lib/oxidized/model/dlinknextgen.rb +55 -0
- data/lib/oxidized/model/dnos.rb +3 -0
- data/lib/oxidized/model/dslcommands.rb +93 -0
- data/lib/oxidized/model/dslsetup.rb +102 -0
- data/lib/oxidized/model/eatonnetwork.rb +70 -0
- data/lib/oxidized/model/edgecos.rb +3 -1
- data/lib/oxidized/model/edgeos.rb +7 -6
- data/lib/oxidized/model/edgeswitch.rb +3 -1
- data/lib/oxidized/model/efos.rb +41 -0
- data/lib/oxidized/model/eltex.rb +4 -2
- data/lib/oxidized/model/enterasys800.rb +2 -2
- data/lib/oxidized/model/enterprise_sonic.rb +46 -0
- data/lib/oxidized/model/eos.rb +3 -1
- data/lib/oxidized/model/exalink.rb +36 -0
- data/lib/oxidized/model/f5os.rb +17 -0
- data/lib/oxidized/model/fabricos.rb +2 -2
- data/lib/oxidized/model/fastiron.rb +5 -3
- data/lib/oxidized/model/firebrick.rb +2 -2
- data/lib/oxidized/model/firelinuxos.rb +13 -6
- data/lib/oxidized/model/firewareos.rb +10 -1
- data/lib/oxidized/model/fortigate.rb +160 -0
- data/lib/oxidized/model/fortios.rb +38 -48
- data/lib/oxidized/model/fsos.rb +46 -0
- data/lib/oxidized/model/gaiaos.rb +4 -4
- data/lib/oxidized/model/garderos.rb +38 -0
- data/lib/oxidized/model/grandstreamht8xx.rb +19 -0
- data/lib/oxidized/model/h3c.rb +1 -1
- data/lib/oxidized/model/ibos.rb +1 -0
- data/lib/oxidized/model/ingate.rb +47 -0
- data/lib/oxidized/model/ios.rb +57 -30
- data/lib/oxidized/model/iosxr.rb +2 -2
- data/lib/oxidized/model/ironware.rb +7 -5
- data/lib/oxidized/model/ivanti.rb +54 -0
- data/lib/oxidized/model/junos.rb +12 -5
- data/lib/oxidized/model/kornfeldos.rb +33 -0
- data/lib/oxidized/model/lenovonos.rb +2 -0
- data/lib/oxidized/model/linksyssrw.rb +3 -3
- data/lib/oxidized/model/linuxgeneric.rb +4 -2
- data/lib/oxidized/model/macros.rb +60 -0
- data/lib/oxidized/model/mimosab11.rb +34 -0
- data/lib/oxidized/model/ml66.rb +33 -0
- data/lib/oxidized/model/mlnxos.rb +18 -14
- data/lib/oxidized/model/model.rb +98 -103
- data/lib/oxidized/model/mtrlrfs.rb +1 -0
- data/lib/oxidized/model/ndms.rb +6 -0
- data/lib/oxidized/model/netgear.rb +14 -3
- data/lib/oxidized/model/netonix.rb +1 -1
- data/lib/oxidized/model/netscaler.rb +1 -1
- data/lib/oxidized/model/nodegrid.rb +1 -1
- data/lib/oxidized/model/nsxdfw.rb +31 -0
- data/lib/oxidized/model/nsxfirewall.rb +2 -1
- data/lib/oxidized/model/nxos.rb +20 -15
- data/lib/oxidized/model/ocnos.rb +42 -0
- data/lib/oxidized/model/onefinity.rb +18 -0
- data/lib/oxidized/model/openbsd.rb +1 -1
- data/lib/oxidized/model/opengear.rb +36 -1
- data/lib/oxidized/model/openwrt.rb +6 -6
- data/lib/oxidized/model/opnsense.rb +9 -1
- data/lib/oxidized/model/os6.rb +48 -0
- data/lib/oxidized/model/outputs.rb +5 -0
- data/lib/oxidized/model/panos.rb +2 -0
- data/lib/oxidized/model/perle.rb +41 -0
- data/lib/oxidized/model/pfsense.rb +9 -0
- data/lib/oxidized/model/powerconnect.rb +32 -11
- data/lib/oxidized/model/procurve.rb +6 -2
- data/lib/oxidized/model/qtech.rb +3 -1
- data/lib/oxidized/model/quantaos.rb +8 -6
- data/lib/oxidized/model/rgos.rb +33 -0
- data/lib/oxidized/model/riverbed.rb +104 -0
- data/lib/oxidized/model/routeros.rb +18 -11
- data/lib/oxidized/model/saos.rb +18 -1
- data/lib/oxidized/model/saos10.rb +38 -0
- data/lib/oxidized/model/siklu.rb +1 -1
- data/lib/oxidized/model/siklumhtg.rb +22 -0
- data/lib/oxidized/model/sixwind.rb +28 -0
- data/lib/oxidized/model/slxos.rb +3 -3
- data/lib/oxidized/model/smartbyte.rb +48 -0
- data/lib/oxidized/model/sonicos.rb +27 -20
- data/lib/oxidized/model/speedtouch.rb +1 -1
- data/lib/oxidized/model/sros.rb +3 -3
- data/lib/oxidized/model/srosmd.rb +1 -1
- data/lib/oxidized/model/supermicro.rb +1 -1
- data/lib/oxidized/model/telco.rb +1 -1
- data/lib/oxidized/model/tmos.rb +1 -0
- data/lib/oxidized/model/tnsr.rb +57 -0
- data/lib/oxidized/model/tplink.rb +5 -5
- data/lib/oxidized/model/trango.rb +4 -2
- data/lib/oxidized/model/truenas.rb +64 -3
- data/lib/oxidized/model/ucs.rb +1 -1
- data/lib/oxidized/model/unifiap.rb +144 -0
- data/lib/oxidized/model/uplinkolt.rb +46 -0
- data/lib/oxidized/model/voltaire.rb +1 -1
- data/lib/oxidized/model/voss.rb +3 -0
- data/lib/oxidized/model/vrp.rb +5 -1
- data/lib/oxidized/model/vyatta.rb +9 -3
- data/lib/oxidized/model/vyos.rb +37 -0
- data/lib/oxidized/model/xos.rb +10 -1
- data/lib/oxidized/model/zhoneolt.rb +4 -2
- data/lib/oxidized/model/zynos.rb +67 -3
- data/lib/oxidized/model/zynosadsl.rb +14 -0
- data/lib/oxidized/model/zynoscli.rb +1 -1
- data/lib/oxidized/model/zynosgs.rb +2 -0
- data/lib/oxidized/model/zynosmgs.rb +32 -0
- data/lib/oxidized/node.rb +99 -64
- data/lib/oxidized/nodes.rb +40 -19
- data/lib/oxidized/output/file.rb +80 -43
- data/lib/oxidized/output/git.rb +295 -151
- data/lib/oxidized/output/gitcrypt.rb +193 -185
- data/lib/oxidized/output/http.rb +63 -51
- data/lib/oxidized/output/output.rb +20 -4
- data/lib/oxidized/signals.rb +44 -0
- data/lib/oxidized/source/csv.rb +49 -49
- data/lib/oxidized/source/http.rb +76 -63
- data/lib/oxidized/source/jsonfile.rb +68 -0
- data/lib/oxidized/source/source.rb +73 -18
- data/lib/oxidized/source/sql.rb +71 -59
- data/lib/oxidized/version.rb +6 -4
- data/lib/oxidized/worker.rb +57 -29
- data/lib/oxidized.rb +4 -24
- data/lib/refinements.rb +21 -1
- data/oxidized.gemspec +45 -21
- metadata +248 -41
- data/lib/oxidized/model/timos.rb +0 -10
data/lib/oxidized/input/http.rb
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
module Oxidized
|
|
2
|
-
require "oxidized/input/cli"
|
|
3
2
|
require "net/http"
|
|
4
3
|
require "json"
|
|
4
|
+
require "net/http/digest_auth"
|
|
5
5
|
|
|
6
6
|
class HTTP < Input
|
|
7
|
-
include Input::CLI
|
|
8
|
-
|
|
9
7
|
def connect(node)
|
|
10
8
|
@node = node
|
|
11
9
|
@secure = false
|
|
12
10
|
@username = nil
|
|
13
11
|
@password = nil
|
|
14
12
|
@headers = {}
|
|
15
|
-
@log = File.open(Oxidized::Config::Log + "/#{@node.ip}-http", "w") if Oxidized.config.input.debug?
|
|
16
13
|
@node.model.cfg["http"].each { |cb| instance_exec(&cb) }
|
|
17
14
|
|
|
18
15
|
return true unless @main_page && defined?(login)
|
|
@@ -47,26 +44,79 @@ module Oxidized
|
|
|
47
44
|
private
|
|
48
45
|
|
|
49
46
|
def get_http(path)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
res = perform_http_request(path, method: :get)
|
|
48
|
+
res.body
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def post_http(path, body = nil, extra_headers = {})
|
|
52
|
+
res = perform_http_request(path, method: :post, body: body, extra_headers: extra_headers)
|
|
53
|
+
res.body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def perform_http_request(path, method: :get, body: nil, extra_headers: {})
|
|
57
|
+
uri = get_uri(path)
|
|
58
|
+
http_method = method.to_s.upcase
|
|
59
|
+
|
|
60
|
+
logger.debug "Making #{http_method} request to: #{uri}"
|
|
61
|
+
|
|
57
62
|
ssl_verify = Oxidized.config.input.http.ssl_verify? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
58
|
-
|
|
63
|
+
|
|
64
|
+
res = make_request(uri, ssl_verify, extra_headers, method: method, body: body)
|
|
65
|
+
|
|
66
|
+
if res.code == '401' && res['www-authenticate']&.include?('Digest')
|
|
67
|
+
uri.user = @username
|
|
68
|
+
uri.password = URI.encode_www_form_component(@password)
|
|
69
|
+
logger.debug "Server requires Digest authentication"
|
|
70
|
+
|
|
71
|
+
auth = Net::HTTP::DigestAuth.new.auth_header(uri, res['www-authenticate'], http_method)
|
|
72
|
+
res = make_request(uri, ssl_verify, extra_headers.merge('Authorization' => auth),
|
|
73
|
+
method: method, body: body)
|
|
74
|
+
|
|
75
|
+
elsif @username && @password && !authorization_header_present?(extra_headers)
|
|
76
|
+
logger.debug "Falling back to Basic authentication"
|
|
77
|
+
res = make_request(uri, ssl_verify, extra_headers.merge('Authorization' => basic_auth_header),
|
|
78
|
+
method: method, body: body)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
logger.debug "Response code: #{res.code}"
|
|
82
|
+
res
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def make_request(uri, ssl_verify, extra_headers = {}, method: :get, body: nil)
|
|
86
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", verify_mode: ssl_verify) do |http|
|
|
87
|
+
req_class = if method == :get
|
|
88
|
+
Net::HTTP::Get
|
|
89
|
+
elsif method == :post
|
|
90
|
+
Net::HTTP::Post
|
|
91
|
+
else
|
|
92
|
+
raise Oxidized::OxidizedError, "Unsupported HTTP method: #{method.inspect}. " \
|
|
93
|
+
"Only :get and :post are supported"
|
|
94
|
+
end
|
|
95
|
+
req = req_class.new(uri)
|
|
96
|
+
@headers.merge(extra_headers).each { |header, value| req.add_field(header, value) }
|
|
97
|
+
req.body = body if body
|
|
98
|
+
|
|
99
|
+
logger.debug "Sending #{method.to_s.upcase} request with headers: #{@headers.merge(extra_headers)}"
|
|
59
100
|
http.request(req)
|
|
60
101
|
end
|
|
61
|
-
res.body
|
|
62
102
|
end
|
|
63
103
|
|
|
64
|
-
def
|
|
65
|
-
|
|
104
|
+
def authorization_header_present?(headers)
|
|
105
|
+
headers.keys.any? { |key| key.to_s.downcase == 'authorization' }
|
|
66
106
|
end
|
|
67
107
|
|
|
68
|
-
def
|
|
69
|
-
|
|
108
|
+
def basic_auth_header
|
|
109
|
+
"Basic " + ["#{@username}:#{@password}"].pack('m').delete("\r\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def disconnect; end
|
|
113
|
+
|
|
114
|
+
def get_uri(path)
|
|
115
|
+
path = URI.parse(path)
|
|
116
|
+
uri_class = @secure ? URI::HTTPS : URI::HTTP
|
|
117
|
+
uri_class.build(host: @node.ip,
|
|
118
|
+
path: path.path,
|
|
119
|
+
query: path.query)
|
|
70
120
|
end
|
|
71
121
|
end
|
|
72
122
|
end
|
data/lib/oxidized/input/input.rb
CHANGED
|
@@ -1,23 +1,44 @@
|
|
|
1
|
+
require_relative 'cli'
|
|
2
|
+
|
|
1
3
|
module Oxidized
|
|
2
4
|
class PromptUndetect < OxidizedError; end
|
|
3
5
|
|
|
4
6
|
class Input
|
|
7
|
+
include SemanticLogger::Loggable
|
|
5
8
|
include Oxidized::Config::Vars
|
|
9
|
+
include Oxidized::Input::CLI
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
debug
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
warn
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Errno::ENETUNREACH,
|
|
18
|
-
Errno::EPIPE,
|
|
19
|
-
Errno::ETIMEDOUT
|
|
20
|
-
]
|
|
11
|
+
RESCUE_FAIL = {
|
|
12
|
+
Errno::ECONNREFUSED => :debug,
|
|
13
|
+
IOError => :warn,
|
|
14
|
+
PromptUndetect => :warn,
|
|
15
|
+
Timeout::Error => :warn,
|
|
16
|
+
Errno::ECONNRESET => :warn,
|
|
17
|
+
Errno::EHOSTUNREACH => :warn,
|
|
18
|
+
Errno::ENETUNREACH => :warn,
|
|
19
|
+
Errno::EPIPE => :warn,
|
|
20
|
+
Errno::ETIMEDOUT => :warn
|
|
21
21
|
}.freeze
|
|
22
|
+
|
|
23
|
+
# Returns a hash mapping exception classes to their log level
|
|
24
|
+
def self.rescue_fail
|
|
25
|
+
RESCUE_FAIL.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.config_name
|
|
29
|
+
name.split('::').last.downcase
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.to_sym
|
|
33
|
+
config_name.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def config_name
|
|
37
|
+
self.class.config_name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_sym
|
|
41
|
+
self.class.to_sym
|
|
42
|
+
end
|
|
22
43
|
end
|
|
23
44
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Oxidized
|
|
2
|
+
require 'net/ssh'
|
|
3
|
+
begin
|
|
4
|
+
require 'net/scp'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise OxidizedError, 'net/scp not found: sudo gem install net-scp'
|
|
7
|
+
end
|
|
8
|
+
require 'timeout'
|
|
9
|
+
require_relative 'sshbase'
|
|
10
|
+
|
|
11
|
+
class SCP < SSHBase
|
|
12
|
+
RESCUE_FAIL = {
|
|
13
|
+
Net::SCP::Error => :warn
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.rescue_fail
|
|
17
|
+
super.merge(RESCUE_FAIL)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def cmd(file)
|
|
21
|
+
logger.debug "SCP: #{file} @ #{@node.name}"
|
|
22
|
+
Timeout.timeout(@node.timeout) do
|
|
23
|
+
@ssh.scp.download!(file)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/oxidized/input/ssh.rb
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
|
+
require 'timeout'
|
|
2
|
+
require_relative 'sshbase'
|
|
3
|
+
require_relative 'debugyaml'
|
|
4
|
+
require_relative 'debugtext'
|
|
5
|
+
|
|
1
6
|
module Oxidized
|
|
2
|
-
|
|
3
|
-
require 'net/ssh/proxy/command'
|
|
4
|
-
require 'timeout'
|
|
5
|
-
require 'oxidized/input/cli'
|
|
6
|
-
class SSH < Input
|
|
7
|
-
RescueFail = {
|
|
8
|
-
debug: [
|
|
9
|
-
Net::SSH::Disconnect
|
|
10
|
-
],
|
|
11
|
-
warn: [
|
|
12
|
-
RuntimeError,
|
|
13
|
-
Net::SSH::AuthenticationFailed
|
|
14
|
-
]
|
|
15
|
-
}.freeze
|
|
16
|
-
include Input::CLI
|
|
7
|
+
class SSH < SSHBase
|
|
17
8
|
class NoShell < OxidizedError; end
|
|
18
9
|
|
|
19
|
-
|
|
10
|
+
RESCUE_FAIL = {
|
|
11
|
+
RuntimeError => :warn
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.rescue_fail
|
|
15
|
+
super.merge(RESCUE_FAIL)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def connect(node) # rubocop:disable Naming/PredicateMethod
|
|
20
19
|
@node = node
|
|
21
|
-
@output = ''
|
|
20
|
+
@output = String.new('')
|
|
22
21
|
@pty_options = { term: "vt100" }
|
|
23
22
|
@node.model.cfg['ssh'].each { |cb| instance_exec(&cb) }
|
|
24
|
-
@log = File.open(Oxidized::Config::Log + "/#{@node.ip}-ssh", 'w') if Oxidized.config.input.debug?
|
|
25
23
|
|
|
26
|
-
Oxidized.
|
|
24
|
+
@yaml_debug = DebugYAML.new(Oxidized.config.input.debug, @node, config_name)
|
|
25
|
+
@text_debug = DebugText.new(Oxidized.config.input.debug, @node, config_name)
|
|
26
|
+
|
|
27
|
+
logger.debug "Connecting to #{@node.name}"
|
|
27
28
|
@ssh = Net::SSH.start(@node.ip, @node.auth[:username], make_ssh_opts)
|
|
28
29
|
unless @exec
|
|
29
30
|
shell_open @ssh
|
|
@@ -36,20 +37,31 @@ module Oxidized
|
|
|
36
37
|
connected?
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
def connected?
|
|
40
|
-
@ssh && (not @ssh.closed?)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
40
|
def cmd(cmd, expect = node.prompt)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
else
|
|
48
|
-
cmd_shell(cmd, expect).gsub(/\r\n/, "\n")
|
|
41
|
+
unless cmd.is_a?(String)
|
|
42
|
+
logger.error "cmd must be a String (#{cmd.class}): #{cmd.inspect} @ #{node.name}"
|
|
43
|
+
raise ArgumentError, "cmd must be a String"
|
|
49
44
|
end
|
|
45
|
+
logger.debug "Sending '#{cmd.dump}' @ #{node.name} with expect: #{expect.inspect}"
|
|
46
|
+
cmd_output = if @exec
|
|
47
|
+
@yaml_debug&.send_data(cmd)
|
|
48
|
+
@text_debug&.send_data(cmd)
|
|
49
|
+
@ssh.exec! cmd
|
|
50
|
+
else
|
|
51
|
+
cmd_shell(cmd, expect).gsub("\r\n", "\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# only logging @exec as cmd_shell is handled in the ssh loop
|
|
55
|
+
@yaml_debug&.receive_data(cmd_output) if @exec
|
|
56
|
+
@text_debug&.receive_data(cmd_output) if @exec
|
|
57
|
+
|
|
58
|
+
# Make sure we return a String
|
|
59
|
+
cmd_output.to_s
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def send(data)
|
|
63
|
+
@yaml_debug&.send_data(data)
|
|
64
|
+
@text_debug&.send_data(data)
|
|
53
65
|
@ses.send_data data
|
|
54
66
|
end
|
|
55
67
|
|
|
@@ -61,23 +73,27 @@ module Oxidized
|
|
|
61
73
|
|
|
62
74
|
private
|
|
63
75
|
|
|
76
|
+
# We need a specific disconnect for SSH in shell mode, see issue #3725
|
|
64
77
|
def disconnect
|
|
65
78
|
disconnect_cli
|
|
66
79
|
# if disconnect does not disconnect us, give up after timeout
|
|
67
|
-
Timeout.timeout(
|
|
68
|
-
rescue Errno::ECONNRESET, Net::SSH::Disconnect, IOError
|
|
80
|
+
Timeout.timeout(@node.timeout) { @ssh.loop }
|
|
81
|
+
rescue Errno::ECONNRESET, Net::SSH::Disconnect, IOError => e
|
|
82
|
+
logger.debug 'The other side closed the connection while ' \
|
|
83
|
+
"disconnecting, raising #{e.class} with #{e.message}"
|
|
84
|
+
rescue Timeout::Error
|
|
85
|
+
logger.debug "#{@node.name} timed out while disconnecting"
|
|
69
86
|
ensure
|
|
70
|
-
@
|
|
71
|
-
|
|
87
|
+
@yaml_debug&.close
|
|
88
|
+
@text_debug&.close
|
|
89
|
+
(@ssh.close rescue true) unless @ssh.closed? # rubocop:disable Style/RedundantParentheses
|
|
72
90
|
end
|
|
73
91
|
|
|
74
92
|
def shell_open(ssh)
|
|
75
93
|
@ses = ssh.open_channel do |ch|
|
|
76
94
|
ch.on_data do |_ch, data|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@log.flush
|
|
80
|
-
end
|
|
95
|
+
@yaml_debug&.receive_data(data)
|
|
96
|
+
@text_debug&.receive_data(data)
|
|
81
97
|
@output << data
|
|
82
98
|
@output = @node.model.expects @output
|
|
83
99
|
end
|
|
@@ -98,8 +114,11 @@ module Oxidized
|
|
|
98
114
|
end
|
|
99
115
|
|
|
100
116
|
def cmd_shell(cmd, expect_re)
|
|
101
|
-
@output = ''
|
|
102
|
-
|
|
117
|
+
@output = String.new('')
|
|
118
|
+
|
|
119
|
+
@yaml_debug&.send_data(cmd + newline)
|
|
120
|
+
@text_debug&.send_data(cmd + newline)
|
|
121
|
+
@ses.send_data cmd + newline
|
|
103
122
|
@ses.process
|
|
104
123
|
expect expect_re if expect_re
|
|
105
124
|
@output
|
|
@@ -107,8 +126,8 @@ module Oxidized
|
|
|
107
126
|
|
|
108
127
|
def expect(*regexps)
|
|
109
128
|
regexps = [regexps].flatten
|
|
110
|
-
|
|
111
|
-
Timeout.timeout(
|
|
129
|
+
logger.debug "Expecting #{regexps.inspect} at #{node.name}"
|
|
130
|
+
Timeout.timeout(@node.timeout) do
|
|
112
131
|
@ssh.loop(0.1) do
|
|
113
132
|
sleep 0.1
|
|
114
133
|
match = regexps.find { |regexp| @output.match regexp }
|
|
@@ -118,48 +137,5 @@ module Oxidized
|
|
|
118
137
|
end
|
|
119
138
|
end
|
|
120
139
|
end
|
|
121
|
-
|
|
122
|
-
def make_ssh_opts
|
|
123
|
-
secure = Oxidized.config.input.ssh.secure?
|
|
124
|
-
ssh_opts = {
|
|
125
|
-
number_of_password_prompts: 0,
|
|
126
|
-
keepalive: vars(:ssh_no_keepalive) ? false : true,
|
|
127
|
-
verify_host_key: secure ? :always : :never,
|
|
128
|
-
append_all_supported_algorithms: true,
|
|
129
|
-
password: @node.auth[:password],
|
|
130
|
-
timeout: Oxidized.config.timeout,
|
|
131
|
-
port: (vars(:ssh_port) || 22).to_i,
|
|
132
|
-
forward_agent: false
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
auth_methods = vars(:auth_methods) || %w[none publickey password]
|
|
136
|
-
ssh_opts[:auth_methods] = auth_methods
|
|
137
|
-
Oxidized.logger.debug "AUTH METHODS::#{auth_methods}"
|
|
138
|
-
|
|
139
|
-
ssh_opts[:proxy] = make_ssh_proxy_command(vars(:ssh_proxy), vars(:ssh_proxy_port), secure) if vars(:ssh_proxy)
|
|
140
|
-
|
|
141
|
-
ssh_opts[:keys] = [vars(:ssh_keys)].flatten if vars(:ssh_keys)
|
|
142
|
-
ssh_opts[:kex] = vars(:ssh_kex).split(/,\s*/) if vars(:ssh_kex)
|
|
143
|
-
ssh_opts[:encryption] = vars(:ssh_encryption).split(/,\s*/) if vars(:ssh_encryption)
|
|
144
|
-
ssh_opts[:host_key] = vars(:ssh_host_key).split(/,\s*/) if vars(:ssh_host_key)
|
|
145
|
-
ssh_opts[:hmac] = vars(:ssh_hmac).split(/,\s*/) if vars(:ssh_hmac)
|
|
146
|
-
|
|
147
|
-
if Oxidized.config.input.debug?
|
|
148
|
-
ssh_opts[:logger] = Oxidized.logger
|
|
149
|
-
ssh_opts[:verbose] = Logger::DEBUG
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
ssh_opts
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def make_ssh_proxy_command(proxy_host, proxy_port, secure)
|
|
156
|
-
return nil unless !proxy_host.nil? && !proxy_host.empty?
|
|
157
|
-
|
|
158
|
-
proxy_command = "ssh "
|
|
159
|
-
proxy_command += "-o StrictHostKeyChecking=no " unless secure
|
|
160
|
-
proxy_command += "-p #{proxy_port} " if proxy_port
|
|
161
|
-
proxy_command += "#{proxy_host} -W [%h]:%p"
|
|
162
|
-
Net::SSH::Proxy::Command.new(proxy_command)
|
|
163
|
-
end
|
|
164
140
|
end
|
|
165
141
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module Oxidized
|
|
2
|
+
require 'net/ssh'
|
|
3
|
+
require 'net/ssh/proxy/command'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
class SSHBase < Input
|
|
7
|
+
RESCUE_FAIL = {
|
|
8
|
+
Net::SSH::Disconnect => :debug,
|
|
9
|
+
Net::SSH::ConnectionTimeout => :debug,
|
|
10
|
+
Net::SSH::AuthenticationFailed => :warn,
|
|
11
|
+
Net::SSH::HostKeyUnknown => :warn
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.rescue_fail
|
|
15
|
+
super.merge(RESCUE_FAIL)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def connect(node) # rubocop:disable Naming/PredicateMethod
|
|
19
|
+
@node = node
|
|
20
|
+
@node.model.cfg[config_name].each { |cb| instance_exec(&cb) }
|
|
21
|
+
logger.debug "Connecting to #{@node.name}"
|
|
22
|
+
@ssh = Net::SSH.start(@node.ip, @node.auth[:username], make_ssh_opts)
|
|
23
|
+
connected?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connected?
|
|
27
|
+
@ssh && (not @ssh.closed?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def make_ssh_opts
|
|
31
|
+
ssh_opts = {
|
|
32
|
+
number_of_password_prompts: 0,
|
|
33
|
+
keepalive: vars(:ssh_no_keepalive) ? false : true,
|
|
34
|
+
verify_host_key: must_secure? ? :always : :never,
|
|
35
|
+
append_all_supported_algorithms: true,
|
|
36
|
+
password: @node.auth[:password],
|
|
37
|
+
timeout: @node.timeout,
|
|
38
|
+
port: (vars(:ssh_port) || 22).to_i,
|
|
39
|
+
forward_agent: false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
auth_methods = vars(:auth_methods) || %w[none publickey password]
|
|
43
|
+
ssh_opts[:auth_methods] = auth_methods
|
|
44
|
+
logger.debug "AUTH METHODS::#{auth_methods}"
|
|
45
|
+
|
|
46
|
+
if vars(:ssh_proxy)
|
|
47
|
+
ssh_opts[:proxy] = make_ssh_proxy_command(
|
|
48
|
+
vars(:ssh_proxy), vars(:ssh_proxy_port), must_secure?
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
ssh_opts[:keys] = [vars(:ssh_keys)].flatten if vars(:ssh_keys)
|
|
52
|
+
ssh_opts[:kex] = vars(:ssh_kex).split(/,\s*/) if vars(:ssh_kex)
|
|
53
|
+
ssh_opts[:encryption] = vars(:ssh_encryption).split(/,\s*/) if vars(:ssh_encryption)
|
|
54
|
+
ssh_opts[:host_key] = vars(:ssh_host_key).split(/,\s*/) if vars(:ssh_host_key)
|
|
55
|
+
ssh_opts[:hmac] = vars(:ssh_hmac).split(/,\s*/) if vars(:ssh_hmac)
|
|
56
|
+
|
|
57
|
+
# Use our logger for Net::SSH
|
|
58
|
+
ssh_logger = SemanticLogger[Net::SSH]
|
|
59
|
+
config_debug = Oxidized.config.input.debug
|
|
60
|
+
if config_debug == true ||
|
|
61
|
+
(config_debug.is_a?(String) && config_debug.downcase.include?('library'))
|
|
62
|
+
ssh_logger.level = :debug
|
|
63
|
+
else
|
|
64
|
+
ssh_logger.level = :fatal
|
|
65
|
+
end
|
|
66
|
+
ssh_opts[:logger] = ssh_logger
|
|
67
|
+
|
|
68
|
+
ssh_opts
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def must_secure?
|
|
72
|
+
Oxidized.config.input[config_name].secure? == true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def make_ssh_proxy_command(proxy_host, proxy_port, secure)
|
|
76
|
+
return nil unless !proxy_host.nil? && !proxy_host.empty?
|
|
77
|
+
|
|
78
|
+
proxy_command = "ssh "
|
|
79
|
+
proxy_command += "-o StrictHostKeyChecking=no " unless secure
|
|
80
|
+
proxy_command += "-p #{proxy_port} " if proxy_port
|
|
81
|
+
proxy_command += "#{proxy_host} -W [%h]:%p"
|
|
82
|
+
Net::SSH::Proxy::Command.new(proxy_command)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def disconnect
|
|
86
|
+
disconnect_cli
|
|
87
|
+
Timeout.timeout(@node.timeout) do
|
|
88
|
+
@ssh.close
|
|
89
|
+
end
|
|
90
|
+
rescue Errno::ECONNRESET, Net::SSH::Disconnect, IOError => e
|
|
91
|
+
logger.debug 'The other side closed the connection while ' \
|
|
92
|
+
"disconnecting, raising #{e.class} with #{e.message}"
|
|
93
|
+
rescue Timeout::Error
|
|
94
|
+
logger.debug "#{@node.name} timed out while disconnecting"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Methods to implement in subclasses
|
|
98
|
+
def cmd(**_args)
|
|
99
|
+
raise NotImplementedError, "Subclasses must implement cmd"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
require_relative 'debugtext'
|
|
2
|
+
|
|
1
3
|
module Oxidized
|
|
2
4
|
require 'net/telnet'
|
|
3
|
-
require 'oxidized/input/cli'
|
|
4
5
|
class Telnet < Input
|
|
5
|
-
RescueFail = {}.freeze
|
|
6
|
-
include Input::CLI
|
|
7
6
|
attr_reader :telnet
|
|
8
7
|
|
|
9
|
-
def connect(node)
|
|
8
|
+
def connect(node) # rubocop:disable Naming/PredicateMethod
|
|
10
9
|
@node = node
|
|
11
|
-
@timeout =
|
|
10
|
+
@timeout = @node.timeout
|
|
12
11
|
@node.model.cfg['telnet'].each { |cb| instance_exec(&cb) }
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
@text_debug = DebugText.new(Oxidized.config.input.debug, @node, config_name)
|
|
14
|
+
|
|
14
15
|
port = vars(:telnet_port) || 23
|
|
15
16
|
|
|
16
17
|
telnet_opts = {
|
|
@@ -18,7 +19,7 @@ module Oxidized
|
|
|
18
19
|
'Port' => port.to_i,
|
|
19
20
|
'Timeout' => @timeout,
|
|
20
21
|
'Model' => @node.model,
|
|
21
|
-
'Log' => @
|
|
22
|
+
'Log' => @text_debug
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
@telnet = Net::Telnet.new telnet_opts
|
|
@@ -35,18 +36,20 @@ module Oxidized
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def cmd(cmd_str, expect = @node.prompt)
|
|
38
|
-
|
|
39
|
+
logger.debug "Telnet: #{cmd_str} @#{@node.name}"
|
|
39
40
|
return send(cmd_str + "\r\n") unless expect
|
|
40
41
|
|
|
41
42
|
# create a string to be passed to oxidized_expect and modified _there_
|
|
42
43
|
# default to a single space so it shouldn't be coerced to nil by any models.
|
|
43
44
|
out = String(' ')
|
|
45
|
+
@text_debug&.send_data(cmd_str)
|
|
44
46
|
@telnet.puts(cmd_str)
|
|
45
47
|
@telnet.oxidized_expect(timeout: @timeout, expect: expect, out: out)
|
|
46
48
|
out
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def send(data)
|
|
52
|
+
@text_debug&.send_data(data)
|
|
50
53
|
@telnet.write data
|
|
51
54
|
end
|
|
52
55
|
|
|
@@ -63,10 +66,11 @@ module Oxidized
|
|
|
63
66
|
def disconnect
|
|
64
67
|
disconnect_cli
|
|
65
68
|
@telnet.close
|
|
66
|
-
rescue Errno::ECONNRESET
|
|
69
|
+
rescue Errno::ECONNRESET, IOError
|
|
70
|
+
# This exception is intented and therefore not handled here
|
|
67
71
|
ensure
|
|
68
|
-
@
|
|
69
|
-
(@telnet.close rescue true) unless @telnet.sock.closed?
|
|
72
|
+
@text_debug&.close
|
|
73
|
+
(@telnet.close rescue true) unless @telnet.sock.closed? # rubocop:disable Style/RedundantParentheses
|
|
70
74
|
end
|
|
71
75
|
end
|
|
72
76
|
end
|
|
@@ -77,12 +81,12 @@ module Net
|
|
|
77
81
|
## FIXME: we also need output (not sure I'm going to support this)
|
|
78
82
|
attr_reader :output
|
|
79
83
|
|
|
80
|
-
def oxidized_expect(options)
|
|
81
|
-
model
|
|
82
|
-
|
|
84
|
+
def oxidized_expect(options)
|
|
85
|
+
model = @options["Model"]
|
|
86
|
+
text_debug = @options["Log"]
|
|
83
87
|
|
|
84
88
|
expects = [options[:expect]].flatten
|
|
85
|
-
time_out = options[:timeout] || @options["Timeout"]
|
|
89
|
+
time_out = options[:timeout] || @options["Timeout"]
|
|
86
90
|
|
|
87
91
|
Timeout.timeout(time_out) do
|
|
88
92
|
line = ""
|
|
@@ -105,10 +109,7 @@ module Net
|
|
|
105
109
|
buf = preprocess(c)
|
|
106
110
|
rest = ''
|
|
107
111
|
end
|
|
108
|
-
|
|
109
|
-
@log.print buf
|
|
110
|
-
@log.flush
|
|
111
|
-
end
|
|
112
|
+
text_debug&.receive_data buf
|
|
112
113
|
line += buf
|
|
113
114
|
line = model.expects line
|
|
114
115
|
# match is a regexp object. we need to return that for logins to work.
|
data/lib/oxidized/input/tftp.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
module Oxidized
|
|
2
2
|
require 'stringio'
|
|
3
|
-
require_relative 'cli'
|
|
4
|
-
|
|
5
3
|
begin
|
|
6
4
|
require 'net/tftp'
|
|
7
5
|
rescue LoadError
|
|
@@ -9,19 +7,24 @@ module Oxidized
|
|
|
9
7
|
end
|
|
10
8
|
|
|
11
9
|
class TFTP < Input
|
|
12
|
-
|
|
10
|
+
RESCUE_FAIL = {
|
|
11
|
+
Net::TFTPTimeout => :warn
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.rescue_fail
|
|
15
|
+
super.merge(RESCUE_FAIL)
|
|
16
|
+
end
|
|
13
17
|
|
|
14
18
|
# TFTP utilizes UDP, there is not a connection. We simply specify an IP and send/receive data.
|
|
15
19
|
def connect(node)
|
|
16
20
|
@node = node
|
|
17
21
|
|
|
18
22
|
@node.model.cfg['tftp'].each { |cb| instance_exec(&cb) }
|
|
19
|
-
@log = File.open(Oxidized::Config::Log + "/#{@node.ip}-tftp", 'w') if Oxidized.config.input.debug?
|
|
20
23
|
@tftp = Net::TFTP.new @node.ip
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def cmd(file)
|
|
24
|
-
|
|
27
|
+
logger.debug "TFTP: #{file} @ #{@node.name}"
|
|
25
28
|
config = StringIO.new
|
|
26
29
|
@tftp.getbinary file, config
|
|
27
30
|
config.rewind
|
|
@@ -32,9 +35,6 @@ module Oxidized
|
|
|
32
35
|
|
|
33
36
|
def disconnect
|
|
34
37
|
# TFTP uses UDP, there is no connection to close
|
|
35
|
-
true
|
|
36
|
-
ensure
|
|
37
|
-
@log.close if Oxidized.config.input.debug?
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
data/lib/oxidized/job.rb
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
module Oxidized
|
|
2
2
|
class Job < Thread
|
|
3
|
+
include SemanticLogger::Loggable
|
|
4
|
+
|
|
3
5
|
attr_reader :start, :end, :status, :time, :node, :config
|
|
4
6
|
|
|
5
7
|
def initialize(node)
|
|
6
|
-
@node
|
|
7
|
-
@start
|
|
8
|
+
@node = node
|
|
9
|
+
@start = Time.now.utc
|
|
10
|
+
self.name = "Job '#{@node.name}'"
|
|
8
11
|
super do
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
logger.debug "Starting fetching process for #{@node.name}"
|
|
13
|
+
begin
|
|
14
|
+
Timeout.timeout(Oxidized.config.timelimit) do
|
|
15
|
+
@status, @config = @node.run
|
|
16
|
+
end
|
|
17
|
+
logger.debug "Config fetched for #{@node.name}"
|
|
18
|
+
rescue Timeout::Error
|
|
19
|
+
logger.warn "Job timelimit reached for #{@node.name}"
|
|
20
|
+
@status = :timelimit
|
|
21
|
+
ensure
|
|
22
|
+
@end = Time.now.utc
|
|
23
|
+
@time = @end - @start
|
|
24
|
+
end
|
|
14
25
|
end
|
|
15
26
|
end
|
|
16
27
|
end
|