Skip to content

Commit 11b71d6

Browse files
committed
ping: T8608: add TCP connect probe
1 parent 5c4afee commit 11b71d6

4 files changed

Lines changed: 601 additions & 43 deletions

File tree

op-mode-definitions/ping.xml.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<interfaceDefinition>
33
<tagNode name="ping">
44
<properties>
5-
<help>Send Internet Control Message Protocol (ICMP) echo request</help>
5+
<help>Send ICMP echo request or TCP connect probe</help>
66
<completionHelp>
77
<list>&lt;hostname&gt; &lt;x.x.x.x&gt; &lt;h:h:h:h:h:h:h:h&gt;</list>
88
</completionHelp>

src/etc/sudoers.d/vyos

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Cmnd_Alias HWINFO = /usr/bin/lspci
4242
Cmnd_Alias FORCE_CLUSTER = /usr/share/heartbeat/hb_takeover, \
4343
/usr/share/heartbeat/hb_standby
4444
Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *, \
45+
/usr/bin/nping *, \
46+
/bin/ip vrf exec * /usr/bin/nping *, \
4547
/bin/ip vrf exec * /bin/traceroute *, \
4648
/bin/ip vrf exec * /usr/bin/mtr *, \
4749
/usr/libexec/vyos/op_mode/*

src/op_mode/ping.py

Lines changed: 285 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

1717
import sys
18+
import re
1819
import socket
1920
import ipaddress
21+
import subprocess
2022

2123
from vyos.utils.network import interface_list
2224
from vyos.utils.network import vrf_list
@@ -156,6 +158,33 @@
156158
6: '/bin/ping6',
157159
}
158160

161+
tcp_options = {
162+
'count': {'type': '<requests>', 'help': 'Number of requests to send'},
163+
'interface': {
164+
'type': '<interface>',
165+
'helpfunction': interface_list,
166+
'help': 'Source interface',
167+
},
168+
'port': {'type': '<port>', 'help': 'Destination port'},
169+
'source-address': {'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>', 'help': 'Source address'},
170+
'vrf': {
171+
'type': '<vrf>',
172+
'help': 'Use specified VRF table',
173+
'helpfunction': vrf_list,
174+
'dflt': 'default',
175+
},
176+
}
177+
178+
ping_options = options | {
179+
'tcp': {'type': 'noarg', 'help': 'Use TCP connect probe'},
180+
}
181+
182+
tcp_completion_options = {'tcp': ping_options['tcp']} | tcp_options
183+
184+
NPING = '/usr/bin/nping'
185+
TCP_DELAY = '1s'
186+
SUCCESSFUL_CONNECTIONS_RE = re.compile(r'Successful connections:\s+(\d+)')
187+
159188

160189
class List(list):
161190
def first(self):
@@ -179,7 +208,7 @@ def completion_failure(option: str) -> None:
179208
sys.exit(1)
180209

181210

182-
def expension_failure(option, completions):
211+
def expansion_failure(option, completions):
183212
reason = 'Ambiguous' if completions else 'Invalid'
184213
sys.stderr.write(
185214
'\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
@@ -192,16 +221,20 @@ def expension_failure(option, completions):
192221
sys.exit(1)
193222

194223

195-
def complete(prefix):
196-
return [o for o in options if o.startswith(prefix)]
224+
def complete(prefix, available_options=options):
225+
return [o for o in available_options if o.startswith(prefix)]
226+
227+
228+
class UsageError(Exception):
229+
pass
197230

198231

199232
def convert(command, args):
200233
while args:
201234
shortname = args.first()
202235
longnames = complete(shortname)
203236
if len(longnames) != 1:
204-
expension_failure(shortname, longnames)
237+
expansion_failure(shortname, longnames)
205238
longname = longnames[0]
206239
if options[longname]['type'] == 'noarg':
207240
command = options[longname]['ping'].format(
@@ -214,6 +247,249 @@ def convert(command, args):
214247
return command
215248

216249

250+
def option_value_help(option, available_options):
251+
helplines = available_options[option]['type']
252+
if 'helpfunction' in available_options[option]:
253+
result = available_options[option]['helpfunction']()
254+
if result:
255+
helplines = '\n' + ' '.join(result)
256+
257+
return helplines
258+
259+
260+
def get_option_completion(args, available_options):
261+
args.first() # pop ping
262+
args.first() # pop IP
263+
usedoptionslist = []
264+
while args:
265+
option = args.first() # pop option
266+
matched = complete(option, available_options) # get option parameters
267+
usedoptionslist.append(option) # list of used options
268+
# Select options
269+
if not args:
270+
# remove from Possible completions used options
271+
for o in usedoptionslist:
272+
if o in matched:
273+
matched.remove(o)
274+
return ' '.join(matched)
275+
276+
if len(matched) > 1:
277+
return ' '.join(matched)
278+
# If option doesn't have value
279+
if matched:
280+
if available_options[matched[0]]['type'] == 'noarg':
281+
continue
282+
else:
283+
# Unexpected option
284+
completion_failure(option)
285+
286+
args.first() # pop option's value
287+
if not args:
288+
matched = complete(option, available_options)
289+
return option_value_help(matched[0], available_options)
290+
291+
return ''
292+
293+
294+
def get_icmp_completion(args):
295+
return get_option_completion(args, ping_options)
296+
297+
298+
def get_tcp_completion(args):
299+
return get_option_completion(args, tcp_completion_options)
300+
301+
302+
def has_tcp_option(args):
303+
args = List(args[:])
304+
args.first() # pop ping
305+
args.first() # pop IP
306+
307+
while args:
308+
shortname = args.first()
309+
longnames = complete(shortname, ping_options)
310+
if len(longnames) != 1:
311+
continue
312+
313+
longname = longnames[0]
314+
if longname == 'tcp':
315+
return True
316+
317+
if ping_options[longname]['type'] != 'noarg':
318+
args.first()
319+
320+
return False
321+
322+
323+
def get_completion(args):
324+
if has_tcp_option(args):
325+
return get_tcp_completion(args)
326+
327+
return get_icmp_completion(args)
328+
329+
330+
def tcp_usage(message):
331+
sys.stderr.write(f'{message}\n')
332+
return 2
333+
334+
335+
def parse_positive_int(value, option):
336+
try:
337+
number = int(value)
338+
except ValueError:
339+
raise UsageError(f'ping tcp: invalid {option}: {value}')
340+
341+
if number < 1:
342+
raise UsageError(f'ping tcp: invalid {option}: {value}')
343+
344+
return number
345+
346+
347+
def parse_tcp_port(value):
348+
port = parse_positive_int(value, 'port')
349+
if port > 65535:
350+
raise UsageError(f'ping tcp: invalid port: {value}')
351+
352+
return port
353+
354+
355+
def parse_tcp_args(argv):
356+
args = List(argv)
357+
host = args.first()
358+
if not host:
359+
raise UsageError('ping tcp: Missing host')
360+
361+
result = {
362+
'host': host,
363+
'port': None,
364+
'count': None,
365+
'interface': None,
366+
'source_address': None,
367+
'vrf': 'default',
368+
}
369+
370+
while args:
371+
shortname = args.first()
372+
longnames = complete(shortname, tcp_completion_options)
373+
if len(longnames) > 1:
374+
raise UsageError(f'ping tcp: ambiguous option: {shortname}')
375+
if not longnames:
376+
raise UsageError(f'ping tcp: invalid option: {shortname}')
377+
378+
longname = longnames[0]
379+
if longname == 'tcp':
380+
continue
381+
382+
if not args:
383+
raise UsageError(f'ping tcp: missing argument for {longname} option')
384+
385+
value = args.first()
386+
if longname == 'port':
387+
result['port'] = parse_tcp_port(value)
388+
elif longname == 'count':
389+
result['count'] = parse_positive_int(value, 'count')
390+
elif longname == 'source-address':
391+
try:
392+
ipaddress.ip_address(value)
393+
except ValueError:
394+
raise UsageError(f'ping tcp: invalid source-address: {value}')
395+
result['source_address'] = value
396+
elif longname == 'interface':
397+
result['interface'] = value
398+
elif longname == 'vrf':
399+
result['vrf'] = value
400+
401+
if result['port'] is None:
402+
raise UsageError('ping tcp: missing port option')
403+
404+
return result
405+
406+
407+
def tcp_uses_ipv6(config):
408+
addresses = [config['host'], config['source_address']]
409+
for address in addresses:
410+
if not address:
411+
continue
412+
try:
413+
if ipaddress.ip_address(address).version == 6:
414+
return True
415+
except ValueError:
416+
continue
417+
418+
return False
419+
420+
421+
def build_tcp_nping_command(config):
422+
command = [
423+
NPING,
424+
'--tcp-connect',
425+
'--dest-port',
426+
str(config['port']),
427+
'--count',
428+
str(config['count'] if config['count'] is not None else 0),
429+
'--delay',
430+
TCP_DELAY,
431+
]
432+
433+
if tcp_uses_ipv6(config):
434+
command.insert(1, '-6')
435+
436+
if config['interface']:
437+
command.extend(['--interface', config['interface']])
438+
439+
if config['source_address']:
440+
command.extend(['--source-ip', config['source_address']])
441+
442+
command.append(config['host'])
443+
444+
if config['vrf'] != 'default':
445+
return ['sudo', '/bin/ip', 'vrf', 'exec', config['vrf']] + command
446+
447+
if config['interface'] or config['source_address']:
448+
return ['sudo'] + command
449+
450+
return command
451+
452+
453+
def run_tcp_nping(command, popen=subprocess.Popen):
454+
successful_connections = None
455+
process = None
456+
457+
try:
458+
process = popen(
459+
command,
460+
stdout=subprocess.PIPE,
461+
stderr=subprocess.STDOUT,
462+
text=True,
463+
)
464+
for line in process.stdout:
465+
print(line, end='')
466+
match = SUCCESSFUL_CONNECTIONS_RE.search(line)
467+
if match:
468+
successful_connections = int(match.group(1))
469+
returncode = process.wait()
470+
except FileNotFoundError:
471+
raise UsageError('ping tcp: nping is not installed')
472+
except KeyboardInterrupt:
473+
if process:
474+
process.terminate()
475+
process.wait()
476+
print()
477+
return 130
478+
479+
if successful_connections is not None:
480+
return 0 if successful_connections > 0 else 1
481+
482+
return returncode if returncode else 1
483+
484+
485+
def run_tcp_ping(argv):
486+
try:
487+
config = parse_tcp_args(argv)
488+
return run_tcp_nping(build_tcp_nping_command(config))
489+
except UsageError as err:
490+
return tcp_usage(str(err))
491+
492+
217493
if __name__ == '__main__':
218494
args = List(sys.argv[1:])
219495
host = args.first()
@@ -222,44 +498,11 @@ def convert(command, args):
222498
sys.exit("ping: Missing host")
223499

224500
if host == '--get-options':
225-
args.first() # pop ping
226-
args.first() # pop IP
227-
usedoptionslist = []
228-
while args:
229-
option = args.first() # pop option
230-
matched = complete(option) # get option parameters
231-
usedoptionslist.append(option) # list of used options
232-
# Select options
233-
if not args:
234-
# remove from Possible completions used options
235-
for o in usedoptionslist:
236-
if o in matched:
237-
matched.remove(o)
238-
sys.stdout.write(' '.join(matched))
239-
sys.exit(0)
240-
241-
if len(matched) > 1:
242-
sys.stdout.write(' '.join(matched))
243-
sys.exit(0)
244-
# If option doesn't have value
245-
if matched:
246-
if options[matched[0]]['type'] == 'noarg':
247-
continue
248-
else:
249-
# Unexpected option
250-
completion_failure(option)
251-
252-
value = args.first() # pop option's value
253-
if not args:
254-
matched = complete(option)
255-
helplines = options[matched[0]]['type']
256-
# Run helpfunction to get list of possible values
257-
if 'helpfunction' in options[matched[0]]:
258-
result = options[matched[0]]['helpfunction']()
259-
if result:
260-
helplines = '\n' + ' '.join(result)
261-
sys.stdout.write(helplines)
262-
sys.exit(0)
501+
sys.stdout.write(get_completion(args))
502+
sys.exit(0)
503+
504+
if has_tcp_option(['ping', host] + args):
505+
sys.exit(run_tcp_ping([host] + args))
263506

264507
for name, option in options.items():
265508
if 'dflt' in option and name not in args:

0 commit comments

Comments
 (0)