netutils 0.1.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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/README.md +36 -0
- data/Rakefile +6 -0
- data/bin/acl +109 -0
- data/bin/alaxala-deploy +271 -0
- data/bin/config-diff-check +111 -0
- data/bin/config-gets +64 -0
- data/bin/console +14 -0
- data/bin/host-locate-on-demand +102 -0
- data/bin/ipaddr-resolv +74 -0
- data/bin/ipaddr-resolv.sh +97 -0
- data/bin/mac-drop +84 -0
- data/bin/mac-nodrop +45 -0
- data/bin/port-shutdown +78 -0
- data/bin/setup +8 -0
- data/lib/netutils.rb +118 -0
- data/lib/netutils/arp.rb +28 -0
- data/lib/netutils/cli.rb +702 -0
- data/lib/netutils/cli/alaxala.rb +121 -0
- data/lib/netutils/cli/alaxala/interface.rb +137 -0
- data/lib/netutils/cli/alaxala/lldp.rb +166 -0
- data/lib/netutils/cli/alaxala/macfib.rb +62 -0
- data/lib/netutils/cli/alaxala/showarp.rb +51 -0
- data/lib/netutils/cli/alaxala/showroute.rb +86 -0
- data/lib/netutils/cli/alaxala/showvrf.rb +46 -0
- data/lib/netutils/cli/aruba.rb +15 -0
- data/lib/netutils/cli/cisco.rb +45 -0
- data/lib/netutils/cli/cisco/cdp.rb +117 -0
- data/lib/netutils/cli/cisco/ifsummary.rb +32 -0
- data/lib/netutils/cli/cisco/interface.rb +67 -0
- data/lib/netutils/cli/cisco/macfib.rb +38 -0
- data/lib/netutils/cli/cisco/showarp.rb +27 -0
- data/lib/netutils/cli/cisco/showinterface.rb +27 -0
- data/lib/netutils/cli/cisco/showroute.rb +73 -0
- data/lib/netutils/cli/cisco/showvrf.rb +45 -0
- data/lib/netutils/cli/nec.rb +20 -0
- data/lib/netutils/cli/nec/lldp.rb +16 -0
- data/lib/netutils/cli/paloalto.rb +21 -0
- data/lib/netutils/fsm.rb +43 -0
- data/lib/netutils/macaddr.rb +51 -0
- data/lib/netutils/oncequeue.rb +78 -0
- data/lib/netutils/parser.rb +30 -0
- data/lib/netutils/rib.rb +80 -0
- data/lib/netutils/switch.rb +402 -0
- data/lib/netutils/tunnel.rb +8 -0
- data/lib/netutils/version.rb +3 -0
- data/lib/netutils/vrf.rb +42 -0
- data/log/.gitignore +1 -0
- data/netutils.gemspec +33 -0
- metadata +195 -0
data/bin/mac-drop
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.unshift File.join(File.expand_path(File.dirname(__FILE__)).untaint, '/..')
|
3
|
+
|
4
|
+
require 'netutils'
|
5
|
+
|
6
|
+
################################################################################
|
7
|
+
def usage
|
8
|
+
progname = File.basename($0)
|
9
|
+
STDERR.print "\
|
10
|
+
Usage: ruby #{progname} [-h] [-d] <IP address>
|
11
|
+
Options:
|
12
|
+
-d: dry run (do not shut down a port, just locate a host only)
|
13
|
+
-h: output this help message.
|
14
|
+
Example:
|
15
|
+
#{progname} 192.168.0.1
|
16
|
+
"
|
17
|
+
exit
|
18
|
+
end
|
19
|
+
|
20
|
+
case ARGV.size
|
21
|
+
when 1
|
22
|
+
usage if ARGV[0] !~ /^[0-9\.]+$/
|
23
|
+
when 2
|
24
|
+
usage if ARGV.shift != '-d'
|
25
|
+
dry = true
|
26
|
+
else
|
27
|
+
usage
|
28
|
+
end
|
29
|
+
usage if ARGV.size != 1
|
30
|
+
|
31
|
+
ia = ARGV[0]
|
32
|
+
|
33
|
+
swname = swia = xia = ma = 'unknown'
|
34
|
+
begin
|
35
|
+
log "locate directly connected router for #{ia}... "
|
36
|
+
sw, xia = router_locate(ia)
|
37
|
+
swname = sw.name
|
38
|
+
swia = sw.ia
|
39
|
+
if ia == xia
|
40
|
+
log "\t\"#{sw.name}\" (#{sw.ia})"
|
41
|
+
else
|
42
|
+
log "\t\"department router\" (#{xia})"
|
43
|
+
end
|
44
|
+
|
45
|
+
log_without_newline "resolving MAC address for #{xia}... "
|
46
|
+
ma, vrf, interface = sw.macaddr_resolve(xia)
|
47
|
+
log "found"
|
48
|
+
log "\t#{xia} #{ma} on VRF \"#{vrf.name}\" #{interface}"
|
49
|
+
|
50
|
+
vlan = interface_name_vlan_id(interface)
|
51
|
+
log_without_newline "setting to \"#{sw.name}\" (#{sw.ia})\n"
|
52
|
+
|
53
|
+
if dry
|
54
|
+
log 'skip (due to -d, dry run, option)'
|
55
|
+
exit 0
|
56
|
+
else
|
57
|
+
sw.configure
|
58
|
+
|
59
|
+
for vid in vlans_by_switch_name(swname, vlan) do
|
60
|
+
cmd = "mac-address-table static #{ma} vlan #{vid} drop"
|
61
|
+
log_without_newline "\t#{cmd}"
|
62
|
+
sw.cmd(cmd)
|
63
|
+
end
|
64
|
+
|
65
|
+
sw.unconfigure
|
66
|
+
log 'done'
|
67
|
+
|
68
|
+
s = File.join(File.dirname(__FILE__), 'mac-no-drop.rb')
|
69
|
+
s = File.expand_path(s)
|
70
|
+
log "please run below command on recovery:\n"
|
71
|
+
log "\t#{s} #{sw.name} #{sw.ia} #{ma} #{vlan}\n"
|
72
|
+
end
|
73
|
+
exitcode = 0
|
74
|
+
rescue => e
|
75
|
+
r = ' FAILED'
|
76
|
+
log "\n#{r}: #{e.to_s}"
|
77
|
+
log "ERROR: Cannot MAC DROP #{xia} (#{ma}) on #{swname} #{swia} for " +
|
78
|
+
"#{ia}"
|
79
|
+
exitcode = 1
|
80
|
+
end
|
81
|
+
|
82
|
+
mail "MAC DROP#{r}: #{swname} (#{swia}) for #{xia} (#{ma})", log_buffer
|
83
|
+
|
84
|
+
exit exitcode
|
data/bin/mac-nodrop
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.unshift File.join(File.expand_path(File.dirname(__FILE__)).untaint, '/..')
|
3
|
+
|
4
|
+
require 'netutils'
|
5
|
+
|
6
|
+
################################################################################
|
7
|
+
def usage
|
8
|
+
progname = File.basename($0)
|
9
|
+
STDERR.print "\
|
10
|
+
Usage: #{progname} <Switch name> <IP address> <MAC address> <existing VLAN>
|
11
|
+
Example:
|
12
|
+
#{progname} hoge-cisco-01 192.168.0.1 de:ad:be:ef:de:ad
|
13
|
+
"
|
14
|
+
exit
|
15
|
+
end
|
16
|
+
|
17
|
+
def mac_nodrop(name, ia, mac, vlan)
|
18
|
+
sw = Switch.new(name, ia)
|
19
|
+
sw.login
|
20
|
+
sw.configure
|
21
|
+
for vid in vlans_by_switch_name(name, vlan) do
|
22
|
+
cmd = "no mac-address-table static #{mac} vlan #{vid}"
|
23
|
+
log_without_newline "\t#{cmd}\n"
|
24
|
+
sw.cmd(cmd)
|
25
|
+
end
|
26
|
+
sw.unconfigure
|
27
|
+
end
|
28
|
+
|
29
|
+
usage if ARGV.size != 4
|
30
|
+
name = ARGV[0]
|
31
|
+
ia = ARGV[1]
|
32
|
+
mac = ARGV[2]
|
33
|
+
vlan = ARGV[3]
|
34
|
+
|
35
|
+
begin
|
36
|
+
log_without_newline "setting to \"#{name}\"(#{ia})\n\n"
|
37
|
+
mac_nodrop(name, ia, mac, vlan)
|
38
|
+
log "\nRe-enable #{mac} on #{name} #{ia}"
|
39
|
+
rescue => e
|
40
|
+
r = ' FAILED'
|
41
|
+
log e.to_s
|
42
|
+
log "\ncannot allow traffic #{mac} on #{name} #{ia}"
|
43
|
+
end
|
44
|
+
|
45
|
+
mail "MAC NODROP#{r}: #{name} #{ia} #{mac}", log_buffer
|
data/bin/port-shutdown
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.unshift File.join(File.expand_path(File.dirname(__FILE__)).untaint, '/..')
|
3
|
+
|
4
|
+
require 'netutils'
|
5
|
+
|
6
|
+
################################################################################
|
7
|
+
def usage
|
8
|
+
STDERR.print "\
|
9
|
+
Usage:
|
10
|
+
#{$progname} [-d] (up|down) <Switch IP address> <port>
|
11
|
+
Example:
|
12
|
+
#{$progname} up 192.168.0.1 GigabitEthernet 1/2/3
|
13
|
+
#{$progname} down 192.168.0.2 GigabitEthernet 1/2/3
|
14
|
+
"
|
15
|
+
exit 1
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
if ARGV[0] === '-d'
|
20
|
+
ARGV.shift
|
21
|
+
dry = true
|
22
|
+
end
|
23
|
+
usage if ARGV.size < 3
|
24
|
+
|
25
|
+
#
|
26
|
+
cmd = ARGV.shift
|
27
|
+
ia = ARGV.shift
|
28
|
+
port = ARGV.shift
|
29
|
+
while ARGV.size > 0
|
30
|
+
port += ' ' + ARGV.shift
|
31
|
+
end
|
32
|
+
|
33
|
+
#
|
34
|
+
case cmd
|
35
|
+
when 'up'
|
36
|
+
msg = 'bringing up'
|
37
|
+
when 'down'
|
38
|
+
msg = 'shutting down'
|
39
|
+
else
|
40
|
+
usage
|
41
|
+
end
|
42
|
+
|
43
|
+
name = 'unknown'
|
44
|
+
begin
|
45
|
+
log_without_newline "Connecting to #{ia}... "
|
46
|
+
sw = Switch.new(nil, Switch::Type::ROUTER, ia)
|
47
|
+
sw.login
|
48
|
+
log 'done'
|
49
|
+
name = sw.name
|
50
|
+
|
51
|
+
port = sw.interface_name(port)
|
52
|
+
interface_sanity_check(name, port)
|
53
|
+
log_without_newline "#{msg.capitalize} #{port} on #{name} (#{ia})... "
|
54
|
+
|
55
|
+
if dry
|
56
|
+
log 'skip (due to -d, dry run, option)'
|
57
|
+
exit 0
|
58
|
+
elsif cmd === 'down'
|
59
|
+
sw.interface_shutdown(port)
|
60
|
+
s = File.expand_path(__FILE__)
|
61
|
+
log 'done'
|
62
|
+
log "please run below command on recovery:\n"
|
63
|
+
log "\t#{s} up #{ia} \'#{port}\'\n"
|
64
|
+
else
|
65
|
+
sw.interface_noshutdown(port)
|
66
|
+
log 'done'
|
67
|
+
end
|
68
|
+
exitcode = 0
|
69
|
+
rescue => e
|
70
|
+
r = ' FAILED'
|
71
|
+
log "\n#{r}: #{e.to_s}"
|
72
|
+
log "ERROR: Cannot #{msg} #{port} on #{name} #{ia}"
|
73
|
+
exitcode = 1
|
74
|
+
end
|
75
|
+
|
76
|
+
mail "Port #{cmd.upcase}#{r}: #{name} #{ia} #{port}", log_buffer
|
77
|
+
|
78
|
+
exit exitcode
|
data/bin/setup
ADDED
data/lib/netutils.rb
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
require "netutils/version"
|
2
|
+
require 'mail'
|
3
|
+
require 'netutils/switch'
|
4
|
+
require 'config/config'
|
5
|
+
require 'config/passwd'
|
6
|
+
|
7
|
+
module Netutils
|
8
|
+
|
9
|
+
#
|
10
|
+
$progname = File.basename($0)
|
11
|
+
|
12
|
+
#
|
13
|
+
ACL_MAX_SEQ = 4294967294
|
14
|
+
#
|
15
|
+
$log = ''
|
16
|
+
|
17
|
+
def mail(s, b)
|
18
|
+
m = Mail.new do
|
19
|
+
delivery_method :smtp, address: MAILSERVER
|
20
|
+
from MAILFROM
|
21
|
+
to MAILTO
|
22
|
+
subject s
|
23
|
+
body b
|
24
|
+
end
|
25
|
+
m.charset = 'ascii'
|
26
|
+
m.deliver!
|
27
|
+
end
|
28
|
+
|
29
|
+
def valid_ip_address?(s)
|
30
|
+
return false if s !~ /^(?:[0-9]+\.){3}[0-9]+$/
|
31
|
+
results = s.split('.').collect { |i| i.to_i.between?(0, 255) }
|
32
|
+
return results.count(true) === 4
|
33
|
+
end
|
34
|
+
|
35
|
+
def interface_sanity_check(host, port)
|
36
|
+
# XXX: need more checks to detect backbone links.
|
37
|
+
if port !~ /^[gf]/i
|
38
|
+
raise "Suppress shutdown #{port}, which may be backbone link."
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def log_without_newline(m)
|
43
|
+
print m
|
44
|
+
$log += m
|
45
|
+
end
|
46
|
+
|
47
|
+
def log(m)
|
48
|
+
puts m
|
49
|
+
$log += m + "\n"
|
50
|
+
end
|
51
|
+
|
52
|
+
def log_buffer
|
53
|
+
return $log
|
54
|
+
end
|
55
|
+
|
56
|
+
def vlans_by_switch_name(swname, vlan)
|
57
|
+
vlan = vlan.to_i
|
58
|
+
vlans = []
|
59
|
+
vlans = VLANS[swname].dup if VLANS.has_key?(swname)
|
60
|
+
vlans.unshift(vlan) if vlan && ! vlans.include?(vlan)
|
61
|
+
return vlans
|
62
|
+
end
|
63
|
+
|
64
|
+
def interface_name_vlan_id(name)
|
65
|
+
return $1 if name =~ /^vlan([0-9]+)$/i
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
|
69
|
+
def static_neighbor_resolve(name, ifname)
|
70
|
+
key = "#{name}_#{ifname}"
|
71
|
+
n = STATIC_NEIGHBOR[key]
|
72
|
+
return nil if n.nil?
|
73
|
+
Switch.get(n[:name], Switch::Type::ROUTER, nil, nil, nil, n[:ia])
|
74
|
+
end
|
75
|
+
|
76
|
+
def tunnel_nexthop_resolve(sw, rt)
|
77
|
+
return rt.nh if rt.nh
|
78
|
+
c = CDP.new(nil)
|
79
|
+
c.parse(sw.cli.cmd("show cdp neighbors #{rt.interface} detail"))
|
80
|
+
c.ias[0]
|
81
|
+
end
|
82
|
+
|
83
|
+
def router_locate(ia)
|
84
|
+
root = SWITCHES[0]
|
85
|
+
sw = Switch.new(root[0], Switch::Type::ROUTER, root[1])
|
86
|
+
sw.login
|
87
|
+
|
88
|
+
while true
|
89
|
+
rts = sw.route_gets(ia)
|
90
|
+
raise "No route found for #{ia}" if ! rts
|
91
|
+
bestrt = nil
|
92
|
+
rts.each do |rt|
|
93
|
+
case rt.proto
|
94
|
+
when 'connected'
|
95
|
+
return sw, ia
|
96
|
+
when 'static', 'rip', 'ospf', 'bgp'
|
97
|
+
# just in case for redistributed routes.
|
98
|
+
next if rt.nh === nil && ! rt.tunnel?
|
99
|
+
bestrt = rt.compare(bestrt)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
raise "No valid route found for #{ia}" if ! bestrt
|
103
|
+
|
104
|
+
if OTHER_NEXTHOPS.include?(bestrt.nh)
|
105
|
+
return sw, bestrt.nh
|
106
|
+
end
|
107
|
+
|
108
|
+
if bestrt.tunnel?
|
109
|
+
nh = tunnel_nexthop_resolve(sw, bestrt)
|
110
|
+
else
|
111
|
+
nh = bestrt.nh
|
112
|
+
end
|
113
|
+
sw = Switch.new(nil, Switch::Type::ROUTER, nh)
|
114
|
+
sw.login
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
data/lib/netutils/arp.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'netutils/macaddr'
|
2
|
+
|
3
|
+
class ARPTable
|
4
|
+
class ARP
|
5
|
+
attr_reader :ia, :ma, :interface, :static
|
6
|
+
|
7
|
+
def initialize(ia, ma, interface, static)
|
8
|
+
@ia = ia
|
9
|
+
@ma = MACAddr.new(ma)
|
10
|
+
@interface = interface
|
11
|
+
@static = static
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :arps
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@arps = {}
|
19
|
+
end
|
20
|
+
|
21
|
+
def add(ia, ma, interface, static)
|
22
|
+
@arps[ia] = ARP.new(ia, ma, interface, static)
|
23
|
+
end
|
24
|
+
|
25
|
+
def [](ia)
|
26
|
+
return @arps[ia]
|
27
|
+
end
|
28
|
+
end
|
data/lib/netutils/cli.rb
ADDED
@@ -0,0 +1,702 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'net/ssh/telnet'
|
3
|
+
require 'net/telnet'
|
4
|
+
|
5
|
+
path = File.expand_path(File.dirname(__FILE__)).untaint
|
6
|
+
Dir.glob("#{path}/cli/*") do |path|
|
7
|
+
file = File.basename(path, '.*')
|
8
|
+
require "netutils/cli/#{file}"
|
9
|
+
end
|
10
|
+
|
11
|
+
class CLI
|
12
|
+
module Maker
|
13
|
+
CISCO = 0
|
14
|
+
ALAXALA = 1
|
15
|
+
PALOALTO = 2
|
16
|
+
ARUBA = 3
|
17
|
+
NEC = 4
|
18
|
+
#
|
19
|
+
# XXX: Cisco WLC should come after Paloalto and NEC for a maker
|
20
|
+
# detection by a command to disable a CLI terminal pager.
|
21
|
+
# Since Paloalto always interprets a command like:
|
22
|
+
#
|
23
|
+
# ``config hogehoge''
|
24
|
+
#
|
25
|
+
# as:
|
26
|
+
#
|
27
|
+
# ``configure.''
|
28
|
+
#
|
29
|
+
# WLC pager command is then accepted by Paloalto and a
|
30
|
+
# maker detection fails if Cisco WLC preceds Paloalto.
|
31
|
+
#
|
32
|
+
WLC = 5 # XXX: Cisco WLC, should be product...
|
33
|
+
UNKNOWN = 6
|
34
|
+
MIN = CISCO
|
35
|
+
MAX = ARUBA
|
36
|
+
end
|
37
|
+
|
38
|
+
FIRST_PROMPT_RE = /^.*\n+\(?!?([^\r\n\(\)\s>#]+)\)? ?([>#]) ?\r?\n?.*$/m
|
39
|
+
|
40
|
+
TIMEOUT = 30
|
41
|
+
LOGIN_TIMEOUT = 3
|
42
|
+
|
43
|
+
attr_reader :name, :ia, :maker, :product, :users, :prompt,
|
44
|
+
:passwds, :enable_passwds
|
45
|
+
|
46
|
+
def initialize(name, ia, types = CLI_SESSION_TYPES, maker = Maker::UNKNOWN)
|
47
|
+
@ia = ia
|
48
|
+
@users = USERS.dup
|
49
|
+
@passwds = PASSWORDS.dup
|
50
|
+
@enable_passwds = ENABLES.dup
|
51
|
+
@enabled = false
|
52
|
+
@type = nil
|
53
|
+
@types = types
|
54
|
+
@session = nil
|
55
|
+
@maker = maker
|
56
|
+
@product = nil
|
57
|
+
@cr = ''
|
58
|
+
@name_supplied = name
|
59
|
+
name_update(nil)
|
60
|
+
@userprompt = [
|
61
|
+
'login:', # Alaxala
|
62
|
+
'[Uu]sername:', # Cisco
|
63
|
+
'User:' # Cisco WiSM/WLC
|
64
|
+
]
|
65
|
+
# Cisco has trailing space, ``Password: '', but Alaxala not
|
66
|
+
@passwdprompt = 'Password:'
|
67
|
+
@telnetopt = Hash.new
|
68
|
+
@telnetopt['Timeout' ] = TIMEOUT
|
69
|
+
if defined?(LOGDIR)
|
70
|
+
path = File.dirname(__FILE__)
|
71
|
+
path += "/../"
|
72
|
+
path += "/#{LOGDIR}/#{ia}.log"
|
73
|
+
path = File.expand_path(path).untaint
|
74
|
+
@telnetopt['Output_log'] = path
|
75
|
+
end
|
76
|
+
#@telnetopt['Dump_log'] = '/dev/stdout'
|
77
|
+
end
|
78
|
+
|
79
|
+
def name_update(name)
|
80
|
+
if name =~ /^([^@]*)@(.*)$/
|
81
|
+
@user = $1
|
82
|
+
name = $2
|
83
|
+
end
|
84
|
+
raise "Invalid host name given: \"#{name}\"" if name =~ /[>#\n]/
|
85
|
+
if @name === nil && name != nil &&
|
86
|
+
@name_supplied != nil && name != @name_supplied
|
87
|
+
raise(ArgumentError, "host name mismatch: " +
|
88
|
+
"\"#{@name_supplied}\" is supplied " +
|
89
|
+
"but actually \"#{name}\"")
|
90
|
+
end
|
91
|
+
@name = name
|
92
|
+
case @maker
|
93
|
+
when Maker::CISCO
|
94
|
+
prefix = suffix = trailer = ''
|
95
|
+
when Maker::ALAXALA
|
96
|
+
prefix = '!?'
|
97
|
+
suffix = ''
|
98
|
+
trailer = "\0? "
|
99
|
+
when Maker::PALOALTO
|
100
|
+
raise('Invalid host name for Paloalto') if ! @user
|
101
|
+
prefix = "#{@user}@"
|
102
|
+
suffix = ''
|
103
|
+
trailer = ' '
|
104
|
+
when Maker::ARUBA
|
105
|
+
prefix = '\('
|
106
|
+
suffix = '\) '
|
107
|
+
trailer = ''
|
108
|
+
else
|
109
|
+
prefix = '\(?'
|
110
|
+
if @user
|
111
|
+
prefix = "#{prefix}#{@user}@"
|
112
|
+
else
|
113
|
+
prefix = "#{prefix}.*"
|
114
|
+
end
|
115
|
+
suffix = '\)? ?'
|
116
|
+
trailer = "\0? ?"
|
117
|
+
end
|
118
|
+
name = '[^\r\n\(\)]+' if ! name
|
119
|
+
@normalprompt = "#{prefix}#{name}#{suffix}>#{trailer}"
|
120
|
+
@enableprompt = "#{prefix}#{name}#{suffix}##{trailer}"
|
121
|
+
@configprompt = "#{prefix}#{name}#{suffix}\\(config[^ ]*\\)##{trailer}"
|
122
|
+
if @enabled
|
123
|
+
@prompt = @enableprompt
|
124
|
+
else
|
125
|
+
# XXX configuring node... dirty....
|
126
|
+
@prompt = @normalprompt
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
#
|
131
|
+
# handle a carriage return (\r) and (\E[K) that intend to
|
132
|
+
# remove all characters in a current line and remove trailing
|
133
|
+
# characters, respectively.
|
134
|
+
#
|
135
|
+
def handle_control_characters(input)
|
136
|
+
out = ''
|
137
|
+
l = ''
|
138
|
+
pos = 0
|
139
|
+
escape = nil
|
140
|
+
input.chars do |c|
|
141
|
+
if escape
|
142
|
+
escape += c
|
143
|
+
case escape
|
144
|
+
when /\e[0-9]/, /\e\[[0-9]+;[0-9]+H/, "\e[r"
|
145
|
+
escape = nil
|
146
|
+
when "\e[K"
|
147
|
+
l.slice!(pos, l.length - pos)
|
148
|
+
escape = nil
|
149
|
+
when "\x1b[6n"
|
150
|
+
# XXX: Alaxala edge switch hack...
|
151
|
+
escape = nil
|
152
|
+
end
|
153
|
+
next
|
154
|
+
end
|
155
|
+
case c
|
156
|
+
when "\0"
|
157
|
+
# XXX: i do not know but Alaxala sends this...
|
158
|
+
when "\b"
|
159
|
+
l.chop!
|
160
|
+
pos -= 1
|
161
|
+
when "\e"
|
162
|
+
escape = c
|
163
|
+
else
|
164
|
+
case c
|
165
|
+
when "\r"
|
166
|
+
pos = 0
|
167
|
+
when "\n"
|
168
|
+
#
|
169
|
+
# in case of "\r\n", do not override
|
170
|
+
# the character.
|
171
|
+
#
|
172
|
+
pos = l.length if pos === 0
|
173
|
+
l[pos] = c
|
174
|
+
out += l
|
175
|
+
l = ''
|
176
|
+
pos = 0
|
177
|
+
else
|
178
|
+
l[pos] = c
|
179
|
+
pos += 1
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
out += l if ! l.empty?
|
184
|
+
out.delete!("^\u{0001}-\u{007f}")
|
185
|
+
return out
|
186
|
+
end
|
187
|
+
private :handle_control_characters
|
188
|
+
|
189
|
+
def error?(r)
|
190
|
+
case r
|
191
|
+
when /%[^\n]+\n+\Z/m # XXX: this is not accurate
|
192
|
+
maker = Maker::CISCO
|
193
|
+
when /Error: Bad command\. \n\Z/m,
|
194
|
+
/[^\s]+: not found\n\Z/m,
|
195
|
+
/% The command or parameter at the ^ marker is invalid\./m,
|
196
|
+
/Error: Invalid parameter\./m
|
197
|
+
maker = Maker::ALAXALA
|
198
|
+
when /Invalid syntax\..*\Z/m, /Unknown command: .*\Z/m
|
199
|
+
maker = Maker::PALOALTO
|
200
|
+
when /^ *\^ *\n% [^\n]+ error/m,
|
201
|
+
/^ *\^ *\n% Invalid input detected at/m # same as Cisco
|
202
|
+
maker = Maker::ARUBA
|
203
|
+
else
|
204
|
+
return false
|
205
|
+
end
|
206
|
+
#maker_update(maker)
|
207
|
+
return true
|
208
|
+
end
|
209
|
+
|
210
|
+
def puts(s)
|
211
|
+
@session.puts(s + @cr)
|
212
|
+
end
|
213
|
+
private :puts
|
214
|
+
|
215
|
+
def cmd(s, nextprompt = nil, ignoreerror = false)
|
216
|
+
re = [ @prompt ]
|
217
|
+
re.push('#') if @maker == Maker::UNKNOWN # XXX dirty hack for now...
|
218
|
+
re.push(nextprompt) if nextprompt
|
219
|
+
re = '(?:' + re.join('|') + ')'
|
220
|
+
r = @session.cmd('String' => s + @cr, 'Match' => /#{re}\Z/)
|
221
|
+
r = handle_control_characters(r)
|
222
|
+
#
|
223
|
+
# XXX: allows a command not to be echo-ed like FTP server.
|
224
|
+
#
|
225
|
+
if r !~ /^#{re}?(?:#{s.sub('*', '\\*')})?\n+(.*)#{re}\Z/m
|
226
|
+
raise(ArgumentError, "CLI output error: \"#{r}\"")
|
227
|
+
end
|
228
|
+
r = $1
|
229
|
+
r.slice!(-1) if @maker === Maker::ALAXALA && r[-1] === '!'
|
230
|
+
if ignoreerror === false && error?(r)
|
231
|
+
raise(ArgumentError,
|
232
|
+
"Command failed on #{@name}: #{s}: #{r}")
|
233
|
+
end
|
234
|
+
@prompt = nextprompt if nextprompt
|
235
|
+
return r
|
236
|
+
end
|
237
|
+
|
238
|
+
def passwd(users, passwds, opasswds, nextprompt, cmd = nil)
|
239
|
+
userpromptre = /(?:#{@userprompt.join('|')})/
|
240
|
+
prompts = @userprompt.dup
|
241
|
+
prompts << @prompt if @prompt
|
242
|
+
prompts << @passwdprompt if @passwdprompt
|
243
|
+
prompts << nextprompt if nextprompt
|
244
|
+
re = /(?:#{prompts.join('|')})/
|
245
|
+
r = @session.waitfor('Match' => re) if users
|
246
|
+
while true
|
247
|
+
if passwds.empty?
|
248
|
+
users.shift if users
|
249
|
+
if users && r =~ userpromptre
|
250
|
+
passwds = opasswds.dup
|
251
|
+
else
|
252
|
+
raise(Errno::EPERM,
|
253
|
+
'authentication failed')
|
254
|
+
end
|
255
|
+
end
|
256
|
+
if users && ! users.empty? && r =~ userpromptre
|
257
|
+
puts(users[0])
|
258
|
+
elsif cmd && r !~ /#{@passwdprompt}/
|
259
|
+
puts(cmd)
|
260
|
+
end
|
261
|
+
if r !~ /#{@passwdprompt}/
|
262
|
+
r = @session.waitfor(
|
263
|
+
'Match' => /#{@passwdprompt}/)
|
264
|
+
#
|
265
|
+
# emulate an exception because net/telnet.rb
|
266
|
+
# does not raise an exception when a remote
|
267
|
+
# note disconnects after consecutive login
|
268
|
+
# failures.
|
269
|
+
#
|
270
|
+
raise(Errno::ECONNRESET) if r === nil
|
271
|
+
end
|
272
|
+
puts(passwds[0])
|
273
|
+
r = @session.waitfor('Match' => re)
|
274
|
+
case r
|
275
|
+
when userpromptre
|
276
|
+
raise('no user for user prompt') if ! users
|
277
|
+
when /#{@passwdprompt}/
|
278
|
+
when /#{nextprompt}/
|
279
|
+
@prompt = nextprompt
|
280
|
+
return r
|
281
|
+
else
|
282
|
+
if @prompt && r =~ /#{@prompt}/ && ! cmd
|
283
|
+
raise('invalid state')
|
284
|
+
end
|
285
|
+
end
|
286
|
+
passwds.shift
|
287
|
+
end
|
288
|
+
end
|
289
|
+
private :passwd
|
290
|
+
|
291
|
+
def login_ssh(ousers, opasswds)
|
292
|
+
users = ousers.dup
|
293
|
+
passwds = opasswds.dup
|
294
|
+
user = users.shift
|
295
|
+
begin
|
296
|
+
passwd = passwds.shift
|
297
|
+
opt = @telnetopt.dup
|
298
|
+
ssh = Net::SSH.start(@ia, user,
|
299
|
+
:password => passwd,
|
300
|
+
:non_interactive => true,
|
301
|
+
#:keys => ['/path/to/private_key'],
|
302
|
+
#:port => 22,
|
303
|
+
:timeout => opt['Timeout']
|
304
|
+
)
|
305
|
+
opt['Session'] = ssh
|
306
|
+
|
307
|
+
#
|
308
|
+
# XXX: Alaxala edge switch hack...
|
309
|
+
#
|
310
|
+
# an Alaxala edge switch asks us cursor position
|
311
|
+
# using CSI escap sequence, DSR (0x1b[6n), before
|
312
|
+
# outputing prompt. Net::SSH::Telnet then does not
|
313
|
+
# consider the case where escape sequence is sent
|
314
|
+
# right after login. This incurs long delay until
|
315
|
+
# Alaxala outputs the first prompt. Here, we send
|
316
|
+
# back a dummy cursor position, (1, 1), to Alaxala.
|
317
|
+
#
|
318
|
+
opt['Prompt'] = /(?:\e\[6n|[$%#>] ?\Z)/
|
319
|
+
|
320
|
+
msg = ''
|
321
|
+
@session = Net::SSH::Telnet.new(opt) { |l| msg += l }
|
322
|
+
@session.prompt = /[$%#>] \z/
|
323
|
+
if msg =~ /ALAXALA/
|
324
|
+
maker_update(Maker::ALAXALA)
|
325
|
+
if msg.rindex("\x1b[6n")
|
326
|
+
@cr = "\r"
|
327
|
+
@session.write("^1;1R")
|
328
|
+
msg += @session.waitfor(/[$%#>] \z/)
|
329
|
+
end
|
330
|
+
end
|
331
|
+
rescue Net::SSH::AuthenticationFailed, Net::SSH::Disconnect => e
|
332
|
+
if passwds.empty?
|
333
|
+
if users.empty?
|
334
|
+
raise(Errno::EPERM, "cannot login to " +
|
335
|
+
"#{@ia}")
|
336
|
+
end
|
337
|
+
user = users.shift
|
338
|
+
passwds = opasswds.dup
|
339
|
+
end
|
340
|
+
retry
|
341
|
+
rescue Net::SSH::ConnectionTimeout
|
342
|
+
raise("cannot login to #{@ia}")
|
343
|
+
end
|
344
|
+
#
|
345
|
+
# XXX: dirty hack
|
346
|
+
#
|
347
|
+
# some switch, say Alaxala, disconnects immediately
|
348
|
+
# after authentication succeeds if many user logged
|
349
|
+
# in. Net::SSH::Telnet.new() unfortunately cannot
|
350
|
+
# handle such case.
|
351
|
+
#
|
352
|
+
if @session.nil?
|
353
|
+
raise(Errno::ECONNRESET, 'too many users???')
|
354
|
+
end
|
355
|
+
return msg
|
356
|
+
end
|
357
|
+
private :login_ssh
|
358
|
+
|
359
|
+
def login_telnet(users, passwds)
|
360
|
+
opt = @telnetopt.dup
|
361
|
+
opt['Host'] = @ia
|
362
|
+
users = users.dup if users
|
363
|
+
passwds = passwds.dup
|
364
|
+
opasswds = passwds.dup
|
365
|
+
begin
|
366
|
+
@session = Net::Telnet::new(opt)
|
367
|
+
r = passwd(users, passwds, opasswds, @normalprompt)
|
368
|
+
rescue Errno::ECONNRESET
|
369
|
+
retry
|
370
|
+
end
|
371
|
+
return r
|
372
|
+
end
|
373
|
+
private :login_telnet
|
374
|
+
|
375
|
+
def login
|
376
|
+
users = @users.dup
|
377
|
+
passwds = @passwds.dup
|
378
|
+
@type = @types[0]
|
379
|
+
begin
|
380
|
+
r = send("login_#{@type}", users, passwds)
|
381
|
+
rescue Errno::ECONNREFUSED => e
|
382
|
+
raise e if @type === @types.last
|
383
|
+
@type = @types[@types.index(@type) + 1]
|
384
|
+
retry
|
385
|
+
end
|
386
|
+
r = handle_control_characters(r)
|
387
|
+
if r =~ FIRST_PROMPT_RE
|
388
|
+
name_update($1)
|
389
|
+
if $2 === '#'
|
390
|
+
@enabled = true
|
391
|
+
end
|
392
|
+
end
|
393
|
+
if r =~ /NEC Corporation.*OpenROUTE.*J\. Noel Chiappa/m
|
394
|
+
maker_update(Maker::NEC)
|
395
|
+
end
|
396
|
+
product_detect
|
397
|
+
end
|
398
|
+
|
399
|
+
PRODUCT_DETECTION_MAXRETRIES = 3
|
400
|
+
|
401
|
+
# XXX: exclude maker dependencies...
|
402
|
+
def product_detect
|
403
|
+
pager_disable
|
404
|
+
return if @maker === Maker::UNKNOWN
|
405
|
+
case @maker
|
406
|
+
when Maker::CISCO
|
407
|
+
c = 'show version'
|
408
|
+
# XXX: Nexus not supported yet
|
409
|
+
re = /IOS Software, ([^\s]+) Software .*/m
|
410
|
+
when Maker::WLC
|
411
|
+
c = 'show sysinfo'
|
412
|
+
re = /.*Product Name[^ ]+ (.*)$/m
|
413
|
+
when Maker::ALAXALA
|
414
|
+
c = 'show version'
|
415
|
+
re = /.*Model:\s+(AX[^\s\n]+).*$/m
|
416
|
+
when Maker::PALOALTO
|
417
|
+
c = 'show system info'
|
418
|
+
re = /model: ([^\s\n]+).*/m
|
419
|
+
when Maker::ARUBA
|
420
|
+
c = 'show version'
|
421
|
+
re = /.*ArubaOS \(MODEL: ([^\)]+)\),.*/m
|
422
|
+
when Maker::NEC
|
423
|
+
c = 'show version'
|
424
|
+
re = /^IX Series ([^ ]+) .*$/m
|
425
|
+
when Maker::UNKNOWN
|
426
|
+
return
|
427
|
+
end
|
428
|
+
|
429
|
+
leftchance = PRODUCT_DETECTION_MAXRETRIES
|
430
|
+
begin
|
431
|
+
v = cmd(c)
|
432
|
+
if v !~ re
|
433
|
+
raise('Unknown maker detected')
|
434
|
+
end
|
435
|
+
@product = $1
|
436
|
+
disable_logging_console
|
437
|
+
rescue => e
|
438
|
+
leftchance -= 1
|
439
|
+
raise e if leftchance === 0
|
440
|
+
retry
|
441
|
+
end
|
442
|
+
end
|
443
|
+
private :product_detect
|
444
|
+
|
445
|
+
def maker_to_s
|
446
|
+
# XXX: smarter way please...
|
447
|
+
case @maker
|
448
|
+
when Maker::CISCO
|
449
|
+
'Cisco'
|
450
|
+
when Maker::WLC
|
451
|
+
'WLC'
|
452
|
+
when Maker::ALAXALA
|
453
|
+
'Alaxala'
|
454
|
+
when Maker::PALOALTO
|
455
|
+
'Paloalto'
|
456
|
+
when Maker::ARUBA
|
457
|
+
'Aruba'
|
458
|
+
when Maker::NEC
|
459
|
+
'NEC'
|
460
|
+
else
|
461
|
+
'Unknown'
|
462
|
+
end
|
463
|
+
end
|
464
|
+
|
465
|
+
def yes_or_no(yes_or_no, re)
|
466
|
+
re = /\n[^\n]+[Yy]\/[Nn]\)? ?$/m if re == nil
|
467
|
+
@session.waitfor('Match' => re)
|
468
|
+
puts(yes_or_no)
|
469
|
+
end
|
470
|
+
private :yes_or_no
|
471
|
+
|
472
|
+
def yes(re = nil)
|
473
|
+
yes_or_no('y', re)
|
474
|
+
end
|
475
|
+
|
476
|
+
def no(re = nil)
|
477
|
+
yes_or_no('n', re)
|
478
|
+
end
|
479
|
+
|
480
|
+
def logout
|
481
|
+
@session.close
|
482
|
+
end
|
483
|
+
|
484
|
+
def maker_update(maker)
|
485
|
+
return if @maker != Maker::UNKNOWN
|
486
|
+
@maker = maker
|
487
|
+
extend Module.const_get("#{maker_to_s}")
|
488
|
+
end
|
489
|
+
private :maker_update
|
490
|
+
|
491
|
+
def pager_disable_cisco
|
492
|
+
cmd('terminal length 0')
|
493
|
+
end
|
494
|
+
private :pager_disable_cisco
|
495
|
+
|
496
|
+
def pager_disable_wlc
|
497
|
+
cmd('config paging disable')
|
498
|
+
end
|
499
|
+
|
500
|
+
def pager_disable_alaxala
|
501
|
+
cmd('set terminal pager disable')
|
502
|
+
end
|
503
|
+
private :pager_disable_alaxala
|
504
|
+
|
505
|
+
def pager_disable_paloalto
|
506
|
+
cmd('set cli pager off')
|
507
|
+
end
|
508
|
+
private :pager_disable_paloalto
|
509
|
+
|
510
|
+
def pager_disable_aruba
|
511
|
+
enable
|
512
|
+
cmd('no paging')
|
513
|
+
end
|
514
|
+
private :pager_disable_aruba
|
515
|
+
|
516
|
+
def pager_disable_nec
|
517
|
+
configure
|
518
|
+
cmd('terminal length 0')
|
519
|
+
unconfigure
|
520
|
+
end
|
521
|
+
private :pager_disable_nec
|
522
|
+
|
523
|
+
def pager_disable
|
524
|
+
maker = omaker = @maker
|
525
|
+
maker = Maker::MIN if omaker === Maker::UNKNOWN
|
526
|
+
begin
|
527
|
+
@maker = maker
|
528
|
+
send('pager_disable_' + maker_to_s.downcase)
|
529
|
+
rescue Timeout::Error => e
|
530
|
+
raise e
|
531
|
+
rescue => e
|
532
|
+
if omaker === Maker::UNKNOWN
|
533
|
+
maker += 1
|
534
|
+
if maker != Maker::UNKNOWN &&
|
535
|
+
e != ArgumentError
|
536
|
+
retry
|
537
|
+
else
|
538
|
+
@maker = omaker
|
539
|
+
raise
|
540
|
+
end
|
541
|
+
end
|
542
|
+
ensure
|
543
|
+
@maker = omaker
|
544
|
+
end
|
545
|
+
maker_update(maker)
|
546
|
+
name_update(@name) if omaker === Maker::UNKNOWN
|
547
|
+
end
|
548
|
+
private :pager_disable
|
549
|
+
|
550
|
+
def enable
|
551
|
+
return if @enabled
|
552
|
+
passwd(nil, @enable_passwds.dup, @enable_passwds.dup,
|
553
|
+
@enableprompt.dup, 'enable')
|
554
|
+
@enabled = true
|
555
|
+
end
|
556
|
+
|
557
|
+
def configure
|
558
|
+
raise "already configuring" if @prompt == @configprompt
|
559
|
+
enable
|
560
|
+
return cmd('configure terminal', @configprompt)
|
561
|
+
end
|
562
|
+
|
563
|
+
def unconfigure
|
564
|
+
raise "currently not configuring" if @prompt != @configprompt
|
565
|
+
return cmd('end', @enableprompt)
|
566
|
+
end
|
567
|
+
|
568
|
+
def config_get
|
569
|
+
enable
|
570
|
+
re = Module.const_get(maker_to_s).const_get('CONFIG_RE')
|
571
|
+
if show_running_config !~ re
|
572
|
+
raise("Invalid configuration format for #{@name}")
|
573
|
+
end
|
574
|
+
return $1
|
575
|
+
end
|
576
|
+
|
577
|
+
def _new(name, *arg)
|
578
|
+
c = Module.const_get(maker_to_s)
|
579
|
+
if c.const_defined?(name)
|
580
|
+
c.const_get(name).new(*arg)
|
581
|
+
else
|
582
|
+
nil
|
583
|
+
end
|
584
|
+
end
|
585
|
+
private :_new
|
586
|
+
|
587
|
+
def route_gets(ia)
|
588
|
+
r = _new(:ShowRoute)
|
589
|
+
r.parse(cmd(r.cmd(ia), nil, true))
|
590
|
+
return r.rib.get(ia)
|
591
|
+
end
|
592
|
+
|
593
|
+
def vrf_gets
|
594
|
+
v = _new(:ShowVRF)
|
595
|
+
v.parse(cmd(v.cmd))
|
596
|
+
v.vrfs.add('default', '0:0') if v.vrfs.empty?
|
597
|
+
return v.vrfs
|
598
|
+
end
|
599
|
+
|
600
|
+
def arp_resolve(ia, vrf)
|
601
|
+
if vrf.name == 'default'
|
602
|
+
output = cmd("show ip arp #{ia}")
|
603
|
+
else
|
604
|
+
output = cmd("show ip arp vrf #{vrf.name} #{ia}")
|
605
|
+
end
|
606
|
+
a = _new(:ShowARP)
|
607
|
+
a.parse(output)
|
608
|
+
return a.arps[ia]
|
609
|
+
end
|
610
|
+
|
611
|
+
def mac_address_table_get(sw, ma, vlan)
|
612
|
+
fib = _new(:MACFIB, sw)
|
613
|
+
fib.parse(cmd(fib.cmd(ma, vlan)))
|
614
|
+
return fib.ports
|
615
|
+
end
|
616
|
+
|
617
|
+
def interface_gets(sw)
|
618
|
+
i = _new(:Interface, sw)
|
619
|
+
i.parse(cmd(i.cmd))
|
620
|
+
#
|
621
|
+
# XXX: hack for Cisco because Cisco cannot obtain up/down of
|
622
|
+
# an interface with interfaces capability command...
|
623
|
+
#
|
624
|
+
is = _new(:IfSummary, sw)
|
625
|
+
is.parse(cmd(is.cmd)) if is
|
626
|
+
end
|
627
|
+
|
628
|
+
def interface_name(port)
|
629
|
+
port
|
630
|
+
end
|
631
|
+
|
632
|
+
def interface_name_cli(port)
|
633
|
+
port
|
634
|
+
end
|
635
|
+
|
636
|
+
def interface_shutdown(port)
|
637
|
+
configure
|
638
|
+
cmd("interface #{port}")
|
639
|
+
cmd('shutdown')
|
640
|
+
unconfigure
|
641
|
+
end
|
642
|
+
|
643
|
+
def interface_noshutdown(port)
|
644
|
+
configure
|
645
|
+
cmd("interface #{port}")
|
646
|
+
cmd('no shutdown')
|
647
|
+
unconfigure
|
648
|
+
end
|
649
|
+
|
650
|
+
def acl_exists?(type, name)
|
651
|
+
configure
|
652
|
+
filters = cmd("show #{acl_definition(type, name)}")
|
653
|
+
unconfigure
|
654
|
+
! filters.empty?
|
655
|
+
end
|
656
|
+
|
657
|
+
def acl_add(type, name, addr, seq = nil)
|
658
|
+
case type
|
659
|
+
when 'ip'
|
660
|
+
# XXX: we may need sanity check.
|
661
|
+
when 'mac', 'advance'
|
662
|
+
addr = MACAddr.new(addr)
|
663
|
+
else
|
664
|
+
raise("Unknown ACL type: #type")
|
665
|
+
end
|
666
|
+
cmd = acl_type_to_cmd(type)
|
667
|
+
configure
|
668
|
+
cmd(acl_definition(type, name))
|
669
|
+
cmd("#{seq} deny #{cmd} host #{addr} any")
|
670
|
+
unconfigure
|
671
|
+
end
|
672
|
+
|
673
|
+
def acl_delete(type, name, seq)
|
674
|
+
configure
|
675
|
+
cmd(acl_definition(type, name))
|
676
|
+
cmd("no #{seq}")
|
677
|
+
unconfigure
|
678
|
+
end
|
679
|
+
|
680
|
+
def neighbor_gets(sw, port = nil)
|
681
|
+
c = _new(:CDP, sw)
|
682
|
+
if c
|
683
|
+
begin
|
684
|
+
c.parse(cmd(c.cmd(port)))
|
685
|
+
return c.rsw if port && c.rsw
|
686
|
+
rescue ArgumentError => e
|
687
|
+
if e.to_s !~ /% CDP is not enabled/
|
688
|
+
raise e
|
689
|
+
end
|
690
|
+
end
|
691
|
+
end
|
692
|
+
l = _new(:LLDP, sw)
|
693
|
+
if l
|
694
|
+
l.parse(cmd(l.cmd(port)))
|
695
|
+
return l.rsw if port && l.rsw
|
696
|
+
end
|
697
|
+
if ! c && ! l
|
698
|
+
raise 'No method found for retrieving a neighbor!!'
|
699
|
+
end
|
700
|
+
nil
|
701
|
+
end
|
702
|
+
end
|