# Functions to read and access the zeekctl configuration. import configparser import hashlib import os import re import socket import subprocess import sys from ZeekControl import node as node_mod from ZeekControl import options from ZeekControl.exceptions import ConfigurationError, RuntimeEnvironmentError from .state import SqliteState from .version import VERSION # Class storing the zeekctl configuration. # # This class provides access to four types of configuration/state: # # - the global zeekctl configuration from zeekctl.cfg # - the node configuration from node.cfg # - dynamic state variables which are kept across restarts in spool/state.db Config = None # Globally accessible instance of Configuration. class NodeStore: def __init__(self): self.nodestore = {} self.nodenameslower = [] def add_node(self, node): # Add a node to the nodestore, but first check for duplicate node # names. This check is not case-sensitive, because names are stored # lowercase in the state db, and some filesystems are not # case-sensitive (working dir name is node name). # Duplicate node names can occur either because the user defined two # nodes that differ only by case (e.g. "Worker-1" and "worker-1"), or # if a user defines a node name that conflicts with an auto-generated # one (e.g. "worker-1" with lb_procs=2 and "worker-1-2"). namelower = node.name.lower() if namelower in self.nodenameslower: matchname = "" for nn in self.nodestore: if nn.lower() == namelower: matchname = nn break raise ConfigurationError( f'node name "{node.name}" is a duplicate of "{matchname}"' ) self.nodestore[node.name] = node self.nodenameslower.append(namelower) class Configuration: def __init__( self, basedir, libdir, libdirinternal, cfgfile, zeekscriptdir, ui, state=None ): self.ui = ui self.basedir = basedir self.libdir = libdir self.libdirinternal = libdirinternal self.cfgfile = cfgfile self.zeekscriptdir = zeekscriptdir global Config Config = self self.config = {} self.state = {} self.nodestore = {} self.localaddrs = self._get_local_addrs() # Read zeekctl.cfg. self.config = self._read_config(cfgfile) self._initialize_options() self._check_options() if state: self.state_store = state else: self.state_store = SqliteState(self.statefile) self.read_state() self._update_cfg_state() def reload_cfg(self): self.config = self._read_config(self.cfgfile) self._initialize_options() self._check_options() self._update_cfg_state() def _initialize_options(self): from ZeekControl import execute # Set defaults for options we get passed in. self.init_option("zeekbase", self.basedir) self.init_option("zeekscriptdir", self.zeekscriptdir) self.init_option("version", VERSION) self.init_option("libdir", self.libdir) self.init_option("libdirinternal", self.libdirinternal) # Initialize options that are not already set. errors = False for opt in options.options: if opt.dontinit: continue if opt.legacy_name: old_key = opt.legacy_name.lower() if old_key in self.config: self.ui.error( f"option '{opt.legacy_name}' is no longer supported, please use '{opt.name}' instead" ) errors = True continue self.init_option(opt.name, opt.default) if errors: sys.exit(1) # Set defaults for options we derive dynamically. self.init_option("mailto", "{}".format(os.getenv("USER"))) self.init_option("mailfrom", f"Zeek ") self.init_option("mailalarmsto", self.config["mailto"]) # Determine operating system. success, output = execute.run_localcmd("uname") if not success or not output: raise RuntimeEnvironmentError(f"failed to run uname: {output}") self.init_option("os", output.strip()) # Determine the CPU pinning command. pin_cmd = "" if self.config["os"] == "Linux": pin_cmd = "taskset -c" elif self.config["os"] == "FreeBSD": pin_cmd = "cpuset -l" self.init_option("pin_command", pin_cmd) # Find the time command (should be a GNU time for best results). time_cmd = "" success, output = execute.run_localcmd("which time") if success and output: # On redhat-based systems, path to cmd is prefixed with '\t' on 2nd # line when alias is defined. time_cmd = output.splitlines()[-1].strip() self.init_option("time", time_cmd) # Calculate the log expire interval (in minutes). minutes = self._get_interval_minutes("logexpireinterval") self.init_option("logexpireminutes", minutes) # Do a basic sanity check on zeekctl options. def _check_options(self): # Option names must be valid bash variable names because we will # write them to zeekctl-config.sh (note that zeekctl will convert "." # to "_" when it writes to zeekctl-config.sh). allowedchars = re.compile("^[a-z0-9_.]+$") nostartdigit = re.compile("^[^0-9]") for key, value in self.config.items(): if re.match(allowedchars, key) is None: raise ConfigurationError( f'zeekctl option name "{key}" contains invalid characters (allowed characters: a-z, 0-9, ., and _)' ) if re.match(nostartdigit, key) is None: raise ConfigurationError( f'zeekctl option name "{key}" cannot start with a number' ) # No zeekctl option ever requires the entire value to be wrapped in # quotes, and since doing so can cause problems, we don't allow it. if isinstance(value, str): if ( value.startswith('"') and value.endswith('"') or value.startswith("'") and value.endswith("'") ): raise ConfigurationError( f'value of zeekctl option "{key}" cannot be wrapped in quotes' ) dirs = ( "zeekbase", "logdir", "spooldir", "cfgdir", "zeekscriptdir", "bindir", "libdirinternal", "plugindir", "scriptsdir", ) files = ("makearchivename",) for d in dirs: v = self.config[d] if not os.path.isdir(v): raise ConfigurationError( f'zeekctl option "{d}" directory not found: {v}' ) for f in files: v = self.config[f] if not os.path.isfile(v): raise ConfigurationError(f'zeekctl option "{f}" file not found: {v}') # Verify that logs don't expire more quickly than the rotation interval logexpireseconds = 60 * self.config["logexpireminutes"] if 0 < logexpireseconds < self.config["logrotationinterval"]: raise ConfigurationError( "Log expire interval cannot be shorter than the log rotation interval" ) # Convert a time interval string (from the value of the given option name) # to an integer number of minutes. def _get_interval_minutes(self, optname): # Conversion table for time units to minutes. units = {"day": 24 * 60, "hr": 60, "min": 1} ss = self.config[optname] try: # If no time unit, assume it's days (for backward compatibility). v = int(ss) * units["day"] return v except ValueError: pass # Time interval is a non-negative integer followed by an optional # space, followed by a time unit. mm = re.match("([0-9]+) ?(day|hr|min)s?$", ss) if mm is None: raise ConfigurationError( f'value of zeekctl option "{optname}" is invalid (value must be integer followed by a time unit "day", "hr", or "min"): {ss}' ) v = int(mm.group(1)) v *= units[mm.group(2)] return v def initPostPlugins(self): # Read node.cfg self.nodestore = self._read_nodes() # If "env_vars" was specified in zeekctl.cfg, then apply to all nodes. varlist = self.config.get("env_vars") if varlist: try: global_env_vars = self._get_env_var_dict(varlist) except ConfigurationError as err: raise ConfigurationError(f"zeekctl config: {err}") for node in self.nodes(): for key, val in global_env_vars.items(): # Values from node.cfg take precedence over zeekctl.cfg node.env_vars.setdefault(key, val) # Check state store for any running nodes that are no longer in the # current node config. self._warn_dangling_zeek() # Set the standalone config option. standalone = len(self.nodestore) == 1 self.init_option("standalone", standalone) # Provides access to the configuration options via the dereference operator. # Lookup the attribute in zeekctl options first, then in the dynamic state # variables. def __getattr__(self, attr): if attr in self.config: return self.config[attr] if attr in self.state: return self.state[attr] raise AttributeError(f"unknown config attribute {attr}") # Returns a sorted list of all zeekctl.cfg entries. # Includes dynamic variables if dynamic is true. def options(self, dynamic=True): optlist = list(self.config.items()) if dynamic: optlist += list(self.state.items()) optlist.sort() return optlist # Returns a list of Nodes (the list will be empty if no matching nodes # are found). The returned list is sorted by node type, and by node name # for each type. # - By default (i.e. tag is None), all Nodes are returned. # - If tag is a node group name (e.g. "workers"), all nodes belonging to # that group are returned. # - If tag is the name of a node, then that node is returned. def nodes(self, tag=None): nodetype = node_mod.group_type(tag) if nodetype == "_ALL_": tag = None nodes = [] for n in self.nodestore.values(): if nodetype == n.type or tag == n.name or tag is None: nodes += [n] nodes.sort(key=node_mod.sortnode) return nodes # Returns the manager Node (cluster config) or standalone Node (standalone # config). Returns None if neither are available. def manager(self): if self.config["standalone"]: n = self.nodes() else: n = self.nodes(node_mod.manager_group()) if not n: return None return n[0] def loggers(self): return self.nodes(node_mod.logger_group()) def proxies(self): return self.nodes(node_mod.proxy_group()) def workers(self): return self.nodes(node_mod.worker_group()) # Returns a list of nodes which is a subset of the result a similar call to # nodes() would yield but within which each host appears only once. # If "exclude_local" is True, then the returned list will not include # nodes that are on the local host. def hosts(self, tag=None, exclude_local=False): hosts = {} nodelist = [] for node in self.nodes(tag): if node.host in hosts: continue if exclude_local and node.addr in self.localaddrs: continue hosts[node.host] = 1 nodelist.append(node) return nodelist # Replace all occurences of "${option}", with option being either # zeekctl.cfg option or a dynamic variable, with the corresponding value. # Defaults to replacement with the empty string for unknown options. def subst(self, text): while True: match = re.search(r"(\$\{([A-Za-z][A-Za-z0-9]*)(:([^}]+))?\})", text) if not match: return text key = match.group(2).lower() try: value = str(self.__getattr__(key)) except AttributeError: value = match.group(4) if value is None: value = "" text = text[0 : match.start(1)] + value + text[match.end(1) :] # Convert string into list of integers (ValueError is raised if any # item in the list is not a non-negative integer). def _get_pin_cpu_list(self, text, numprocs): if not text: return [] cpulist = [int(x) for x in text.split(",")] # Minimum allowed CPU number is zero. if min(cpulist) < 0: raise ValueError # Make sure list is at least as long as number of worker processes. cpulen = len(cpulist) if numprocs > cpulen: cpulist = [cpulist[i % cpulen] for i in range(numprocs)] return cpulist # Convert a string consisting of a comma-separated list of environment # variables (e.g. "VAR1=123, VAR2=456") to a dictionary. # If the string is empty, then return an empty dictionary. def _get_env_var_dict(self, text): env_vars = {} if text: for keyval in text.split(","): try: key, val = keyval.split("=", 1) except ValueError: raise ConfigurationError( f"missing '=' in env_vars option value: {keyval}" ) key = key.strip() if not key: raise ConfigurationError( f"env_vars option value must contain at least one environment variable name: {keyval}" ) env_vars[key] = val.strip() return env_vars # Parse node.cfg. def _read_nodes(self): config = configparser.ConfigParser() fname = self.nodecfg try: if not config.read(fname): raise ConfigurationError(f"cannot read node config file: {fname}") except configparser.MissingSectionHeaderError as err: raise ConfigurationError(err) nodestore = NodeStore() counts = {} for sec in config.sections(): node = node_mod.Node(self, sec) # Note that the keys are converted to lowercase by configparser. for key, val in config.items(sec): key = key.replace(".", "_") if key not in node_mod.Node._keys: self.ui.warn( f"ignoring unrecognized node config option '{key}' given for node '{sec}'" ) continue node.__dict__[key] = val # Perform a sanity check on the node, and update nodestore. self._check_node(node, nodestore, counts) # Perform a sanity check on the nodestore (make sure we have a valid # cluster config, etc.). self._check_nodestore(nodestore.nodestore) return nodestore.nodestore def _check_node(self, node, nodestore, counts): if not node.type: raise ConfigurationError(f"no type given for node {node.name}") if node.type not in node_mod.node_types(): raise ConfigurationError( f"unknown node type '{node.type}' given for node '{node.name}'" ) if not node.host: raise ConfigurationError(f"no host given for node '{node.name}'") try: addrinfo = socket.getaddrinfo(node.host, None, 0, 0, socket.SOL_TCP) except socket.gaierror as e: raise ConfigurationError( f"hostname lookup failed for '{node.host}' in node config [{e.args[1]}]" ) addrs = [addr[4][0] for addr in addrinfo] # By default, just use the first IP addr in the list. addr_str = addrs[0] # Choose the first IPv4 addr (if any) in the list. for ip in addrs: if ":" not in ip: addr_str = ip break # zone_id is handled manually, so strip it if it's there node.addr = addr_str.split("%")[0] # Convert env_vars from a string to a dictionary. try: node.env_vars = self._get_env_var_dict(node.env_vars) except ConfigurationError as err: raise ConfigurationError(f"node '{node.name}' config: {err}") # Each node gets a number unique across its type. try: counts[node.type] += 1 except KeyError: counts[node.type] = 1 node.count = counts[node.type] numprocs = 0 if node.lb_procs: if not node_mod.is_worker(node): raise ConfigurationError( f"node '{node.name}' config: load balancing node config options are only for worker nodes" ) try: numprocs = int(node.lb_procs) except ValueError: raise ConfigurationError( f"number of load-balanced processes must be an integer for node '{node.name}'" ) if numprocs < 1: raise ConfigurationError( f"number of load-balanced processes must be greater than zero for node '{node.name}'" ) elif node.lb_method: raise ConfigurationError( f"number of load-balanced processes not specified for node '{node.name}'" ) try: pin_cpus = self._get_pin_cpu_list(node.pin_cpus, numprocs) except ValueError: raise ConfigurationError( f"pin cpus list must contain only non-negative integers for node '{node.name}'" ) if pin_cpus: node.pin_cpus = pin_cpus[0] if node.lb_procs: if not node.lb_method: raise ConfigurationError( f"no load balancing method given for node '{node.name}'" ) if node.lb_method not in ( "af_packet", "pf_ring", "myricom", "custom", "interfaces", ): raise ConfigurationError( f"unknown load balancing method '{node.lb_method}' given for node '{node.name}'" ) if node.lb_method == "interfaces": if not node.lb_interfaces: raise ConfigurationError( f"list of load-balanced interfaces not specified for node '{node.name}'" ) # get list of interfaces to use, and assign one to each node netifs = node.lb_interfaces.split(",") if len(netifs) != numprocs: raise ConfigurationError( f"number of load-balanced interfaces is not same as number of load-balanced processes for node '{node.name}'" ) node.interface = netifs.pop().strip() origname = node.name # node names will have a numerical suffix node.name = f"{node.name}-1" for num in range(2, numprocs + 1): newnode = node.copy() # Update the node attrs that need to be changed newname = f"{origname}-{num:d}" newnode.name = newname counts[node.type] += 1 newnode.count = counts[node.type] if pin_cpus: newnode.pin_cpus = pin_cpus[num - 1] if newnode.lb_method == "interfaces": newnode.interface = netifs.pop().strip() nodestore.add_node(newnode) nodestore.add_node(node) def _check_nodestore(self, nodestore): if not nodestore: raise ConfigurationError("no nodes found in node config") standalone = False manager = False proxy = False manageronlocalhost = False # Note: this is a subset of localaddrs localhostaddrs = "127.0.0.1", "::1" for n in nodestore.values(): if node_mod.is_manager(n): if manager: raise ConfigurationError( "only one manager can be defined in node config" ) manager = True if n.addr in localhostaddrs: manageronlocalhost = True if n.addr not in self.localaddrs: raise ConfigurationError( "must run zeekctl on same machine as the manager node. The manager node has IP address {} and this machine has IP addresses: {}".format( n.addr, ", ".join(self.localaddrs) ) ) elif node_mod.is_proxy(n): proxy = True elif node_mod.is_standalone(n): standalone = True if n.addr not in self.localaddrs: raise ConfigurationError( "must run zeekctl on same machine as the standalone node. The standalone node has IP address {} and this machine has IP addresses: {}".format( n.addr, ", ".join(self.localaddrs) ) ) if standalone: if len(nodestore) > 1: raise ConfigurationError( "more than one node defined in standalone node config" ) else: if not manager: raise ConfigurationError("no manager defined in node config") elif not proxy: raise ConfigurationError("no proxy defined in node config") # If manager is on localhost, then all other nodes must be on localhost if manageronlocalhost: for n in nodestore.values(): if not node_mod.is_manager(n) and n.addr not in localhostaddrs: raise ConfigurationError( "all nodes must use localhost/127.0.0.1/::1 when manager uses it" ) def _to_bool(self, val): if val.lower() in ("1", "true"): return True if val.lower() in ("0", "false"): return False raise ValueError(f"invalid boolean: '{val}'") # Parses zeekctl.cfg and returns a dictionary of all entries. def _read_config(self, fname): type_converters = {"bool": self._to_bool, "int": int, "string": str} config = {} opt_names = set() for opt in options.options: # Convert key to lowercase because keys are stored in lowercase. key = opt.name.lower() opt_names.add(key) if opt.legacy_name: opt_names.add(opt.legacy_name.lower()) with open(fname) as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue args = line.split("=", 1) if len(args) != 2: raise ConfigurationError(f"zeekctl config syntax error: {line}") key, val = args # Option names are not case-sensitive. key = key.strip().lower() # Warn about unrecognized options, but we can't check plugin # options here because no plugins have been loaded yet. if "." not in key and key not in opt_names: self.ui.warn(f"ignoring unrecognized zeekctl option: {key}") continue # if the key already exists, just overwrite with new value config[key] = val.strip() # Convert option values to correct data type for opt in options.options: # Convert key to lowercase because keys are stored in lowercase. key = opt.name.lower() if key in config: try: config[key] = type_converters[opt.type](config[key]) except ValueError: raise ConfigurationError( f"zeekctl option '{key}' has invalid value '{config[key]}' for type {opt.type}" ) return config # Initialize a global option if not already set. def init_option(self, key, val): # Store option names in lowercase, because they are not case-sensitive. key = key.lower() if key not in self.config: if isinstance(val, str): self.config[key] = self.subst(val) else: self.config[key] = val # Set a global option (regardless of whether or not it is already set). def set_option(self, key, val): # Store option names in lowercase, because they are not case-sensitive. key = key.lower() self.config[key] = val # Returns value of an option, or None if the option is not defined. def get_option(self, key): # Convert key to lowercase because keys are stored in lowercase. return self.config.get(key.lower()) # Set a dynamic state variable. def set_state(self, key, val): key = key.lower() if self.state.get(key) == val: return self.state[key] = val self.state_store.set(key, val) # Returns value of state variable, or the specified default value if the # state variable is not defined. def get_state(self, key, default=None): return self.state.get(key.lower(), default) # Read dynamic state variables. def read_state(self): self.state = dict(self.state_store.items()) # Use the ifconfig command to find local IP addrs. def _get_local_addrs_ifconfig(self): try: # On Linux, ifconfig is often not in the user's standard PATH. # Also need to set LANG here to ensure that the output of ifconfig # is consistent regardless of which locale the system is using. proc = subprocess.Popen( ["PATH=$PATH:/sbin:/usr/sbin LANG=C ifconfig", "-a"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, ) out, err = proc.communicate() except OSError: return False success = proc.returncode == 0 if not success: return False localaddrs = [] out = out.decode() # The output of ifconfig varies by OS and by IPv4 vs IPv6. # Linux example: # inet addr:127.0.0.1 # inet6 addr: ::1/128 # BSD (and OS X) example: # inet 127.0.0.1 # inet6 ::1 # inet6 fe80::1%lo0 for line in out.splitlines(): fields = line.split() if len(fields) < 3: continue if fields[0] != "inet" and fields[0] != "inet6": continue addrstr = fields[1] if addrstr[-1] == ":" and addrstr.count(":") == 1: addrstr = fields[2] if addrstr.count(":") == 1: # Remove "addr:" prefix (if any). addrstr = addrstr.split(":")[1] # Remove everything after "/" or "%" (if any) addrstr = addrstr.split("/")[0] addrstr = addrstr.split("%")[0] if _is_valid_addr(addrstr): localaddrs.append(addrstr) if not localaddrs: self.ui.warn( 'failed to extract IP addresses from the "ifconfig -a" command output' ) return localaddrs # Use the ip command to find local IP addrs. def _get_local_addrs_ip(self): try: # On Linux, "ip" is sometimes not in the user's standard PATH. proc = subprocess.Popen( ["PATH=$PATH:/sbin:/usr/sbin ip address"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, ) out, err = proc.communicate() except OSError: return False success = proc.returncode == 0 if not success: return False localaddrs = [] out = out.decode() # Here is an example portion of "ip" command output: # inet 127.0.0.1/8 # inet6 ::1/128 for line in out.splitlines(): fields = line.split() if len(fields) < 2: continue if fields[0] != "inet" and fields[0] != "inet6": continue addrstr = fields[1] addrstr = addrstr.split("/")[0] addrstr = addrstr.split("%")[0] if _is_valid_addr(addrstr): localaddrs.append(addrstr) if not localaddrs: self.ui.warn( 'failed to extract IP addresses from the "ip address" command output' ) return localaddrs # Return a list of the IP addresses associated with local interfaces. # For IPv6 addresses, zone_id and prefix length are removed if present. def _get_local_addrs(self): # ifconfig is more portable so try it first localaddrs = self._get_local_addrs_ifconfig() if not localaddrs: # On some Linux systems ifconfig has been superseded by ip. localaddrs = self._get_local_addrs_ip() # Fallback to localhost if we did not find any IP addrs. if not localaddrs: self.ui.warn( 'failed to find local IP addresses with "ifconfig -a" or "ip address" commands' ) localaddrs = ["127.0.0.1", "::1"] try: addrinfo = socket.getaddrinfo( socket.gethostname(), None, 0, 0, socket.SOL_TCP ) except Exception: addrinfo = [] for ai in addrinfo: localaddrs.append(ai[4][0]) return localaddrs # Record the Zeek version. def record_zeek_version(self): version = self._get_zeek_version() self.set_state("zeekversion", version) # Record the state of the zeekctl config files. def _update_cfg_state(self): self.set_state("configchksum", self._get_zeekctlcfg_hash(filehash=True)) self.set_state("confignodechksum", self._get_nodecfg_hash(filehash=True)) # Returns True if the zeekctl config files have changed since last reload. def is_cfg_changed(self): try: if "configchksum" in self.state: if self.state["configchksum"] != self._get_zeekctlcfg_hash( filehash=True ): return True if "confignodechksum" in self.state: if self.state["confignodechksum"] != self._get_nodecfg_hash( filehash=True ): return True except OSError: # If we can't read the config files, then do nothing. pass return False # Check if the user has already run the "install" or "deploy" commands. def is_zeekctl_installed(self): return os.path.isfile( os.path.join(self.config["policydirsiteinstallauto"], "zeekctl-config.zeek") ) # Warn user to run zeekctl deploy if any changes are detected to zeekctl # config options, node config, Zeek version, or if certain state variables # are missing. def warn_zeekctl_install(self): missingstate = False # Check if node config has changed since last install. if "hash-nodecfg" in self.state: nodehash = self._get_nodecfg_hash() if nodehash != self.state["hash-nodecfg"]: self.ui.warn( 'zeekctl node config has changed (run the zeekctl "deploy" command)' ) return else: missingstate = True # If this is a fresh install (i.e., zeekctl install not yet run), then # inform the user what to do. if not self.is_zeekctl_installed(): self.ui.info('Hint: Run the zeekctl "deploy" command to get started.') return # Check if Zeek version is different from the previously-installed # version. if "zeekversion" in self.state: oldversion = self.state["zeekversion"] version = self._get_zeek_version() if version != oldversion: self.ui.warn( 'new zeek version detected (run the zeekctl "deploy" command)' ) return else: missingstate = True # Check if any config values have changed since last install. if "hash-zeekctlcfg" in self.state: cfghash = self._get_zeekctlcfg_hash() if cfghash != self.state["hash-zeekctlcfg"]: self.ui.warn( 'zeekctl config has changed (run the zeekctl "deploy" command)' ) return else: missingstate = True # If any of the state variables don't exist, then we need to install # (this would most likely indicate an upgrade install was performed # over an old version that didn't have the state.db file). if missingstate: self.ui.warn( 'state database needs updating (run the zeekctl "deploy" command)' ) return # Warn if there might be any dangling Zeek nodes (i.e., nodes that are # still running but are either no longer part of the current node # configuration or have moved to a new host). def _warn_dangling_zeek(self): nodes = {} for n in self.nodes(): # Convert node name to lowercase because below we are using # node names from the state db (which is lowercase). nodes[n.name.lower()] = n.host for key in self.state.keys(): # Look for a PID associated with a Zeek node if not key.endswith("-pid"): continue pid = self.get_state(key) if not pid: continue # Get node name and host name for this node nname = key[:-4] hostkey = key.replace("-pid", "-host") hname = self.get_state(hostkey) if not hname: continue # If node is not a known node or if host has changed, then # we must warn about dangling Zeek node. if nname not in nodes or hname != nodes[nname]: self.ui.warn( f'Zeek node "{nname}" possibly still running on host "{hname}" (PID {pid})' ) # Set the "expected running" flag to False so cron doesn't try # to start this node. expectkey = key.replace("-pid", "-expect-running") self.set_state(expectkey, False) # Clear the PID so we don't keep getting warnings. self.set_state(key, None) # Return a hash value (as a string) of the current zeekctl configuration. def _get_zeekctlcfg_hash(self, filehash=False): if filehash: with open(self.cfgfile) as ff: data = ff.read() else: data = str(sorted(self.config.items())) data = data.encode() hh = hashlib.sha1() hh.update(data) return hh.hexdigest() # Return a hash value (as a string) of the current zeekctl node config. def _get_nodecfg_hash(self, filehash=False): if filehash: with open(self.nodecfg) as ff: data = ff.read() else: nn = [] for n in self.nodes(): nn.append( tuple( [ (key, val) for key, val in n.items() if not key.startswith("_") ] ) ) data = str(nn) data = data.encode() hh = hashlib.sha1() hh.update(data) return hh.hexdigest() # Update the stored hash value of the current zeekctl config. def update_cfg_hash(self): cfghash = self._get_zeekctlcfg_hash() nodehash = self._get_nodecfg_hash() self.set_state("hash-zeekctlcfg", cfghash) self.set_state("hash-nodecfg", nodehash) # Runs Zeek to get its version number. def _get_zeek_version(self): from ZeekControl import execute zeek = self.config["zeek"] if not os.path.lexists(zeek): raise ConfigurationError(f"cannot find Zeek binary: {zeek}") version = "" success, output = execute.run_localcmd(f"{zeek} -v") if success and output: version = output.splitlines()[-1] else: msg = " with no output" if output: msg = f" with output:\n{output}" raise RuntimeEnvironmentError(f'running "zeek -v" failed{msg}') match = re.search(".* version ([^ ]*).*$", version) if not match: raise RuntimeEnvironmentError( f'cannot determine Zeek version ("zeek -v" output: {version.strip()})' ) version = match.group(1) # If zeek is built with the "--enable-debug" configure option, then it # appends "-debug" to the version string. if version.endswith("-debug"): version = version[:-6] return version # Check if a string is a valid representation of an IP address or not. def _is_valid_addr(ipstr): try: if ":" in ipstr: socket.inet_pton(socket.AF_INET6, ipstr) else: socket.inet_pton(socket.AF_INET, ipstr) except OSError: return False return True