ospf_sniffer_configurator.py 12.8 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
#!/usr/bin/env python3

# Copyright (C) 2018 Simon Redman <sredman@cs.utah.edu>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import getpass
from netdiff import NetJsonParser
import networkx
import re
from typing import List, Optional

import ssh_helper

27
DEFAULT_CLONE_PATH="/tmp/sniffer/"
28
DEFAULT_CONTROLLER='core0'
29
DEFAULT_CONTROLLER_PORT = 8080 # While this could theoretically be any port, the SDN controller does not accept it as a parameter...
30
DEFAULT_EXECUTABLE='main.py'
31
DEFAULT_LOGFILE="/tmp/sniffer.log"
32 33 34 35 36
DEFAULT_PIDFILE="/tmp/sniffer.pid"
DEFAULT_REPO_BRANCH='release'
DEFAULT_REPO_URL="https://gitlab.flux.utah.edu/safeedge/ospfv3_monitor.git"


37
def _generate_repo_clone_command(repo: str, branch: str, path: str) -> str:
38 39 40 41 42
    return "git clone -b {branch} --single-branch -- {repo} {path};".format(repo=repo, branch=branch, path=path)


def _generate_environment_create_command(path:str) -> str:
    return "pushd {path} && " \
43
           "virtualenv -p /usr/bin/python2 env; " \
44 45 46 47
           "source env/bin/activate && " \
           "pip install -r requirements.txt && " \
           "deactivate && " \
           "popd".format(path=path)
48 49


50
def _generate_repo_update_command(path: str) -> str:
51 52 53
    return "pushd {path}; git pull; popd".format(path=path)


54 55 56
def _generate_start_command(path:str=DEFAULT_CLONE_PATH,
                            executable: str=DEFAULT_EXECUTABLE,
                            controller: str=DEFAULT_CONTROLLER,
57
                            port: int=DEFAULT_CONTROLLER_PORT,
58 59 60
                            logfile: str=DEFAULT_LOGFILE,
                            pidfile: str=DEFAULT_PIDFILE,
                            ) -> str:
61
    """
Simon Redman's avatar
Simon Redman committed
62
    Generate a command which launches the OSPF sniffer
63 64 65

    Does not do any initialization, such as downloading the sniffer

66
    :param path:       Path into which the sniffer was cloned
67 68
    :param executable: Filename of the sniffer daemon from the repository root
    :param controller: Hostname or IP address to which sniffer reports should be sent
69
    :param port:       Port on the server listening for OSPF updates
70 71
    :param logfile:    File to which output from the sniffer should be written
    :param pidfile:    File to which the PID will be written
72 73
    :return:
    """
74
    return "pushd {path}; source env/bin/activate; sudo $(which python) {executable} --controller={controller} --port={port} --log-file={logfile} & echo $! > {pidfile} && disown; deactivate; popd".format(
75 76 77
        path=path,
        executable=executable,
        controller=controller,
78
        port=port,
79
        logfile=logfile,
80 81 82
        pidfile=pidfile,
    )

83
def _generate_stop_command(pidfile: str=DEFAULT_PIDFILE) -> str:
84
    return "sudo kill $(cat {pidfile}); rm -f {pidfile}".format(pidfile=pidfile)
Simon Redman's avatar
Simon Redman committed
85

86 87 88 89 90 91

def clone_repo_on_network(graph: networkx.Graph,
                          repo: str=DEFAULT_REPO_URL,
                          branch: str=DEFAULT_REPO_BRANCH,
                          path: str=DEFAULT_CLONE_PATH,
                          ignore_nodes: List[str]=None,
92
                          ):
93 94 95
    """
    Clone the specified repository and branch on each non-ignored node in the network

Simon Redman's avatar
Simon Redman committed
96 97
    If the passed path is already in use, attempts to update the repository in that directory

98 99 100
    :param graph:  networkx.Graph representation of the network
    :param repo:   Repository to clone
    :param branch: Branch in the repository to clone
Simon Redman's avatar
Simon Redman committed
101
    :param path:   Directory into which to clone the repository
102 103 104
    :param ignore_nodes: Nodes in the network which should not have the repository cloned
    :return:
    """
Simon Redman's avatar
Simon Redman committed
105
    if ignore_nodes is None: ignore_nodes = []
106
    command = _generate_repo_clone_command(repo=repo, branch=branch, path=path)
107 108 109 110 111
    hosts = [host for host in graph.nodes if host not in ignore_nodes]

    commands = [command for host in hosts]
    sessions = [graph._node[host]['session'] for host in hosts]

112 113 114
    # If we try to clone into an existing directory, git will return an error
    # (Obviously this will get messed up on a non-English system. Sorry.)
    allowed_exception = ".*fatal: destination path \S+ already exists and is not an empty directory"
115
    sessions_needing_updating = []
116 117 118 119 120 121 122
    try:
        outputs = ssh_helper.run_commands_on_many_hosts(sessions, commands)
    except ssh_helper.SSHCommandErrorError as e:
        while e is not None:
            output = str.join('', e.output.splitlines()) # Get rid of linebreaks (otherwise we get one every 80 characters)
            if re.match(allowed_exception, output):
                # No problem: Hopefully we tried to re-clone the repository
123
                sessions_needing_updating.append(e.session)
124 125 126
                e = e.next
            else:
                raise
127

128
    # Update anything which needed updating
129
    commands = [_generate_repo_update_command(path)] * len(sessions_needing_updating)
130
    outputs = ssh_helper.run_commands_on_many_hosts(sessions_needing_updating, commands)
131 132 133 134

    # Setup the virtual environment
    commands = [_generate_environment_create_command(path)] * len(sessions)
    outputs = ssh_helper.run_commands_on_many_hosts(sessions, commands)
135 136
    pass

137 138 139 140 141

def start_sniffer_on_network(graph: networkx.Graph,
                             path:str=DEFAULT_CLONE_PATH,
                             executable: str=DEFAULT_EXECUTABLE,
                             controller: str=DEFAULT_CONTROLLER,
142
                             port: int=DEFAULT_CONTROLLER_PORT,
143
                             pidfile: str=DEFAULT_PIDFILE,
144
                             ignore_nodes: List[str]=None):
145 146 147 148 149 150 151
    """
    Start the sniffer daemon on all non-ignored nodes in the network

    :param graph:      networkx.Graph representation of the network
    :param path:       path into which the repository has been cloned
    :param executable: executable to execute
    :param controller: node which is listening for the OSPF reports
152
    :param port:       port on the controller listening for OSPF updates
153 154 155 156
    :param pidfile:    file to write the PID of the daemon
    :param ignore_nodes: nodes which should not have the daemon run on them
    :return:
    """
Simon Redman's avatar
Simon Redman committed
157
    if ignore_nodes is None: ignore_nodes = []
158
    command = _generate_start_command(path=path, executable=executable, controller=controller, port=port, pidfile=pidfile)
159 160 161 162 163
    hosts = [host for host in graph.nodes if host not in ignore_nodes]

    commands = [command for host in hosts]
    sessions = [graph._node[host]['session'] for host in hosts]

164
    # Since this command puts the sniffer in the background, the naive default error checking does not work
165 166 167
    # First the command is echoed
    echos = ssh_helper.unchecked_run_commands_on_many_hosts(sessions, commands)
    # Next we check for potential outputs
168
    outputs = list(map(lambda s: ssh_helper._get_output(s, timeout=1), sessions))
169 170 171 172 173 174 175 176 177 178 179

    # Some real basic error checking
    errors = None
    for index in range(0, len(outputs)):
        # The only good output from a backgrounded script with stdout being redirected to a file is silence
        if not len(outputs[index]) == 0:
            errors = ssh_helper.SSHCommandErrorError(sessions[index],
                                                     outputs[index],
                                                     code=999,
                                                     next=errors)
    if errors is not None: raise errors
180 181


Simon Redman's avatar
Simon Redman committed
182 183 184 185 186 187 188 189 190 191
def stop_sniffer_on_network(graph: networkx.Graph,
                            pidfile: str=DEFAULT_PIDFILE,
                            ignore_nodes: List[str] = None):
    """
    Stop the sniffer on all non-ignored nodes in the network
    """
    if ignore_nodes is None: ignore_nodes = []
    hosts = [host for host in graph.nodes if host not in ignore_nodes]

    sessions = [graph._node[host]['session'] for host in hosts]
192 193
    command = _generate_stop_command(pidfile=pidfile)
    commands = [command] * len(hosts)
Simon Redman's avatar
Simon Redman committed
194

195 196 197
    try:
        output = ssh_helper.run_commands_on_many_hosts(sessions, commands)
    except ssh_helper.SSHCommandErrorError as e:
198 199 200 201 202 203
        # If the PID file does not exist, cat will have an error trying to read it. This is not a problem, since it probably just
        # means the sniffer is not running or has already been stopped
        acceptable_error_cat = r".*{command}\s+cat: {pidfile}: No such file or directory.*".format(command=re.escape(command), pidfile=re.escape(pidfile))

        # If the sniffer cannot be killed, it probably means that it has already died for some reason. Since our goal was to stop it, mission accomplished
        acceptable_error_kill = r".*kill: \([0-9]+\) - No such process.*".format(command=re.escape(command), pidfile=re.escape(pidfile))
204 205
        while e is not None:
            output = str.join(' ', e.output.splitlines()) # Get rid of newlines
206
            if re.match(acceptable_error_cat, output) or re.match(acceptable_error_kill, output):
207 208 209 210
                # Presumably this is a sign of the stop command being run more than once or before the sniffer was started: no problem
                e = e.next
                continue
            raise
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
    pass


if __name__ == "__main__":
    parser = argparse.ArgumentParser("Configure and control the OSPF path sniffer for all nodes in the network")
    parser.add_argument("--in-file", action='store', type=str, required=True,
                        help="Path to the NetJSON file to parse")
    parser.add_argument("--username", action='store', type=str, default=getpass.getuser(),
                        help="Username to use on all hosts. Defaults to current user's username")
    parser.add_argument("--stop", action='store_true',
                        help="Stop all sniffers")
    parser.add_argument("--ignore-regex", action='store', type=str, default='.*ovs.*',
                        help="Regex used to determine which nodes, if any, should be ignored (Default: \".*ovs.*\")")
    parser.add_argument("--pid-file", action='store', type=str, default=DEFAULT_PIDFILE,
                        help="File to write sniffer's PID (Default: {default})".format(default=DEFAULT_PIDFILE))
226 227
    parser.add_argument("--log-file", action='store', type=str, default=DEFAULT_LOGFILE,
                        help="File to write sniffer's output (Default: {default})".format(default=DEFAULT_LOGFILE))
228 229
    parser.add_argument("--controller-name", action='store', type=str, default=DEFAULT_CONTROLLER,
                        help="Hostname or IP of the node which is listening to the OSPF reports (Default: {default})".format(default=DEFAULT_CONTROLLER))
230 231
    parser.add_argument("--controller-port", action='store', type=int, default=DEFAULT_CONTROLLER_PORT,
                        help="Port number on the server listening for OSPF reports (Default: {default})".format(default=DEFAULT_CONTROLLER_PORT))
232 233 234 235 236
    parser.add_argument("--repo-path", action='store', type=str, default=DEFAULT_REPO_URL,
                        help="(git) Repository which should be downloaded for the OSPF sniffer (Default: {default})".format(default=DEFAULT_REPO_URL))
    parser.add_argument("--repo-branch", action='store', type=str, default=DEFAULT_REPO_BRANCH,
                        help="Name of the branch in the repository to check out (Default: {default})".format(default=DEFAULT_REPO_BRANCH))
    parser.add_argument("--clone-path", action='store', type=str, default=DEFAULT_CLONE_PATH,
237
                        help="Directory where the repository should be downloaded (Default: {default})".format(default=DEFAULT_CLONE_PATH))
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255

    args = parser.parse_args()

    netgraph = NetJsonParser(file=args.in_file)
    ssh_helper.network_graph_login(netgraph.graph, args.username)

    ignore_nodes = [node for node in netgraph.graph.nodes
                        if re.match(args.ignore_regex, netgraph.graph._node[node]['label'])]

    if args.stop:
        stop_sniffer_on_network(netgraph.graph)
    else:
        clone_repo_on_network(netgraph.graph,
                              repo=args.repo_path,
                              branch=args.repo_branch,
                              path=args.clone_path,
                              ignore_nodes=ignore_nodes)
        start_sniffer_on_network(netgraph.graph,
256
                                 path=args.clone_path,
257
                                 controller=args.controller_name,
258
                                 port=args.controller_port,
259 260 261 262
                                 pidfile=args.pid_file,
                                 ignore_nodes=ignore_nodes)

    ssh_helper.network_graph_logout(netgraph.graph)