diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a48e76 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.8-slim + +RUN pip install serles-acme + +COPY . /app + +WORKDIR /app + +EXPOSE 8443 + +# CMD ["python", "-m", "serles"] +CMD ["gunicorn", "-c", "gunicorn.conf.py", "serles:create_app()"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..57b0240 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3' +services: + app: + build: . + ports: + - "8443:8443" + environment: + - CONFIG=./config.ini + volumes: + - .:/app diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..10b9676 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +import multiprocessing +import os + +from distutils.util import strtobool + + +bind = os.getenv('WEB_BIND', '0.0.0.0:8443') +accesslog = '-' +access_log_format = "%(h)s %(l)s %(u)s %(t)s '%(r)s' %(s)s %(b)s '%(f)s' '%(a)s' in %(D)sµs" # noqa: E501 +timeout = 300 + +workers = int(os.getenv('WEB_CONCURRENCY', multiprocessing.cpu_count() * 2)) +threads = int(os.getenv('PYTHON_MAX_THREADS', 1)) + +reload = bool(strtobool(os.getenv('WEB_RELOAD', 'false'))) diff --git a/serles/__init__.py b/serles/__init__.py index 2268f85..6c9e886 100644 --- a/serles/__init__.py +++ b/serles/__init__.py @@ -15,17 +15,21 @@ def create_app(): This function should be passed to the WSGI server. """ config, _ = get_config() - + # print(config) app = Flask(__name__) - app.config["PROPAGATE_EXCEPTIONS"] = True # makes @app.errorhandler handle events + # makes @app.errorhandler handle events + app.config["PROPAGATE_EXCEPTIONS"] = True app.config["SQLALCHEMY_DATABASE_URI"] = config["database"] app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SERVER_NAME"] = config["server_name"] + app.config['PREFERRED_URL_SCHEME'] = 'https' init_config() # views.init_config() api.init_app(app) db.init_app(app) - db.create_all(app=app) # Note: model classes must be defined at this point + # db.create_all(app=app) # Note: model classes must be defined at this point + with app.app_context(): + db.create_all() @app.route('/') def HomePage(): @@ -36,7 +40,8 @@ def HomePage(): app.after_request(inject_nonce) app.after_request(index_header) - @background_job(60) # purge unused nonces every minute (keeps database small) + # purge unused nonces every minute (keeps database small) + @background_job(60) def purge_nonces(): with app.app_context(): Nonces.purge_expired() diff --git a/serles/configloader.py b/serles/configloader.py index 3d4071b..b4c4db0 100644 --- a/serles/configloader.py +++ b/serles/configloader.py @@ -11,6 +11,7 @@ def get_config(): Returns: (dict, class): A tuple of ``config``, ``backend``. """ + print("path ", os.environ.get("CONFIG", "/etc/serles/config.ini")) config, backend = load_config_and_backend( os.environ.get("CONFIG", "/etc/serles/config.ini") ) @@ -82,7 +83,8 @@ class and the parsed config (dict-like) ipaddress.ip_network(cidr) for cidr in ranges if cidr ] except KeyError: - config["allowedServerIpRanges"] = None # if not defined, allow from everywhere. + # if not defined, allow from everywhere. + config["allowedServerIpRanges"] = None try: ranges = cparser["serles"]["excludeServerIpRanges"].splitlines() @@ -107,8 +109,10 @@ class and the parsed config (dict-like) ) from None try: - config["verifyPTR"] = cparser["serles"].getboolean("verifyPTR", fallback=False) + config["verifyPTR"] = cparser["serles"].getboolean( + "verifyPTR", fallback=False) except ValueError: - raise ConfigError("[serles]verifyPTR= must be 'true' or 'false'") from None + raise ConfigError( + "[serles]verifyPTR= must be 'true' or 'false'") from None return config, backend diff --git a/serles/views.py b/serles/views.py index 2f46b4f..de5d975 100644 --- a/serles/views.py +++ b/serles/views.py @@ -27,10 +27,11 @@ def get(self): """ Displays the URLs for accessing certain functions, and some metadata. """ + return { - "newNonce": api.url_for(NewNonce, _external=True), - "newAccount": api.url_for(NewAccount, _external=True), - "newOrder": api.url_for(NewOrder, _external=True), + "newNonce": api.url_for(NewNonce, _external=True, _scheme='https'), + "newAccount": api.url_for(NewAccount, _external=True, _scheme='https'), + "newOrder": api.url_for(NewOrder, _external=True, _scheme='https'), # "newAuthz": MUST be absent if pre-authorization not supported # "revokeCert": not offered # optional: meta:{termsOfService"",website"",caaIdentities[""],externalAccountRequired?} @@ -61,7 +62,8 @@ def post(self): contact = g.payload.get("contact", []) contact = contact[0] if len(contact) > 0 else None # only 1 email! if contact and not contact.startswith("mailto:"): - raise ACMEError("only (one) email supported", 400, "unsupportedContact") + raise ACMEError("only (one) email supported", + 400, "unsupportedContact") if contact: contact = contact.replace("mailto:", "") termsOfServiceAgreed = g.payload.get("termsOfServiceAgreed", False) @@ -84,7 +86,8 @@ def post(self): else: # At this point, the user has no account, but wants one account = Account(jwk=jwk_pem, contact=contact) db.session.add(account) - db.session.commit() # note: accessing `account` after the commit requires setting expire_on_commit=False + # note: accessing `account` after the commit requires setting expire_on_commit=False + db.session.commit() preexisting = False return ( @@ -101,7 +104,8 @@ def post(self): Submit a new Order. The request will include a list of Identifers (domain names) the client wants on the certificate. """ - notBefore = g.payload.get("notBefore") # optional, we ignore it for now + notBefore = g.payload.get( + "notBefore") # optional, we ignore it for now notAfter = g.payload.get("notAfter") # optional, we ignore it for now identifiers = g.payload.get("identifiers") if not identifiers: @@ -144,11 +148,13 @@ def post(self): ) db.session.add(order) - db.session.commit() # note: accessing `order` after the commit requires setting expire_on_commit=False + # note: accessing `order` after the commit requires setting expire_on_commit=False + db.session.commit() return ( order.serialized, 201, - {"Location": api.url_for(OrderMain, orderid=order.id, _external=True)}, + {"Location": api.url_for( + OrderMain, orderid=order.id, _external=True)}, ) @@ -158,7 +164,8 @@ class NewAuthz(Resource): pass -@api.resource("/revokeCert") # RFC8555 §7.6 (Certificate Revocation, not offered) +# RFC8555 §7.6 (Certificate Revocation, not offered) +@api.resource("/revokeCert") class RevokeCert(Resource): "not offered." pass @@ -177,7 +184,8 @@ def post(self, kid): JSON-serialized Account object (post-update). """ if kid != g.kid: - raise ACMEError(f"{kid}, {g.kid}Unexpected Account ID", 403, "unauthorized") + raise ACMEError( + f"{kid}, {g.kid}Unexpected Account ID", 403, "unauthorized") account = Account.query.filter_by(id=kid).first() if not account: raise ACMEError("", 400, "accountDoesNotExist") @@ -188,11 +196,13 @@ def post(self, kid): if contact is not None: contact = contact[0] if len(contact) > 0 else None # only 1 email! if contact and not contact.startswith("mailto:"): - raise ACMEError("only (one) email supported", 400, "unsupportedContact") + raise ACMEError("only (one) email supported", + 400, "unsupportedContact") if contact: contact = contact.replace("mailto:", "") account.contact = contact - db.session.commit() # note: accessing `account` after the commit requires setting expire_on_commit=False + # note: accessing `account` after the commit requires setting expire_on_commit=False + db.session.commit() return account.serialized @@ -324,5 +334,6 @@ def post(self, certid): pem_cert = pkcs7_to_pem_chain(cert) return make_response( - pem_cert, 200, {"Content-Type": "application/pem-certificate-chain"} + pem_cert, 200, { + "Content-Type": "application/pem-certificate-chain"} )