From 0c5400b7d134bf957ee654a1c26063a2bb23084a Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Mon, 10 Oct 2016 16:23:42 -0400 Subject: [PATCH] enforce license across registry blueprints --- app.py | 41 +++++++++------ endpoints/api/suconfig.py | 2 +- endpoints/v1/__init__.py | 8 +-- endpoints/v2/__init__.py | 12 +++-- endpoints/verbs/__init__.py | 7 ++- test/test_license.py | 3 +- util/config/provider/baseprovider.py | 9 +++- util/{config/provider => }/license.py | 75 ++++++++++++++++++++++++--- 8 files changed, 118 insertions(+), 39 deletions(-) rename util/{config/provider => }/license.py (72%) diff --git a/app.py b/app.py index 214efb3b6..1fd0bc936 100644 --- a/app.py +++ b/app.py @@ -1,47 +1,51 @@ +import json import logging import os -import json from functools import partial + +from Crypto.PublicKey import RSA from flask import Flask, request, Request, _request_ctx_stack -from flask_principal import Principal from flask_login import LoginManager, UserMixin from flask_mail import Mail -from werkzeug.routing import BaseConverter +from flask_principal import Principal from jwkest.jwk import RSAKey -from Crypto.PublicKey import RSA +from werkzeug.routing import BaseConverter import features from avatars.avatars import Avatar -from storage import Storage -from data import model from data import database -from data.userfiles import Userfiles -from data.users import UserAuthentication +from data import model +from data.archivedlogs import LogArchive from data.billing import Billing from data.buildlogs import BuildLogs -from data.archivedlogs import LogArchive -from data.userevent import UserEventsBuilderModule from data.queue import WorkQueue, BuildMetricQueueReporter +from data.userevent import UserEventsBuilderModule +from data.userfiles import Userfiles +from data.users import UserAuthentication +from storage import Storage from util import get_app_url from util.saas.analytics import Analytics from util.saas.useranalytics import UserAnalytics from util.saas.exceptionlog import Sentry from util.names import urn_generator +from util.config.configutil import generate_secret_key from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig, DexOAuthConfig) - -from util.security.signing import Signer -from util.security.instancekeys import InstanceKeys -from util.saas.cloudwatch import start_cloudwatch_sender from util.config.provider import get_config_provider -from util.config.configutil import generate_secret_key from util.config.superusermanager import SuperUserManager -from util.secscan.api import SecurityScannerAPI +from util.label_validator import LabelValidator +from util.license import LicenseValidator, LICENSE_FILENAME from util.metrics.metricqueue import MetricQueue from util.metrics.prometheus import PrometheusPlugin -from util.label_validator import LabelValidator +from util.names import urn_generator +from util.saas.analytics import Analytics +from util.saas.cloudwatch import start_cloudwatch_sender +from util.saas.exceptionlog import Sentry +from util.secscan.api import SecurityScannerAPI +from util.security.instancekeys import InstanceKeys +from util.security.signing import Signer OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/' OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' @@ -189,6 +193,9 @@ signer = Signer(app, config_provider) instance_keys = InstanceKeys(app) label_validator = LabelValidator(app) +license_validator = LicenseValidator(os.path.join(OVERRIDE_CONFIG_DIRECTORY, LICENSE_FILENAME)) +license_validator.start() + start_cloudwatch_sender(metric_queue, app) tf = app.config['DB_TRANSACTION_FACTORY'] diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py index d8c22cbf2..3da059290 100644 --- a/endpoints/api/suconfig.py +++ b/endpoints/api/suconfig.py @@ -19,7 +19,7 @@ from data.database import User from util.config.configutil import add_enterprise_config_defaults from util.config.database import sync_database_with_config from util.config.validator import validate_service_for_config, CONFIG_FILENAMES -from util.config.provider.license import decode_license, LicenseError +from util.license import decode_license, LicenseError from data.runmigration import run_alembic_migration from data.users import get_federated_service_name, get_users_handler diff --git a/endpoints/v1/__init__.py b/endpoints/v1/__init__.py index 1e9715787..1407a1b25 100644 --- a/endpoints/v1/__init__.py +++ b/endpoints/v1/__init__.py @@ -1,14 +1,16 @@ from flask import Blueprint, make_response -from app import metric_queue +from app import metric_queue, license_validator from endpoints.decorators import anon_protect, anon_allowed +from util.license import enforce_license_before_request from util.metrics.metricqueue import time_blueprint v1_bp = Blueprint('v1', __name__) - +enforce_license_before_request(license_validator, v1_bp) time_blueprint(v1_bp, metric_queue) + # Note: This is *not* part of the Docker index spec. This is here for our own health check, # since we have nginx handle the _ping below. @v1_bp.route('/_internal_ping') @@ -29,4 +31,4 @@ def ping(): from endpoints.v1 import index from endpoints.v1 import registry -from endpoints.v1 import tag \ No newline at end of file +from endpoints.v1 import tag diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py index da20b9077..dda9baca4 100644 --- a/endpoints/v2/__init__.py +++ b/endpoints/v2/__init__.py @@ -9,7 +9,7 @@ from semantic_version import Spec import features -from app import app, metric_queue, get_app_url +from app import app, metric_queue, get_app_url, license_validator from auth.auth_context import get_grant_context from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) @@ -18,13 +18,17 @@ from data import model from endpoints.decorators import anon_protect, anon_allowed from endpoints.v2.errors import V2RegistryException, Unauthorized from util.http import abort -from util.registry.dockerver import docker_version +from util.license import enforce_license_before_request from util.metrics.metricqueue import time_blueprint +from util.registry.dockerver import docker_version from util.pagination import encrypt_page_token, decrypt_page_token -logger = logging.getLogger(__name__) -v2_bp = Blueprint('v2', __name__) +logger = logging.getLogger(__name__) + + +v2_bp = Blueprint('v2', __name__) +enforce_license_before_request(license_validator, v2_bp) time_blueprint(v2_bp, metric_queue) diff --git a/endpoints/verbs/__init__.py b/endpoints/verbs/__init__.py index 7dddd0a8c..87e4fa644 100644 --- a/endpoints/verbs/__init__.py +++ b/endpoints/verbs/__init__.py @@ -5,7 +5,7 @@ from flask import redirect, Blueprint, abort, send_file, make_response, request import features -from app import app, signer, storage, metric_queue +from app import app, signer, storage, metric_queue, license_validator from auth.auth_context import get_authenticated_user from auth.permissions import ReadRepositoryPermission from auth.process import process_auth @@ -18,6 +18,7 @@ from endpoints.v2.blob import BLOB_DIGEST_ROUTE from image.appc import AppCImageFormatter from image.docker.squashed import SquashedDockerImageFormatter from storage import Storage +from util.license import enforce_license_before_request from util.registry.filelike import wrap_with_handler from util.registry.queuefile import QueueFile from util.registry.queueprocess import QueueProcess @@ -25,9 +26,11 @@ from util.registry.torrent import (make_torrent, per_user_torrent_filename, publ PieceHasher) -verbs = Blueprint('verbs', __name__) logger = logging.getLogger(__name__) +verbs = Blueprint('verbs', __name__) +enforce_license_before_request(license_validator, verbs) + def _open_stream(formatter, namespace, repository, tag, derived_image_id, repo_image, handlers): """ diff --git a/test/test_license.py b/test/test_license.py index 22840939f..802b036b7 100644 --- a/test/test_license.py +++ b/test/test_license.py @@ -9,8 +9,7 @@ from Crypto.PublicKey import RSA from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_der_public_key -from util.config.provider.license import (decode_license, LICENSE_PRODUCT_NAME, - LicenseValidationError) +from util.license import decode_license, LICENSE_PRODUCT_NAME, LicenseValidationError class TestLicense(unittest.TestCase): diff --git a/util/config/provider/baseprovider.py b/util/config/provider/baseprovider.py index c742f85e0..97360d309 100644 --- a/util/config/provider/baseprovider.py +++ b/util/config/provider/baseprovider.py @@ -1,18 +1,22 @@ -import yaml import logging +import yaml + +from util.license import LICENSE_FILENAME, LicenseError, decode_license -from util.config.provider.license import LICENSE_FILENAME, LicenseError, decode_license logger = logging.getLogger(__name__) + class CannotWriteConfigException(Exception): """ Exception raised when the config cannot be written. """ pass + class SetupIncompleteException(Exception): """ Exception raised when attempting to verify config that has not yet been setup. """ pass + def import_yaml(config_obj, config_file): with open(config_file) as f: c = yaml.safe_load(f) @@ -33,6 +37,7 @@ def import_yaml(config_obj, config_file): def get_yaml(config_obj): return yaml.safe_dump(config_obj, encoding='utf-8', allow_unicode=True) + def export_yaml(config_obj, config_file): try: with open(config_file, 'w') as f: diff --git a/util/config/provider/license.py b/util/license.py similarity index 72% rename from util/config/provider/license.py rename to util/license.py index d592d2fa7..62a0b97b6 100644 --- a/util/config/provider/license.py +++ b/util/license.py @@ -1,20 +1,29 @@ +import json import logging +import multiprocessing +import time -from dateutil import parser +from ctypes import c_bool from datetime import datetime, timedelta +from threading import Thread + from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_public_key +from dateutil import parser +from flask import abort import jwt -import json + logger = logging.getLogger(__name__) + TRIAL_GRACE_PERIOD = timedelta(7, 0) # 1 week MONTHLY_GRACE_PERIOD = timedelta(30, 0) # 1 month YEARLY_GRACE_PERIOD = timedelta(90, 0) # 3 months - LICENSE_PRODUCT_NAME = "quay-enterprise" +LICENSE_FILENAME = 'license' + class LicenseError(Exception): """ Exception raised if the license could not be read, decoded or has expired. """ @@ -115,9 +124,6 @@ class License(object): return True -LICENSE_FILENAME = 'license' - - _PROD_LICENSE_PUBLIC_KEY_DATA = """ -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuCkRnkuqox3A0djgRnHR @@ -129,8 +135,6 @@ ScjObTKaSUOGen6aYFF5Bd6V/ucxHmcmJlycwNZOKGFpbhLU173/oBJ+okvDbJpN qwIDAQAB -----END PUBLIC KEY----- """ - - _PROD_LICENSE_PUBLIC_KEY = load_pem_public_key(_PROD_LICENSE_PUBLIC_KEY_DATA, backend=default_backend()) @@ -150,3 +154,58 @@ def decode_license(license_contents, public_key_instance=None): raise LicenseDecodeError('Could not decode license found: %s' % ve.message) return License(decoded) + + +LICENSE_VALIDATION_INTERVAL = 3600 # seconds +LICENSE_VALIDATION_EXPIRED_INTERVAL = 60 # seconds + + +class LicenseValidator(Thread): + """ + LicenseValidator is a thread that asynchronously reloads and validates license files. + + This thread is meant to be run before registry gunicorn workers fork and uses shared memory as a + synchronization primitive. + """ + def __init__(self, license_path): + self._license_path = license_path + + # multiprocessing.Value does not ensure consistent write-after-reads, but we don't need that. + self._license_is_expired = multiprocessing.Value(c_bool, True) + + super(LicenseValidator, self).__init__() + + @property + def expired(self): + return self._license_is_expired.value + + def _check_expiration(self): + try: + with open(self._license_path) as f: + current_license = decode_license(f.read()) + logger.debug('updating license expiration to %s', current_license.is_expired) + self._license_is_expired.value = current_license.is_expired + return current_license.is_expired + except (IOError, LicenseError): + logger.exception('failed to validate license') + self._license_is_expired.value = True + return True + + def run(self): + logger.debug('Starting license validation thread') + while True: + expired = self._check_expiration() + sleep_time = LICENSE_VALIDATION_EXPIRED_INTERVAL if expired else LICENSE_VALIDATION_INTERVAL + logger.debug('waiting %d seconds before retrying to validate license', sleep_time) + time.sleep(sleep_time) + + +def enforce_license_before_request(license_validator, blueprint): + """ + Adds a pre-check to a Flask blueprint such that if the provided license_validator determines the + license has become invalid, the client will receive a HTTP 402 response. + """ + def _enforce_license(): + if license_validator.expired: + abort(402) + blueprint.before_request(_enforce_license)