summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniil Baturin <daniil@vyos.io>2024-11-07 17:46:27 +0000
committerGitHub <noreply@github.com>2024-11-07 17:46:27 +0000
commit3eb66e36b81c835f802900c4bc2e973e2657fa31 (patch)
tree39e8061bcd16155b80b6ab1747b27aa68b43f9ad
parentc97980d18610d8d48a726986139fed29ad03137a (diff)
parent18a9cec3deb6cc2dc49020a89208dc70defe9822 (diff)
downloadvyos-1x-3eb66e36b81c835f802900c4bc2e973e2657fa31.tar.gz
vyos-1x-3eb66e36b81c835f802900c4bc2e973e2657fa31.zip
Merge pull request #4151 from natali-rs1985/T6695
T6695: Machine-readable operational mode support for traceroute
-rw-r--r--data/templates/https/nginx.default.j22
-rw-r--r--python/vyos/configsession.py14
-rw-r--r--python/vyos/opmode.py165
-rw-r--r--src/op_mode/mtr.py76
-rw-r--r--src/op_mode/mtr_execute.py217
-rw-r--r--src/services/api/rest/models.py14
-rw-r--r--src/services/api/rest/routers.py24
7 files changed, 411 insertions, 101 deletions
diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2
index 1dde66ebf..51da46946 100644
--- a/data/templates/https/nginx.default.j2
+++ b/data/templates/https/nginx.default.j2
@@ -48,7 +48,7 @@ server {
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK';
# proxy settings for HTTP API, if enabled; 503, if not
- location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|docs|openapi.json|redoc|graphql) {
+ location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|traceroute|docs|openapi.json|redoc|graphql) {
{% if api is vyos_defined %}
proxy_pass http://unix:/run/api.sock;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py
index 5876dc5b0..90b96b88c 100644
--- a/python/vyos/configsession.py
+++ b/python/vyos/configsession.py
@@ -66,6 +66,16 @@ REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot']
POWEROFF = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'poweroff']
OP_CMD_ADD = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'add']
OP_CMD_DELETE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'delete']
+TRACEROUTE = [
+ '/usr/libexec/vyos/op_mode/mtr_execute.py',
+ 'mtr',
+ '--for-api',
+ '--report-mode',
+ '--report-cycles',
+ '1',
+ '--json',
+ '--host',
+]
# Default "commit via" string
APP = 'vyos-http-api'
@@ -335,3 +345,7 @@ class ConfigSession(object):
def show_container_image(self):
out = self.__run_command(SHOW + ['container', 'image'])
return out
+
+ def traceroute(self, host):
+ out = self.__run_command(TRACEROUTE + [host])
+ return out
diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py
index 136ac35e2..7b11d36dd 100644
--- a/python/vyos/opmode.py
+++ b/python/vyos/opmode.py
@@ -20,89 +20,110 @@ from humps import decamelize
class Error(Exception):
- """ Any error that makes requested operation impossible to complete
- for reasons unrelated to the user input or script logic.
+ """Any error that makes requested operation impossible to complete
+ for reasons unrelated to the user input or script logic.
- This is the base class, scripts should not use it directly
- and should raise more specific errors instead,
- whenever possible.
+ This is the base class, scripts should not use it directly
+ and should raise more specific errors instead,
+ whenever possible.
"""
+
pass
+
class UnconfiguredSubsystem(Error):
- """ Requested operation is valid, but cannot be completed
- because corresponding subsystem is not configured
- and thus is not running.
+ """Requested operation is valid, but cannot be completed
+ because corresponding subsystem is not configured
+ and thus is not running.
"""
+
pass
+
class UnconfiguredObject(UnconfiguredSubsystem):
- """ Requested operation is valid but cannot be completed
- because its parameter refers to an object that does not exist
- in the system configuration.
+ """Requested operation is valid but cannot be completed
+ because its parameter refers to an object that does not exist
+ in the system configuration.
"""
+
pass
+
class DataUnavailable(Error):
- """ Requested operation is valid, but cannot be completed
- because data for it is not available.
- This error MAY be treated as temporary because such issues
- are often caused by transient events such as service restarts.
+ """Requested operation is valid, but cannot be completed
+ because data for it is not available.
+ This error MAY be treated as temporary because such issues
+ are often caused by transient events such as service restarts.
"""
+
pass
+
class PermissionDenied(Error):
- """ Requested operation is valid, but the caller has no permission
- to perform it.
+ """Requested operation is valid, but the caller has no permission
+ to perform it.
"""
+
pass
+
class InsufficientResources(Error):
- """ Requested operation and its arguments are valid but the system
- does not have enough resources (such as drive space or memory)
- to complete it.
+ """Requested operation and its arguments are valid but the system
+ does not have enough resources (such as drive space or memory)
+ to complete it.
"""
+
pass
+
class UnsupportedOperation(Error):
- """ Requested operation is technically valid but is not implemented yet. """
+ """Requested operation is technically valid but is not implemented yet."""
+
pass
+
class IncorrectValue(Error):
- """ Requested operation is valid, but an argument provided has an
- incorrect value, preventing successful completion.
+ """Requested operation is valid, but an argument provided has an
+ incorrect value, preventing successful completion.
"""
+
pass
+
class CommitInProgress(Error):
- """ Requested operation is valid, but not possible at the time due
+ """Requested operation is valid, but not possible at the time due
to a commit being in progress.
"""
+
pass
+
class InternalError(Error):
- """ Any situation when VyOS detects that it could not perform
- an operation correctly due to logic errors in its own code
- or errors in underlying software.
+ """Any situation when VyOS detects that it could not perform
+ an operation correctly due to logic errors in its own code
+ or errors in underlying software.
"""
+
pass
def _is_op_mode_function_name(name):
if re.match(
- r'^(show|clear|reset|restart|add|update|delete|generate|set|renew|release|execute|import)',
+ r'^(show|clear|reset|restart|add|update|delete|generate|set|renew|release|execute|import|mtr)',
name,
):
return True
else:
return False
+
def _capture_output(name):
- if re.match(r"^(show|generate)", name):
+ if re.match(r'^(show|generate)', name):
return True
else:
return False
+
def _get_op_mode_functions(module):
from inspect import getmembers, isfunction
@@ -113,32 +134,35 @@ def _get_op_mode_functions(module):
funcs = list(filter(lambda ft: _is_op_mode_function_name(ft[0]), funcs))
funcs_dict = {}
- for (name, thunk) in funcs:
+ for name, thunk in funcs:
funcs_dict[name] = thunk
return funcs_dict
+
def _is_optional_type(t):
# Optional[t] is internally an alias for Union[t, NoneType]
# and there's no easy way to get union members it seems
- if (type(t) == typing._UnionGenericAlias):
- if (len(t.__args__) == 2):
- if t.__args__[1] == type(None):
+ if type(t) is typing._UnionGenericAlias:
+ if len(t.__args__) == 2:
+ if t.__args__[1] is type(None):
return True
return False
+
def _get_arg_type(t):
- """ Returns the type itself if it's a primitive type,
- or the "real" type of typing.Optional
+ """Returns the type itself if it's a primitive type,
+ or the "real" type of typing.Optional
- Doesn't work with anything else at the moment!
+ Doesn't work with anything else at the moment!
"""
if _is_optional_type(t):
return t.__args__[0]
else:
return t
+
def _is_literal_type(t):
if _is_optional_type(t):
t = _get_arg_type(t)
@@ -148,9 +172,9 @@ def _is_literal_type(t):
return False
+
def _get_literal_values(t):
- """ Returns the tuple of allowed values for a Literal type
- """
+ """Returns the tuple of allowed values for a Literal type"""
if not _is_literal_type(t):
return tuple()
if _is_optional_type(t):
@@ -158,6 +182,7 @@ def _get_literal_values(t):
return typing.get_args(t)
+
def _normalize_field_name(name):
# Convert the name to string if it is not
# (in some cases they may be numbers)
@@ -182,6 +207,7 @@ def _normalize_field_name(name):
return name
+
def _normalize_dict_field_names(old_dict):
new_dict = {}
@@ -191,10 +217,11 @@ def _normalize_dict_field_names(old_dict):
# Sanity check
if len(old_dict) != len(new_dict):
- raise InternalError("Dictionary fields do not allow unique normalization")
+ raise InternalError('Dictionary fields do not allow unique normalization')
else:
return new_dict
+
def _normalize_field_names(value):
if isinstance(value, dict):
return _normalize_dict_field_names(value)
@@ -203,16 +230,19 @@ def _normalize_field_names(value):
else:
return value
+
def run(module):
from argparse import ArgumentParser
functions = _get_op_mode_functions(module)
parser = ArgumentParser()
- subparsers = parser.add_subparsers(dest="subcommand")
+ subparsers = parser.add_subparsers(dest='subcommand')
for function_name in functions:
- subparser = subparsers.add_parser(function_name, help=functions[function_name].__doc__)
+ subparser = subparsers.add_parser(
+ function_name, help=functions[function_name].__doc__
+ )
type_hints = typing.get_type_hints(functions[function_name])
if 'return' in type_hints:
@@ -225,62 +255,73 @@ def run(module):
# Without this, we'd get options like "--foo_bar"
opt = re.sub(r'_', '-', opt)
- if _get_arg_type(th) == bool:
- subparser.add_argument(f"--{opt}", action='store_true')
+ if _get_arg_type(th) is bool:
+ subparser.add_argument(f'--{opt}', action='store_true')
else:
if _is_optional_type(th):
if _is_literal_type(th):
- subparser.add_argument(f"--{opt}",
- choices=list(_get_literal_values(th)),
- default=None)
+ subparser.add_argument(
+ f'--{opt}',
+ choices=list(_get_literal_values(th)),
+ default=None,
+ )
else:
- subparser.add_argument(f"--{opt}",
- type=_get_arg_type(th), default=None)
+ subparser.add_argument(
+ f'--{opt}',
+ type=_get_arg_type(th),
+ default=None,
+ )
else:
if _is_literal_type(th):
- subparser.add_argument(f"--{opt}",
- choices=list(_get_literal_values(th)),
- required=True)
+ subparser.add_argument(
+ f'--{opt}',
+ choices=list(_get_literal_values(th)),
+ required=True,
+ )
else:
- subparser.add_argument(f"--{opt}",
- type=_get_arg_type(th), required=True)
+ subparser.add_argument(
+ f'--{opt}', type=_get_arg_type(th), required=True
+ )
# Get options as a dict rather than a namespace,
# so that we can modify it and pack for passing to functions
args = vars(parser.parse_args())
- if not args["subcommand"]:
- print("Subcommand required!")
+ if not args['subcommand']:
+ print('Subcommand required!')
parser.print_usage()
sys.exit(1)
- function_name = args["subcommand"]
+ function_name = args['subcommand']
func = functions[function_name]
# Remove the subcommand from the arguments,
# it would cause an extra argument error when we pass the dict to a function
- del args["subcommand"]
+ del args['subcommand']
# Show and generate commands must always get the "raw" argument,
# but other commands (clear/reset/restart/add/delete) should not,
# because they produce no output and it makes no sense for them.
- if ("raw" not in args) and _capture_output(function_name):
- args["raw"] = False
+ if ('raw' not in args) and _capture_output(function_name):
+ args['raw'] = False
if _capture_output(function_name):
# Show and generate commands are slightly special:
# they may return human-formatted output
# or a raw dict that we need to serialize in JSON for printing
res = func(**args)
- if not args["raw"]:
+ if not args['raw']:
return res
else:
if not isinstance(res, dict) and not isinstance(res, list):
- raise InternalError(f"Bare literal is not an acceptable raw output, must be a list or an object.\
- The output was:{res}")
+ raise InternalError(
+ f'Bare literal is not an acceptable raw output, must be a list or an object.\
+ The output was:{res}'
+ )
res = decamelize(res)
res = _normalize_field_names(res)
from json import dumps
+
return dumps(res, indent=4)
else:
# Other functions should not return anything,
diff --git a/src/op_mode/mtr.py b/src/op_mode/mtr.py
index baf9672a1..522cbe008 100644
--- a/src/op_mode/mtr.py
+++ b/src/op_mode/mtr.py
@@ -23,155 +23,155 @@ from vyos.utils.network import vrf_list
from vyos.utils.process import call
options = {
- 'report': {
+ 'report-mode': {
'mtr': '{command} --report',
'type': 'noarg',
- 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.'
+ 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.',
},
'report-wide': {
'mtr': '{command} --report-wide',
'type': 'noarg',
- 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.'
+ 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.',
},
'raw': {
'mtr': '{command} --raw',
'type': 'noarg',
- 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.'
+ 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.',
},
'json': {
'mtr': '{command} --json',
'type': 'noarg',
- 'help': 'Use this option to tell mtr to use the JSON output format.'
+ 'help': 'Use this option to tell mtr to use the JSON output format.',
},
'split': {
'mtr': '{command} --split',
'type': 'noarg',
- 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.'
+ 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.',
},
'no-dns': {
'mtr': '{command} --no-dns',
'type': 'noarg',
- 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.'
+ 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.',
},
'show-ips': {
'mtr': '{command} --show-ips {value}',
'type': '<num>',
- 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.'
+ 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.',
},
'ipinfo': {
'mtr': '{command} --ipinfo {value}',
'type': '<num>',
- 'help': 'Displays information about each IP hop.'
+ 'help': 'Displays information about each IP hop.',
},
'aslookup': {
'mtr': '{command} --aslookup',
'type': 'noarg',
- 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.'
+ 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.',
},
'interval': {
'mtr': '{command} --interval {value}',
'type': '<num>',
- 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.'
+ 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.',
},
'report-cycles': {
'mtr': '{command} --report-cycles {value}',
'type': '<num>',
- 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.'
+ 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.',
},
'psize': {
'mtr': '{command} --psize {value}',
'type': '<num>',
- 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.'
+ 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.',
},
'bitpattern': {
'mtr': '{command} --bitpattern {value}',
'type': '<num>',
- 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.'
+ 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.',
},
'gracetime': {
'mtr': '{command} --gracetime {value}',
'type': '<num>',
- 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.'
+ 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.',
},
'tos': {
'mtr': '{command} --tos {value}',
'type': '<tos>',
- 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.'
+ 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.',
},
'mpls': {
'mtr': '{command} --mpls {value}',
'type': 'noarg',
- 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.'
+ 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.',
},
'interface': {
'mtr': '{command} --interface {value}',
'type': '<interface>',
'helpfunction': interface_list,
- 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.'
+ 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.',
},
'address': {
'mtr': '{command} --address {value}',
'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>',
- 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.'
+ 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.',
},
'first-ttl': {
'mtr': '{command} --first-ttl {value}',
'type': '<num>',
- 'help': 'Specifies with what TTL to start. Defaults to 1.'
+ 'help': 'Specifies with what TTL to start. Defaults to 1.',
},
'max-ttl': {
'mtr': '{command} --max-ttl {value}',
'type': '<num>',
- 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.'
+ 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.',
},
'max-unknown': {
'mtr': '{command} --max-unknown {value}',
'type': '<num>',
- 'help': 'Specifies the maximum unknown host. Default is 5.'
+ 'help': 'Specifies the maximum unknown host. Default is 5.',
},
'udp': {
'mtr': '{command} --udp',
'type': 'noarg',
- 'help': 'Use UDP datagrams instead of ICMP ECHO.'
+ 'help': 'Use UDP datagrams instead of ICMP ECHO.',
},
'tcp': {
'mtr': '{command} --tcp',
'type': 'noarg',
- 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.'
+ 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.',
},
'sctp': {
'mtr': '{command} --sctp',
'type': 'noarg',
- 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.'
+ 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.',
},
'port': {
'mtr': '{command} --port {value}',
'type': '<port>',
- 'help': 'The target port number for TCP/SCTP/UDP traces.'
+ 'help': 'The target port number for TCP/SCTP/UDP traces.',
},
'localport': {
'mtr': '{command} --localport {value}',
'type': '<port>',
- 'help': 'The source port number for UDP traces.'
+ 'help': 'The source port number for UDP traces.',
},
'timeout': {
'mtr': '{command} --timeout {value}',
'type': '<num>',
- 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.'
+ 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.',
},
'mark': {
'mtr': '{command} --mark {value}',
'type': '<num>',
- 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.'
+ 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.',
},
'vrf': {
'mtr': 'sudo ip vrf exec {value} {command}',
'type': '<vrf>',
'help': 'Use specified VRF table',
'helpfunction': vrf_list,
- 'dflt': 'default'
- }
- }
+ 'dflt': 'default',
+ },
+}
mtr = {
4: '/bin/mtr -4',
@@ -204,8 +204,8 @@ def completion_failure(option: str) -> None:
def expension_failure(option, completions):
reason = 'Ambiguous' if completions else 'Invalid'
sys.stderr.write(
- '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
- option))
+ '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), option)
+ )
if completions:
sys.stderr.write(' Possible completions:\n ')
sys.stderr.write('\n '.join(completions))
@@ -229,13 +229,13 @@ def convert(command, args):
if longname == 'json':
to_json = True
if options[longname]['type'] == 'noarg':
- command = options[longname]['mtr'].format(
- command=command, value='')
+ command = options[longname]['mtr'].format(command=command, value='')
elif not args:
sys.exit(f'mtr: missing argument for {longname} option')
else:
command = options[longname]['mtr'].format(
- command=command, value=args.first())
+ command=command, value=args.first()
+ )
return command, to_json
@@ -244,7 +244,7 @@ if __name__ == '__main__':
host = args.first()
if not host:
- sys.exit("mtr: Missing host")
+ sys.exit('mtr: Missing host')
if host == '--get-options' or host == '--get-options-nested':
if host == '--get-options-nested':
diff --git a/src/op_mode/mtr_execute.py b/src/op_mode/mtr_execute.py
new file mode 100644
index 000000000..2585a7ee4
--- /dev/null
+++ b/src/op_mode/mtr_execute.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import ipaddress
+import socket
+import sys
+import typing
+
+from json import loads
+
+from vyos.utils.network import interface_list
+from vyos.utils.network import vrf_list
+from vyos.utils.process import cmd
+from vyos.utils.process import call
+
+import vyos.opmode
+
+ArgProtocol = typing.Literal['tcp', 'udp', 'sctp']
+noargs_list = [
+ 'report_mode',
+ 'json',
+ 'report_wide',
+ 'split',
+ 'raw',
+ 'no_dns',
+ 'aslookup',
+]
+
+
+def vrf_list_default():
+ return vrf_list() + ['default']
+
+
+options = {
+ 'report_mode': {
+ 'mtr': '{command} --report',
+ },
+ 'protocol': {
+ 'mtr': '{command} --{value}',
+ },
+ 'json': {
+ 'mtr': '{command} --json',
+ },
+ 'report_wide': {
+ 'mtr': '{command} --report-wide',
+ },
+ 'raw': {
+ 'mtr': '{command} --raw',
+ },
+ 'split': {
+ 'mtr': '{command} --split',
+ },
+ 'no_dns': {
+ 'mtr': '{command} --no-dns',
+ },
+ 'show_ips': {
+ 'mtr': '{command} --show-ips {value}',
+ },
+ 'ipinfo': {
+ 'mtr': '{command} --ipinfo {value}',
+ },
+ 'aslookup': {
+ 'mtr': '{command} --aslookup',
+ },
+ 'interval': {
+ 'mtr': '{command} --interval {value}',
+ },
+ 'report_cycles': {
+ 'mtr': '{command} --report-cycles {value}',
+ },
+ 'psize': {
+ 'mtr': '{command} --psize {value}',
+ },
+ 'bitpattern': {
+ 'mtr': '{command} --bitpattern {value}',
+ },
+ 'gracetime': {
+ 'mtr': '{command} --gracetime {value}',
+ },
+ 'tos': {
+ 'mtr': '{command} --tos {value}',
+ },
+ 'mpls': {
+ 'mtr': '{command} --mpls {value}',
+ },
+ 'interface': {
+ 'mtr': '{command} --interface {value}',
+ 'helpfunction': interface_list,
+ },
+ 'address': {
+ 'mtr': '{command} --address {value}',
+ },
+ 'first_ttl': {
+ 'mtr': '{command} --first-ttl {value}',
+ },
+ 'max_ttl': {
+ 'mtr': '{command} --max-ttl {value}',
+ },
+ 'max_unknown': {
+ 'mtr': '{command} --max-unknown {value}',
+ },
+ 'port': {
+ 'mtr': '{command} --port {value}',
+ },
+ 'localport': {
+ 'mtr': '{command} --localport {value}',
+ },
+ 'timeout': {
+ 'mtr': '{command} --timeout {value}',
+ },
+ 'mark': {
+ 'mtr': '{command} --mark {value}',
+ },
+ 'vrf': {
+ 'mtr': 'sudo ip vrf exec {value} {command}',
+ 'helpfunction': vrf_list_default,
+ 'dflt': 'default',
+ },
+}
+
+mtr_command = {
+ 4: '/bin/mtr -4',
+ 6: '/bin/mtr -6',
+}
+
+
+def mtr(
+ host: str,
+ for_api: typing.Optional[bool],
+ report_mode: typing.Optional[bool],
+ protocol: typing.Optional[ArgProtocol],
+ report_wide: typing.Optional[bool],
+ raw: typing.Optional[bool],
+ json: typing.Optional[bool],
+ split: typing.Optional[bool],
+ no_dns: typing.Optional[bool],
+ show_ips: typing.Optional[str],
+ ipinfo: typing.Optional[str],
+ aslookup: typing.Optional[bool],
+ interval: typing.Optional[str],
+ report_cycles: typing.Optional[str],
+ psize: typing.Optional[str],
+ bitpattern: typing.Optional[str],
+ gracetime: typing.Optional[str],
+ tos: typing.Optional[str],
+ mpl: typing.Optional[bool],
+ interface: typing.Optional[str],
+ address: typing.Optional[str],
+ first_ttl: typing.Optional[str],
+ max_ttl: typing.Optional[str],
+ max_unknown: typing.Optional[str],
+ port: typing.Optional[str],
+ localport: typing.Optional[str],
+ timeout: typing.Optional[str],
+ mark: typing.Optional[str],
+ vrf: typing.Optional[str],
+):
+ args = locals()
+ for name, option in options.items():
+ if 'dflt' in option and not args[name]:
+ args[name] = option['dflt']
+
+ try:
+ ip = socket.gethostbyname(host)
+ except UnicodeError:
+ raise vyos.opmode.InternalError(f'Unknown host: {host}')
+ except socket.gaierror:
+ ip = host
+
+ try:
+ version = ipaddress.ip_address(ip).version
+ except ValueError:
+ raise vyos.opmode.InternalError(f'Unknown host: {host}')
+
+ command = mtr_command[version]
+
+ for key, val in args.items():
+ if key in options and val:
+ if 'helpfunction' in options[key]:
+ allowed_values = options[key]['helpfunction']()
+ if val not in allowed_values:
+ raise vyos.opmode.InternalError(
+ f'Invalid argument for option {key} - {val}'
+ )
+ value = '' if key in noargs_list else val
+ command = options[key]['mtr'].format(command=command, value=val)
+
+ if json:
+ output = cmd(f'{command} {host}')
+ if for_api:
+ output = loads(output)
+ print(output)
+ else:
+ call(f'{command} --curses --displaymode 0 {host}')
+
+
+if __name__ == '__main__':
+ try:
+ res = vyos.opmode.run(sys.modules[__name__])
+ if res:
+ print(res)
+ except (ValueError, vyos.opmode.Error) as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py
index 23ae9be9d..27d9fb5ee 100644
--- a/src/services/api/rest/models.py
+++ b/src/services/api/rest/models.py
@@ -279,6 +279,20 @@ class PoweroffModel(ApiModel):
}
+class TracerouteModel(ApiModel):
+ op: StrictStr
+ host: StrictStr
+
+ class Config:
+ schema_extra = {
+ 'example': {
+ 'key': 'id_key',
+ 'op': 'traceroute',
+ 'host': 'host',
+ }
+ }
+
+
class Success(BaseModel):
success: bool
data: Union[str, bool, Dict]
diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py
index 5612e947c..e52c77fda 100644
--- a/src/services/api/rest/routers.py
+++ b/src/services/api/rest/routers.py
@@ -68,6 +68,7 @@ from .models import RebootModel
from .models import ResetModel
from .models import ImportPkiModel
from .models import PoweroffModel
+from .models import TracerouteModel
if TYPE_CHECKING:
@@ -209,6 +210,7 @@ class MultipartRequest(Request):
'/container-image',
'/image',
'/configure-section',
+ '/traceroute',
):
if 'path' not in c:
self.form_err = (
@@ -742,6 +744,28 @@ def poweroff_op(data: PoweroffModel):
return success(res)
+@router.post('/traceroute')
+def traceroute_op(data: TracerouteModel):
+ state = SessionState()
+ session = state.session
+
+ op = data.op
+ host = data.host
+
+ try:
+ if op == 'traceroute':
+ res = session.traceroute(host)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, 'An internal error occurred. Check the logs for details.')
+
+ return success(res)
+
+
def rest_init(app: 'FastAPI'):
if all(r in app.routes for r in router.routes):
return