NGINXApiModule

Common NGINX code that will be appended into each NGINX based integration when it's deployed.

python · ApiModules

Source

import os
import subprocess
import traceback
from math import ceil
from multiprocessing import Process
from pathlib import Path
from signal import SIGUSR1
from string import Template
from typing import Any

import demistomock as demisto  # noqa: F401
import gevent
import requests
from CommonServerPython import *  # noqa: F401
from flask.logging import default_handler
from gevent.pywsgi import WSGIServer

from CommonServerUserPython import *


class Handler:
    @staticmethod
    def write(msg: str):
        demisto.info(msg)


class ErrorHandler:
    @staticmethod
    def write(msg: str):
        demisto.error(f"wsgi error: {msg}")


DEMISTO_LOGGER: Handler = Handler()
ERROR_LOGGER: ErrorHandler = ErrorHandler()


# nginx server params
NGINX_SERVER_ACCESS_LOG = "/var/log/nginx/access.log"
NGINX_SERVER_ERROR_LOG = "/var/log/nginx/error.log"
NGINX_SERVER_CONF_FILE = "/etc/nginx/conf.d/default.conf"
NGINX_SSL_KEY_FILE = "/etc/nginx/ssl/ssl.key"
NGINX_SSL_CRT_FILE = "/etc/nginx/ssl/ssl.crt"
NGINX_SSL_CERTS = f"""
    ssl_certificate {NGINX_SSL_CRT_FILE};
    ssl_certificate_key {NGINX_SSL_KEY_FILE};
"""
NGINX_SERVER_CONF = """
server {

    listen $port default_server $ssl;

    $sslcerts

    proxy_cache_key $scheme$proxy_host$request_uri$extra_cache_key;
    $proxy_set_range_header
    $extra_headers
# Thundering-herd protection
proxy_cache_lock on;
proxy_cache_lock_timeout $cache_lock_timeout;
proxy_cache_lock_age $cache_lock_age;

# Cache validity by status
proxy_cache_valid 200 301 302 $cache_refresh_rate;

# Optional: cache other responses briefly (helps absorb spikes)
proxy_cache_valid 404 $cache_404_ttl;
proxy_cache_valid any $cache_default_ttl;

# Revalidation (use conditional requests when expired)
proxy_cache_revalidate on;

# Serve stale content in failure/update scenarios
proxy_cache_use_stale
    updating
    error
    timeout
    invalid_header
    http_500
    http_502
    http_503
    http_504;

# Background refresh of expired cache
proxy_cache_background_update on;

    # Static test file
    location = /nginx-test {
        alias /var/lib/nginx/html/index.html;
        default_type text/html;
    }

    # Proxy everything to python
    location / {
        proxy_pass http://localhost:$serverport/;
        add_header X-Proxy-Cache $upstream_cache_status;
        $extra_headers
        # allow bypassing the cache with an arg of nocache=1 ie http://server:7000/?nocache=1
        proxy_cache_bypass $arg_nocache;
        proxy_read_timeout $timeout;
        proxy_connect_timeout 3600;
        proxy_send_timeout 3600;
        send_timeout 3600;
    }
}

"""
NGINX_MAX_POLLING_TRIES = 5


def create_nginx_server_conf(file_path: str, port: int, params: dict):
    """Create nginx conf file

    Args:
        file_path (str): path of server conf file
        port (int): listening port. server port to proxy to will be port+1
        params (Dict): additional nginx params

    Raises:
        DemistoException: raised if there is a detected config error
    """
    params = params if params else demisto.params()
    template_str = params.get("nginx_server_conf") or NGINX_SERVER_CONF
    certificate: str = params.get("certificate", "")
    private_key: str = params.get("key", "")
    # Normalize all five `cache_*` time params (plus `timeout`) through a single helper so the
    # rendered nginx directives are always a safe `<int>s` token.
    timeout = _normalize_nginx_time(params.get("timeout"), default="3600", param_name="timeout")
    cache_refresh_rate = _normalize_nginx_time(params.get("cache_refresh_rate"), default=timeout, param_name="cache_refresh_rate")

    # Ensure cache lock directives are at least as large as the upstream timeout. Otherwise, when an
    # upstream request takes longer than the lock timeout/age, waiting clients bypass the cache lock
    # and stampede the upstream (each waiter then produces an uncached response), defeating the purpose
    # of `proxy_cache_lock on`. Defaults match `timeout`; explicit smaller values are bumped up.
    cache_lock_timeout = _normalize_nginx_time(params.get("cache_lock_timeout"), default=timeout, param_name="cache_lock_timeout")
    cache_lock_age = _normalize_nginx_time(params.get("cache_lock_age"), default=timeout, param_name="cache_lock_age")
    cache_404_ttl = _normalize_nginx_time(params.get("cache_404_ttl"), default="1m", param_name="cache_404_ttl")
    cache_default_ttl = _normalize_nginx_time(params.get("cache_default_ttl"), default="1m", param_name="cache_default_ttl")

    # Ensure cache_refresh_rate is at least as large as timeout, and apply the same anti-stampede
    # floor to the cache lock directives. All values are now guaranteed to end in "s" (the helper
    # always returns `<int>s`), so an O(1) integer compare on the prefix is safe.
    timeout_seconds = int(timeout[:-1])
    if int(cache_refresh_rate[:-1]) < timeout_seconds:
        cache_refresh_rate = timeout
    if int(cache_lock_timeout[:-1]) < timeout_seconds:
        cache_lock_timeout = timeout
    if int(cache_lock_age[:-1]) < timeout_seconds:
        cache_lock_age = timeout

    ssl, extra_headers, sslcerts, proxy_set_range_header = "", "", "", ""
    serverport = port + 1
    extra_cache_keys = []
    if (certificate and not private_key) or (private_key and not certificate):
        raise DemistoException("If using HTTPS connection, both certificate and private key should be provided.")
    if certificate and private_key:
        demisto.debug("Using HTTPS for nginx conf")
        with open(NGINX_SSL_CRT_FILE, "w") as f:
            f.write(certificate)
        with open(NGINX_SSL_KEY_FILE, "w") as f:
            f.write(private_key)
        ssl = "ssl"  # to be included in the listen directive
        sslcerts = NGINX_SSL_CERTS
        if argToBoolean(params.get("hsts_header", False)):
            extra_headers = 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;'
    credentials = params.get("credentials") or {}
    if credentials.get("identifier"):
        extra_cache_keys.append("$http_authorization")
    if get_integration_name() == "TAXII2 Server":
        extra_cache_keys.append("$http_accept")
        if params.get("version") == "2.0":
            proxy_set_range_header = "proxy_set_header Range $http_range;"
            extra_cache_keys.extend(["$http_range", "$http_content_range"])

    extra_cache_keys_str = "".join(extra_cache_keys)
    server_conf = Template(template_str).safe_substitute(
        port=port,
        serverport=serverport,
        ssl=ssl,
        sslcerts=sslcerts,
        extra_cache_key=extra_cache_keys_str,
        proxy_set_range_header=proxy_set_range_header,
        timeout=timeout,
        cache_refresh_rate=cache_refresh_rate,
        cache_lock_timeout=cache_lock_timeout,
        cache_lock_age=cache_lock_age,
        cache_404_ttl=cache_404_ttl,
        cache_default_ttl=cache_default_ttl,
        extra_headers=extra_headers,
    )
    with open(file_path, mode="w+") as f:
        f.write(server_conf)


def start_nginx_server(port: int, params: dict = {}) -> subprocess.Popen:
    params = params if params else demisto.params()
    create_nginx_server_conf(NGINX_SERVER_CONF_FILE, port, params)
    nginx_global_directives = "daemon off;"
    global_directives_conf = params.get("nginx_global_directives")
    if global_directives_conf:
        nginx_global_directives = f"{nginx_global_directives} {global_directives_conf}"
    directive_args = ["-g", nginx_global_directives]
    # we first do a test that all config is good and log it
    try:
        nginx_test_command = ["nginx", "-T"]
        nginx_test_command.extend(directive_args)
        test_output = subprocess.check_output(nginx_test_command, stderr=subprocess.STDOUT, text=True)
        demisto.info(f"ngnix test passed. command: [{nginx_test_command}]")
        demisto.debug(f"nginx test ouput:\n{test_output}")
    except subprocess.CalledProcessError as err:
        raise ValueError(f"Failed testing nginx conf. Return code: {err.returncode}. Output: {err.output}")
    nginx_command = ["nginx"]
    nginx_command.extend(directive_args)
    res = subprocess.Popen(nginx_command, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    demisto.info(f"done starting nginx with pid: {res.pid}")
    return res


def nginx_log_process(nginx_process: subprocess.Popen):
    old_access = NGINX_SERVER_ACCESS_LOG + ".old"
    old_error = NGINX_SERVER_ERROR_LOG + ".old"
    log_access = False
    log_error = False
    # first check if one of the logs are missing. This may happen on rare ocations that we renamed and deleted the file
    # before nginx completed the role over of the logs
    missing_log = False
    if not os.path.isfile(NGINX_SERVER_ACCESS_LOG):
        missing_log = True
        demisto.info(f"Missing access log: {NGINX_SERVER_ACCESS_LOG}. Will send roll signal to nginx.")
    if not os.path.isfile(NGINX_SERVER_ERROR_LOG):
        missing_log = True
        demisto.info(f"Missing error log: {NGINX_SERVER_ERROR_LOG}. Will send roll signal to nginx.")
    if missing_log:
        nginx_process.send_signal(int(SIGUSR1))
        demisto.info(
            f"Done sending roll signal to nginx (pid: {nginx_process.pid}) after detecting missing log file."
            " Will skip this iteration."
        )
        return
    if os.path.getsize(NGINX_SERVER_ACCESS_LOG):
        log_access = True
        Path(NGINX_SERVER_ACCESS_LOG).rename(old_access)
    if os.path.getsize(NGINX_SERVER_ERROR_LOG):
        log_error = True
        Path(NGINX_SERVER_ERROR_LOG).rename(old_error)
    if log_access or log_error:
        # nginx rolls the logs when getting sigusr1
        nginx_process.send_signal(int(SIGUSR1))
        gevent.sleep(0.5)  # sleep 0.5 to let nginx complete the roll
    if log_access:
        with open(old_access) as f:
            start = 1
            for lines in batch(f.readlines(), 100):
                end = start + len(lines)
                demisto.info(f"nginx access log ({start}-{end-1}): " + "".join(lines))
                start = end
        Path(old_access).unlink()
    if log_error:
        with open(old_error) as f:
            start = 1
            for lines in batch(f.readlines(), 100):
                end = start + len(lines)
                demisto.error(f"nginx error log ({start}-{end-1}): " + "".join(lines))
                start = end
        Path(old_error).unlink()


def nginx_log_monitor_loop(nginx_process: subprocess.Popen):
    """An endless loop to monitor nginx logs. Meant to be spawned as a greenlet.
    Will run every minute and if needed will dump the nginx logs and roll them if needed.

    Args:
        nginx_process (subprocess.Popen): the nginx process. Will send signal for log rolling.
    """
    while True:
        gevent.sleep(60)
        nginx_log_process(nginx_process)


def test_nginx_web_server(port: int, params: dict):
    polling_tries = 1
    is_test_done = False
    try:
        while polling_tries <= NGINX_MAX_POLLING_TRIES and not is_test_done:
            try:
                # let nginx startup
                time.sleep(0.5)
                protocol = "https" if params.get("key") else "http"
                res = requests.get(
                    f"{protocol}://localhost:{port}/nginx-test", verify=False, proxies={"http": "", "https": ""}
                )  # guardrails-disable-line # nosec
                res.raise_for_status()
                welcome = "Welcome to nginx"
                if welcome not in res.text:
                    raise ValueError(f'Unexpected response from nginx-test (does not contain "{welcome}"): {res.text}')
                is_test_done = True
            except Exception:
                if polling_tries == NGINX_MAX_POLLING_TRIES:
                    raise
                polling_tries += 1
    except Exception as ex:
        err_msg = f"Testing nginx server: {ex}"
        demisto.error(err_msg)
        raise DemistoException(err_msg) from ex


def test_nginx_server(port: int, params: dict):
    nginx_process = start_nginx_server(port, params)
    try:
        test_nginx_web_server(port, params)
    finally:
        try:
            nginx_process.terminate()
            nginx_process.wait(1.0)
        except Exception as ex:
            demisto.error(f"failed stopping test nginx process: {ex}")


def try_parse_integer(int_to_parse: Any, err_msg: str) -> int:
    """
    Tries to parse an integer, and if fails will throw DemistoException with given err_msg
    """
    try:
        res = int(int_to_parse)
    except (TypeError, ValueError):
        raise DemistoException(err_msg)
    return res


def parse_nginx_time_to_seconds(time_str: str) -> int:
    """Parses an NGINX time string (or a human-readable equivalent) into seconds.

    NGINX uses suffixes to denote time units (e.g., ``"3600"``, ``"1h"``,
    ``"30m"``, ``"60s"``). Supported suffixes are ``s`` (seconds), ``m``
    (minutes), ``h`` (hours), ``d`` (days), ``w`` (weeks), ``M`` (30 days),
    and ``y`` (years). If no suffix is supplied, the value is treated as
    seconds.

    Additionally, human-readable values used by some integrations (e.g., EDL's
    ``cache_refresh_rate`` parameter such as ``"5 minutes"``, ``"1 hour"``,
    ``"2 days"``) are also supported by falling back to ``dateparser``.

    Args:
        time_str (str): The time string to parse.

    Returns:
        int: The time converted to seconds.

    Raises:
        DemistoException: If ``time_str`` is empty, whitespace-only, ``None``,
            or otherwise cannot be parsed as a valid time value.
    """
    if not time_str or not (time_str := time_str.strip()):
        raise DemistoException(f"Invalid NGINX time format: {time_str}")
    if time_str.isdigit():
        return int(time_str)

    units = {
        "s": 1,
        "m": 60,
        "h": 3600,
        "d": 86400,
        "w": 604800,
        "M": 2592000,  # 30 days
        "y": 31536000,
    }

    unit = time_str[-1]
    value_str = time_str[:-1]

    if unit in units and value_str.isdigit():
        return int(value_str) * units[unit]

    # If it doesn't match the NGINX-native format, try parsing it as a
    # human-readable relative time (e.g., "5 minutes", "1 hour", "2 days").
    try:
        seconds = ceil((datetime.now() - dateparser.parse(time_str)).total_seconds())  # type: ignore[operator]
        if seconds > 0:
            return seconds
    except Exception:
        pass

    # Last resort: try to interpret the value as an integer number of seconds.
    try:
        return int(time_str)
    except (ValueError, TypeError):
        raise DemistoException(f"Invalid NGINX time format: {time_str}")


# Human-readable units accepted in the "<int> <unit>" form (e.g. "12 hours",
# "1 minute"). Anything outside this allow-list is rejected up-front so we do
# not silently inherit dateparser's permissive interpretation of unit-only
# tokens (e.g. "hours" -> midnight today) or compound expressions
# (e.g. "12 hours and 5 minutes" -> "12 hours ago at 23:25").
_NORMALIZE_NGINX_TIME_HUMAN_UNITS = frozenset(
    {
        "second",
        "seconds",
        "minute",
        "minutes",
        "hour",
        "hours",
        "day",
        "days",
        "week",
        "weeks",
        "month",
        "months",
        "year",
        "years",
    }
)


def _normalize_nginx_time(value: Any, default: str, param_name: str) -> str:
    """Normalize a user-supplied time value to an nginx-valid ``"<int>s"`` token.

    Accepts:
      * pure ints       : ``300``, ``"300"`` (must be non-negative)
      * nginx-native    : ``"12h"``, ``"30m"``, ``"1d"``, ``"2w"``, ``"1M"``,
                          ``"1y"``, ``"60s"``
      * human-readable  : strict ``"<positive-int> <unit>"`` form, where unit
                          is one of ``second(s)``, ``minute(s)``, ``hour(s)``,
                          ``day(s)``, ``week(s)``, ``month(s)``, ``year(s)``.

    Always returns ``f"{seconds}s"`` (e.g. ``"43200s"``) so the rendered nginx
    directive is unambiguous and unit-safe — the entire class of unit-string
    typo bugs in the template is eliminated.

    Falls back to ``default`` (which is itself normalized through the same
    helper) when ``value`` is ``None``, an empty string, or whitespace-only,
    so callers can pass either form for the default.

    Args:
        value: The user-supplied value (may be ``None``, ``int``, or ``str``).
        default: The fallback value used when ``value`` is empty/missing.
            Accepts the same formats as ``value``.
        param_name: Name of the originating parameter; included verbatim in
            error messages so users can pinpoint the offending field.

    Returns:
        An nginx-valid time token of the form ``"<int>s"``.

    Raises:
        DemistoException: If ``value`` (or, when ``value`` is empty,
            ``default``) is non-empty but cannot be parsed as a valid time
            value, or resolves to a non-positive number of seconds. The
            message includes ``param_name`` and the original ``value``.
    """
    raw = "" if value is None else str(value).strip()
    if not raw:
        raw = str(default).strip()

    # Pre-validate the shape before delegating.
    tokens = raw.split()
    accepted_shape = (
        # nginx-native single token: pure int, or <int><unit-letter>
        (len(tokens) == 1 and (raw.isdigit() or (raw[:-1].isdigit() and raw[-1] in "smhdwMy")))
        # strict "<int> <unit>" human-readable form
        or (len(tokens) == 2 and tokens[0].isdigit() and tokens[1].lower() in _NORMALIZE_NGINX_TIME_HUMAN_UNITS)
    )
    if not accepted_shape:
        raise DemistoException(
            f"Invalid value for parameter '{param_name}': {value!r}. "
            f"Expected an nginx-native value (e.g. '12h', '30m', '300') "
            f"or a human-readable value (e.g. '12 hours', '30 minutes')."
        )

    try:
        seconds = parse_nginx_time_to_seconds(raw)
    except DemistoException as e:
        raise DemistoException(
            f"Invalid value for parameter '{param_name}': {value!r}. "
            f"Expected an nginx-native value (e.g. '12h', '30m', '300') "
            f"or a human-readable value (e.g. '12 hours', '30 minutes'). "
            f"Original parser error: {e}"
        )
    if seconds <= 0:
        raise DemistoException(
            f"Invalid value for parameter '{param_name}': {value!r}. " f"Value must resolve to a positive number of seconds."
        )
    return f"{seconds}s"


def get_params_port(params: dict = None) -> int:
    """
    Gets port from the integration parameters
    """
    params = params if params else demisto.params()
    port_mapping: str = params.get("longRunningPort", "")
    err_msg: str
    port: int
    if port_mapping:
        err_msg = f"Listen Port must be an integer. {port_mapping} is not valid."
        if ":" in port_mapping:
            port = try_parse_integer(port_mapping.split(":")[1], err_msg)
        else:
            port = try_parse_integer(port_mapping, err_msg)
    else:
        raise ValueError("Please provide a Listen Port.")
    return port


def run_long_running(params: dict = None, is_test: bool = False):
    """
    Start the long running server
    :param params: Demisto params
    :param is_test: Indicates whether it's test-module run or regular run
    :return: None
    """
    params = params if params else demisto.params()
    nginx_process = None
    nginx_log_monitor = None

    try:
        nginx_port = get_params_port()
        server_port = nginx_port + 1
        # set our own log handlers
        APP.logger.removeHandler(default_handler)  # type: ignore[name-defined] # pylint: disable=E0602
        integration_logger = IntegrationLogger()
        integration_logger.buffering = False
        log_handler = DemistoHandler(integration_logger)
        log_handler.setFormatter(logging.Formatter("flask log: [%(asctime)s] %(levelname)s in %(module)s: %(message)s"))
        APP.logger.addHandler(log_handler)  # type: ignore[name-defined] # pylint: disable=E0602
        demisto.debug("done setting demisto handler for logging")
        server = WSGIServer(
            ("0.0.0.0", server_port),
            APP,  # type: ignore[name-defined]    # pylint: disable=E0602
            log=DEMISTO_LOGGER,  # type: ignore[name-defined] # pylint: disable=E0602
            error_log=ERROR_LOGGER,
        )
        if is_test:
            test_nginx_server(nginx_port, params)
            server_process = Process(target=server.serve_forever)
            server_process.start()
            time.sleep(5)
            try:
                server_process.terminate()
                server_process.join(1.0)
            except Exception as ex:
                demisto.error(f"failed stopping test wsgi server process: {ex}")

        else:
            nginx_process = start_nginx_server(nginx_port, params)
            test_nginx_web_server(nginx_port, params)
            nginx_log_monitor = gevent.spawn(nginx_log_monitor_loop, nginx_process)
            demisto.updateModuleHealth("")
            server.serve_forever()
    except Exception as e:
        error_message = str(e)
        if isinstance(e, ValueError) and "Try to write when connection closed" in error_message:
            # This indicates that the XSOAR platform is unreachable, and there is no way to recover from this, so we need to exit.
            sys.exit(1)  # pylint: disable=E9001

        demisto.error(f"An error occurred: {error_message}. Exception: {traceback.format_exc()}")
        demisto.updateModuleHealth(f"An error occurred: {error_message}")
        raise ValueError(error_message)

    finally:
        if nginx_process:
            try:
                nginx_process.terminate()
            except Exception as ex:
                demisto.error(f"Failed stopping nginx process when exiting: {ex}")
        if nginx_log_monitor:
            try:
                nginx_log_monitor.kill(timeout=1.0)
            except Exception as ex:
                demisto.error(f"Failed stopping nginx_log_monitor when exiting: {ex}")

README

Common NGINX code that will be appended into each NGINX based integration when it’s deployed

Script Data


Name Description
Script Type python3
Tags infra, server
Cortex XSOAR Version 5.5.0

Inputs


There are no inputs for this script.

Outputs


There are no outputs for this script.