diff --git a/USER-JOURNEY.md b/USER-JOURNEY.md index 1145c66..b3f0c70 100644 --- a/USER-JOURNEY.md +++ b/USER-JOURNEY.md @@ -31,7 +31,7 @@ The gateway configuration can be hand-crafted or you can use a command line inte **Simple Example** -``` +``` bash export NS="my_namespace" export NAME="a-service-for-$NS" echo " @@ -65,7 +65,7 @@ services: > **Upstream Services on OCP4:** If your service is running on OCP4, you should specify the Kubernetes Service in the `Service.host`. It must have the format: `..svc` The Network Security Policies (NSP) will be setup automatically on the API Gateway side. You will need to create an NSP on your side looking something like this to allow the Gateway's test and prod environments to route traffic to your API: -``` +``` yaml kind: NetworkSecurityPolicy apiVersion: security.devops.gov.bc.ca/v1alpha1 metadata: @@ -84,7 +84,7 @@ spec: > **Require mTLS between the Gateway and your Upstream Service?** To support mTLS on your Upstream Service, you will need to provide client certificate details and if you want to verify the upstream endpoint then the `ca_certificates` and `tls_verify` is required as well. An example: -``` +``` yaml services: - name: my-upstream-service host: my-upstream.site @@ -116,7 +116,7 @@ Run: `gwa new` and follow the prompts. Example: -``` +``` bash gwa new -o sample.yaml \ --route-host myapi.api.gov.bc.ca \ --service-url https://httpbin.org \ @@ -133,7 +133,7 @@ The Swagger console for the `gwa-api` can be used to publish Kong Gateway config **Install (for Linux)** -``` +``` bash GWA_CLI_VERSION=v1.1.2; curl -L -O https://github.com/bcgov/gwa-cli/releases/download/${GWA_CLI_VERSION}/gwa_${GWA_CLI_VERSION}_linux_x64.zip unzip gwa_${GWA_CLI_VERSION}_linux_x64.zip ./gwa --version @@ -147,7 +147,7 @@ unzip gwa_${GWA_CLI_VERSION}_linux_x64.zip Create a `.env` file and update the CLIENT_ID and CLIENT_SECRET with the new credentials that were generated in step #2: -``` +``` bash echo " GWA_NAMESPACE=$NS CLIENT_ID= @@ -165,13 +165,13 @@ gwa init -T --namespace=$NS --client-id= --client-secre **Publish** -``` +``` bash gwa pg sample.yaml ``` If you want to see the expected changes but not actually apply them, you can run: -``` +``` bash gwa pg --dry-run sample.yaml ``` @@ -211,7 +211,7 @@ To verify that the Gateway can access the upstream services, run the command: `g In our test environment, the hosts that you defined in the routes get altered; to see the actual hosts, log into the API Services Portal and view the hosts under `Services`. -``` +``` bash curl https://${NAME}-api-gov-bc-ca.test.apsgw.xyz/headers ab -n 20 -c 2 https://${NAME}-api-gov-bc-ca.test.apsgw.xyz/headers @@ -243,7 +243,7 @@ The `acl` command provides a way to update the access for the namespace. It exp For elevated privileges (such as managing Service Accounts), add the usernames to the `--managers` argument. -``` +``` bash gwa acl --users jjones@idir --managers acope@idir ``` @@ -265,7 +265,7 @@ Add a `.gwa` folder (can be called anything) that will be used to hold your gate An example Github Workflow: -``` +``` yaml env: NS: "" diff --git a/docs/SSL.md b/docs/SSL.md new file mode 100644 index 0000000..0202d64 --- /dev/null +++ b/docs/SSL.md @@ -0,0 +1,41 @@ + +# SSL Termination + +If you would like to verify the SSL endpoint, you can run the following two commands and compare the fingerprint and serial no. + +``` +export A_HOST=httpbin-regression.api.gov.bc.ca +openssl s_client -showcerts -verify 5 -connect 142.34.194.118:443 -servername ${A_HOST} < /dev/null | awk '/BEGIN/,/END/{ if(/BEGIN/){a++}; print}' > gw.crt + +openssl x509 -in gw.crt -fingerprint -serial -dates -noout + +``` + +## *.api.gov.bc.ca + +| Issue Date | Expires | Deployed | SHA1 Fingerprint | Serial No. | +|-------------|-------------|-------------|-------------------------------------------------------------|----------------------------------| +| Oct 6 2020 | Oct 16 2021 | Oct 6 2020 | 20:7D:15:9D:42:BE:CC:BC:FD:EF:DF:13:77:C7:25:A3:A4:72:45:05 | 7876EB597E14F728C8455504177D3BC9 | +| Feb 16 2021 | Oct 16 2021 | Feb 25 2021 | 4D:EA:CE:C4:0A:73:67:D3:B4:03:F6:63:C4:E1:67:2C:47:9D:EA:82 | 3B5849D8A670251A3C20EA7859BDF996 | + + +You can run the above as one line: + +``` +A_HOST=httpbin-regression.api.gov.bc.ca; openssl s_client -showcerts -verify 5 -connect ${A_HOST}:443 -servername ${A_HOST} < /dev/null | awk '/BEGIN/,/END/{ if(/BEGIN/){a++}; print}' | openssl x509 -fingerprint -serial -dates -noout +``` + + +**Individual File Verification** + +``` +openssl x509 -in data-api-wildcard-2020.crt -fingerprint -serial -dates -noout +openssl x509 -in data-api-wildcard-2021.crt -fingerprint -serial -dates -noout +``` + +**Cert/Key Verification** + +``` +openssl x509 -noout -modulus -in data-api-wildcard.crt | openssl md5 +openssl rsa -noout -modulus -in data-api-wildcard.key | openssl md5 +``` \ No newline at end of file diff --git a/microservices/gatewayApi/Dockerfile b/microservices/gatewayApi/Dockerfile index 69bed85..8cb1697 100644 --- a/microservices/gatewayApi/Dockerfile +++ b/microservices/gatewayApi/Dockerfile @@ -1,10 +1,10 @@ -FROM golang:1.15.2 AS build -WORKDIR /deck -RUN git clone https://github.com/kong/deck.git -RUN cd deck \ - && go mod download \ - && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o deck \ - -ldflags "-s -w -X github.com/kong/deck/cmd.VERSION=$TAG -X github.com/kong/deck/cmd.COMMIT=$COMMIT" +# FROM golang:1.15.2 AS build +# WORKDIR /deck +# RUN git clone https://github.com/kong/deck.git +# RUN cd deck \ +# && go mod download \ +# && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o deck \ +# -ldflags "-s -w -X github.com/kong/deck/cmd.VERSION=$TAG -X github.com/kong/deck/cmd.COMMIT=$COMMIT" FROM python:3.8-alpine @@ -15,11 +15,11 @@ RUN apk add build-base libffi-dev openssl openssl-dev curl RUN curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" && \ chmod +x kubectl; mv kubectl /usr/local/bin/. -COPY --from=build /deck/deck /usr/local/bin +#COPY --from=build /deck/deck /usr/local/bin -#RUN curl -sL https://github.com/kong/deck/releases/download/v1.2.4/deck_1.2.4_linux_amd64.tar.gz -o deck.tar.gz && \ -# tar -xf deck.tar.gz -C /tmp && \ -# cp /tmp/deck /usr/local/bin/ +RUN curl -sL https://github.com/kong/deck/releases/download/v1.5.0/deck_1.5.0_linux_amd64.tar.gz -o deck.tar.gz && \ + tar -xf deck.tar.gz -C /tmp && \ + cp /tmp/deck /usr/local/bin/ COPY requirements.txt requirements.txt diff --git a/microservices/gatewayApi/app.py b/microservices/gatewayApi/app.py index 530c64e..57d5153 100644 --- a/microservices/gatewayApi/app.py +++ b/microservices/gatewayApi/app.py @@ -12,12 +12,9 @@ from flask_cors import CORS import threading -from auth.token import OIDCDiscovery - import v1.v1 as v1 -from swagger_ui import api_doc -from jinja2 import Template + def set_cors_headers_on_response(response): response.headers['Access-Control-Allow-Origin'] = '*' @@ -25,29 +22,6 @@ def set_cors_headers_on_response(response): response.headers['Access-Control-Allow-Methods'] = 'OPTIONS' return response -def setup_swagger_docs (app): - conf = config.Config() - log = logging.getLogger(__name__) - - try: - ## Template the spec and write it to a temporary location - discovery = OIDCDiscovery(conf.data['oidcBaseUrl']) - tmpFile = "%s/v1.yaml" % conf.data['workingFolder'] - f = open("v1/spec/v1.yaml", "r") - t = Template(f.read()) - f = open(tmpFile, "w") - f.write(t.render( - server_url = "/v1", - tokeninfo_url = discovery["introspection_endpoint"], - authorization_url = discovery["authorization_endpoint"], - accesstoken_url = discovery["token_endpoint"] - )) - api_doc(app, config_path=tmpFile, url_prefix='/api/doc', title='API doc') - log.info("Configured /api/doc") - except: - log.error("Failed to do OIDC Discovery, sleeping 5 seconds and trying again.") - time.sleep(5) - setup_swagger_docs(app) def create_app(test_config=None): @@ -78,8 +52,6 @@ def create_app(test_config=None): v1.Register(app) Compress(app) - t = threading.Thread(name='child procs', target=setup_swagger_docs, args=(app,)) - t.start() @app.before_request def before_request(): diff --git a/microservices/gatewayApi/auth/token.py b/microservices/gatewayApi/auth/token.py index 0c48323..30463ed 100644 --- a/microservices/gatewayApi/auth/token.py +++ b/microservices/gatewayApi/auth/token.py @@ -1,7 +1,8 @@ import requests +import time from authlib.jose import jwt from authlib.jose.errors import JoseError, ExpiredTokenError -from authlib.oauth2.rfc6749 import TokenMixin, time +from authlib.oauth2.rfc6749 import TokenMixin from authlib.oauth2.rfc6750 import BearerTokenValidator from flask import current_app, g from config import Config diff --git a/microservices/gatewayApi/clients/ocp_routes.py b/microservices/gatewayApi/clients/ocp_routes.py index bd874e5..78110e3 100644 --- a/microservices/gatewayApi/clients/ocp_routes.py +++ b/microservices/gatewayApi/clients/ocp_routes.py @@ -117,6 +117,25 @@ def prepare_delete_routes (ns, select_tag, rootPath): return len(delete_list) +def prepare_route_last_version (ns, select_tag): + log = app.logger + + args = [ + "kubectl", "get", "routes", "-l", "aps-select-tag=%s" % select_tag, "-o", "json" + ] + run = Popen(args, stdout=PIPE, stderr=PIPE) + out, err = run.communicate() + if run.returncode != 0: + log.error("Failed to get existing routes", out, err) + raise Exception("Failed to get existing routes") + + resource_versions = {} + + existing = json.loads(out) + for route in existing['items']: + resource_versions[ route['metadata']['name'] ] = route['metadata']['resourceVersion'] + return resource_versions + def prepare_apply_routes (ns, select_tag, is_host_transform_enabled, rootPath): log = app.logger ssl_key_path = "/ssl/tls.key" @@ -132,6 +151,7 @@ def prepare_apply_routes (ns, select_tag, is_host_transform_enabled, rootPath): kind: Route metadata: name: ${name} + resourceVersion: "${resource_version}" annotations: haproxy.router.openshift.io/timeout: 30m labels: @@ -167,6 +187,8 @@ def prepare_apply_routes (ns, select_tag, is_host_transform_enabled, rootPath): ts = int(time.time()) fmt_time = datetime.now().strftime("%Y.%m-%b.%d") + resource_versions = prepare_route_last_version(ns, select_tag) + with open(out_filename, 'w') as out_file: index = 1 for host in host_list: @@ -182,8 +204,14 @@ def prepare_apply_routes (ns, select_tag, is_host_transform_enabled, rootPath): ssl_key = read_and_indent("/ssl/%s.key" % ssl_ref, 8) ssl_crt = read_and_indent("/ssl/%s.crt" % ssl_ref, 8) - log.debug("[%s] Route A %03d wild-%s-%s" % (select_tag, index, select_tag.replace('.','-'), host)) - out_file.write(template.substitute(name="wild-%s-%s" % (select_tag.replace('.','-'), host), ns=ns, select_tag=select_tag, host=host, path='/', ssl_ref=ssl_ref, ssl_key=ssl_key, ssl_crt=ssl_crt, serviceName='kong-kong-proxy', timestamp=ts, fmt_time=fmt_time)) + name = "wild-%s-%s" % (select_tag.replace('.','-'), host) + + resource_version = "" + if name in resource_versions: + resource_version = resource_versions[name] + + log.debug("[%s] Route A %03d wild-%s-%s (ver.%s)" % (select_tag, index, select_tag.replace('.','-'), host, resource_version)) + out_file.write(template.substitute(name=name, ns=ns, select_tag=select_tag, resource_version=resource_version, host=host, path='/', ssl_ref=ssl_ref, ssl_key=ssl_key, ssl_crt=ssl_crt, serviceName='kong-kong-proxy', timestamp=ts, fmt_time=fmt_time)) out_file.write('\n---\n') index = index + 1 diff --git a/microservices/gatewayApi/clients/portal.py b/microservices/gatewayApi/clients/portal.py new file mode 100644 index 0000000..de3a4bf --- /dev/null +++ b/microservices/gatewayApi/clients/portal.py @@ -0,0 +1,50 @@ +from flask import current_app as app +import sys +import requests +import traceback +import urllib.parse + +# +# 'type', 'name', 'action', 'message', 'refId', 'namespace' + + +def record_namespace_event (uuid, action, result, namespace, message = ""): + record_activity ({ + 'id': uuid, + 'type': 'GatewayNamespace', + 'action': action, + 'result': result, + 'name': 'N/A', + 'message': message, + 'refId': '', + 'namespace': namespace +}) + +def record_gateway_event (uuid, action, result, namespace, message = ""): + record_activity ({ + 'id': uuid, + 'type': 'GatewayConfig', + 'action': action, + 'result': result, + 'name': 'N/A', + 'message': message, + 'refId': '', + 'namespace': namespace + }) + +def record_activity (activity): + log = app.logger + portal_url = app.config['portal']['url'] + + log.debug("record_activity %s : %s %s" % (portal_url, activity['id'], activity['result'])) + + if portal_url != "": + headers = { + "Content-Type": "application/json" + } + try: + r = requests.put("%s/feed/Activity" % portal_url, headers=headers, json=activity, timeout=5) + log.info("Request Record Activity %s : %d" % (portal_url, r.status_code)) + except Exception as ex: + log.error("Error recording activity %s : %s" % (portal_url, str(ex))) + traceback.print_exc(file=sys.stdout) \ No newline at end of file diff --git a/microservices/gatewayApi/entrypoint.sh b/microservices/gatewayApi/entrypoint.sh index 3ea626c..3a7732c 100755 --- a/microservices/gatewayApi/entrypoint.sh +++ b/microservices/gatewayApi/entrypoint.sh @@ -21,10 +21,16 @@ cat > "${CONFIG_PATH:-./config/default.json}" <', @@ -34,6 +40,10 @@ @admin_jwt(None) def delete_config(namespace: str, qualifier = "") -> object: enforce_authorization(namespace) + + event_id = str(uuid.uuid4()) + record_gateway_event(event_id, 'delete', 'received', namespace) + log = app.logger outFolder = namespace @@ -63,7 +73,7 @@ def delete_config(namespace: str, qualifier = "") -> object: if deck_run.returncode != 0: cleanup (tempFolder) log.warn("%s - %s" % (namespace, out.decode('utf-8'))) - abort(make_response(jsonify(error="Sync Failed.", results=mask(out.decode('utf-8'))), 400)) + abort_early(event_id, 'delete', namespace, jsonify(error="Sync Failed.", results=mask(out.decode('utf-8'))) ) elif cmd == "sync": try: @@ -93,11 +103,11 @@ def delete_config(namespace: str, qualifier = "") -> object: except HTTPException as ex: traceback.print_exc() log.error("Error updating custom routes, nsps and secrets. %s" % ex) - abort(make_response(jsonify(error="Partially failed."), 400)) + abort_early(event_id, 'delete', namespace, jsonify(error="Partially failed.") ) except: traceback.print_exc() log.error("Error updating custom routes, nsps and secrets. %s" % sys.exc_info()[0]) - abort(make_response(jsonify(error="Partially failed."), 400)) + abort_early(event_id, 'delete', namespace, jsonify(error="Partially failed.") ) cleanup (tempFolder) @@ -107,6 +117,7 @@ def delete_config(namespace: str, qualifier = "") -> object: if cmd == 'diff': message = "Dry-run. No changes applied." + record_gateway_event(event_id, 'delete', 'completed', namespace) return make_response('', http.HTTPStatus.NO_CONTENT) @@ -119,6 +130,10 @@ def write_config(namespace: str) -> object: :return: JSON of success message or error message """ enforce_authorization(namespace) + + event_id = str(uuid.uuid4()) + record_gateway_event(event_id, 'publish', 'received', namespace) + log = app.logger outFolder = namespace @@ -152,7 +167,7 @@ def write_config(namespace: str) -> object: log.error(request.form) log.error(request.content_type) log.error(request.headers) - abort(make_response(jsonify(error="Missing input"), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Missing input")) tempFolder = "%s/%s/%s" % ('/tmp', uuid.uuid4(), outFolder) os.makedirs (tempFolder, exist_ok=False) @@ -205,7 +220,7 @@ def write_config(namespace: str) -> object: except Exception as ex: traceback.print_exc() log.error("%s - %s" % (namespace, " Tag Validation Errors: %s" % ex)) - abort(make_response(jsonify(error="Validation Errors:\n%s" % ex), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) # Validate that hosts are valid try: @@ -213,7 +228,16 @@ def write_config(namespace: str) -> object: except Exception as ex: traceback.print_exc() log.error("%s - %s" % (namespace, " Host Validation Errors: %s" % ex)) - abort(make_response(jsonify(error="Validation Errors:\n%s" % ex), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) + + # Validate upstream URLs are valid + try: + protected_kube_namespaces = json.loads(app.config['protectedKubeNamespaces']) + validate_upstream (gw_config, ns_attributes, protected_kube_namespaces) + except Exception as ex: + traceback.print_exc() + log.error("%s - %s" % (namespace, " Upstream Validation Errors: %s" % ex)) + abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ex)) # Validation #3 # Validate that certain plugins are configured (such as the gwa_gov_endpoint) at the right level @@ -223,7 +247,7 @@ def write_config(namespace: str) -> object: nsq = traverse_get_ns_qualifier (gw_config, selectTag) if nsq is not None: if ns_qualifier is not None and nsq != ns_qualifier: - abort(make_response(jsonify(error="Validation Errors:\n%s" % ("Conflicting ns qualifiers (%s != %s)" % (ns_qualifier, nsq))), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Validation Errors:\n%s" % ("Conflicting ns qualifiers (%s != %s)" % (ns_qualifier, nsq)))) ns_qualifier = nsq log.info("[%s] CHANGING ns_qualifier %s" % (namespace, ns_qualifier)) @@ -245,7 +269,7 @@ def write_config(namespace: str) -> object: if deck_run.returncode != 0: cleanup (tempFolder) log.warn("[%s] - %s" % (namespace, out.decode('utf-8'))) - abort(make_response(jsonify(error="Sync Failed.", results=mask(out.decode('utf-8'))), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Sync Failed.", results=mask(out.decode('utf-8')))) elif cmd == "sync": try: @@ -261,11 +285,12 @@ def write_config(namespace: str) -> object: # create Network Security Policies (nsp) for any upstream that # has the format: ..svc - log.debug("[%s] - Update NSPs" % (namespace)) - ocp_ns_list = get_ocp_service_namespaces (tempFolder) - for ocp_ns in ocp_ns_list: - if check_nsp (namespace, ocp_ns) is False: - apply_nsp (namespace, ocp_ns, tempFolder) + if should_we_apply_nsp_policies(): + log.debug("[%s] - Update NSPs" % (namespace)) + ocp_ns_list = get_ocp_service_namespaces (tempFolder) + for ocp_ns in ocp_ns_list: + if check_nsp (namespace, ocp_ns) is False: + apply_nsp (namespace, ocp_ns, tempFolder) # ok all looks good, so update a secret containing the original submitted request log.debug("[%s] - Update Original Config" % (namespace)) @@ -275,11 +300,11 @@ def write_config(namespace: str) -> object: except HTTPException as ex: traceback.print_exc() log.error("[%s] Error updating custom routes, nsps and secrets. %s" % (namespace, ex)) - abort(make_response(jsonify(error="Partially failed."), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Partially failed.")) except: traceback.print_exc() log.error("[%s] Error updating custom routes, nsps and secrets. %s" % (namespace, sys.exc_info()[0])) - abort(make_response(jsonify(error="Partially failed."), 400)) + abort_early(event_id, 'publish', namespace, jsonify(error="Partially failed.")) cleanup (tempFolder) @@ -289,6 +314,7 @@ def write_config(namespace: str) -> object: if cmd == 'diff': message = "Dry-run. No changes applied." + record_gateway_event(event_id, 'publish', 'completed', namespace) return make_response(jsonify(message=message, results=mask(out.decode('utf-8')))) def cleanup (dir_path): @@ -360,6 +386,55 @@ def transform_host (host): else: return host + +def validate_upstream (yaml, ns_attributes, protected_kube_namespaces): + errors = [] + + allow_protected_ns = ns_attributes.get('perm-protected-ns', ['deny'] )[0] == 'allow' + + ## A host must not contain a list of protected + if 'services' in yaml: + for service in yaml['services']: + if 'url' in service: + try: + u = urlparse(service["url"]) + if u.hostname is None: + errors.append("service upstream has invalid url specified (e1)") + else: + validate_upstream_host (u.hostname, errors, allow_protected_ns, protected_kube_namespaces) + except Exception as e: + errors.append("service upstream has invalid url specified (e2)") + + if 'host' in service: + host = service["host"] + validate_upstream_host (host, errors, allow_protected_ns, protected_kube_namespaces) + + if len(errors) != 0: + raise Exception('\n'.join(errors)) + +def validate_upstream_host (_host, errors, allow_protected_ns, protected_kube_namespaces): + host = _host.lower() + + restricted = [ 'localhost', '127.0.0.1', '0.0.0.0' ] + + if host in restricted: + errors.append("service upstream is invalid (e1)") + if host.endswith('svc'): + partials = host.split('.') + # get the namespace, and make sure it is not in the protected_kube_namespaces list + if len(partials) != 3: + errors.append("service upstream is invalid (e2)") + elif partials[1] in protected_kube_namespaces and allow_protected_ns is False: + errors.append("service upstream is invalid (e3)") + if host.endswith('svc.cluster.local'): + partials = host.split('.') + # get the namespace, and make sure it is not in the protected_kube_namespaces list + if len(partials) != 5: + errors.append("service upstream is invalid (e4)") + elif partials[1] in protected_kube_namespaces and allow_protected_ns is False: + errors.append("service upstream is invalid (e5)") + + def validate_hosts (yaml, reserved_hosts, ns_attributes): errors = [] @@ -463,3 +538,7 @@ def traverse_get_ns_qualifier (yaml, required_tag): def is_host_transform_enabled (): conf = app.config['hostTransformation'] return conf['enabled'] is True + +def should_we_apply_nsp_policies (): + conf = app.config['applyAporetoNSP'] + return conf is True diff --git a/microservices/gatewayApi/wsgi.py b/microservices/gatewayApi/wsgi.py index 3bdebb6..0fd1d2a 100644 --- a/microservices/gatewayApi/wsgi.py +++ b/microservices/gatewayApi/wsgi.py @@ -11,10 +11,13 @@ # from server import app from timeit import default_timer as timer from app import create_app +import threading import config from logging.config import dictConfig +from swagger import setup_swagger_docs + conf = config.Config() dictConfig({ @@ -37,6 +40,9 @@ app = create_app() +t = threading.Thread(name='child procs', target=setup_swagger_docs, args=(app,)) +t.start() + def signal_handler(sig, frame): log.info('You pressed Ctrl+C - exiting!') sys.exit(0)