12-factorize application a little

snikket_web can now be fully configured via the environment alone,
no extra files needed. It is still supported to inject a python
file to generate environment variables though, which may be
useful for generating and reloading a secret key.
This commit is contained in:
Jonas Schäfer
2021-01-17 11:00:42 +01:00
parent fadbdaf204
commit e0cfcc6aaa
12 changed files with 115 additions and 98 deletions

2
.envrc
View File

@@ -2,4 +2,4 @@ layout python3
export QUART_APP='snikket_web:create_app()'
export QUART_ENV=development
export SNIKKET_WEB_CONFIG="$(pwd)/.local/web_config.py"
export QUART_DEBUG=1

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ __pycache__
/snikket_web/static/css/*.css
/snikket_web/translations/messages.pot
/snikket_web/translations/*/LC_MESSAGES/*.mo
/.env

View File

@@ -2,15 +2,34 @@
![Screenshot of the app](docs/readme-screenshot.png)
## Start the dev server
## Development quickstart
```console
$ direnv allow
$ mkdir .local
$ cp web_config.example.py .local/web_config.py
$ $EDITOR .local/web_config.py # to adapt the configuration to your needs
$ cp example.env .env
$ $EDITOR .env # to adapt the configuration to your needs
$ pip install -r requirements.txt
$ pip install -r build-requirements.txt
$ make
$ quart run
```
## Configuring
### Purely via environment variables
For a list of required and understood environment variables as well as their
semantics, please refer to [`example.env`](example.env).
### Via python code
In addition to statically setting environment variables, it is possible to
initialise the environment variables in a python file. To do that, pass the
path to the python file as `SNIKKET_WEB_PYENV` environment variable.
The python file is evaluated before further environment variable processing
takes place. Every name defined in that file which begins with an upper case
ASCII letter is included in the processing of environment variables for
configuration purposes.
For a (non-productive) example of such a file, see `example.env.py`.

View File

@@ -1,2 +1,3 @@
pyscss~=1.3
mypy
python-dotenv~=0.15

View File

@@ -45,8 +45,8 @@ RUN set -eu; \
rm -rf /root/.cache; \
apt-get clean ; rm -rf /var/lib/apt/lists
COPY web_config.production.py /opt/snikket-web-portal/.local/web_config.py
ENV SNIKKET_WEB_CONFIG "/opt/snikket-web-portal/.local/web_config.py"
COPY docker/env.py /etc/snikket-web-portal/env.py
ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py
ADD docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

25
docker/env.py Normal file
View File

@@ -0,0 +1,25 @@
import os
import secrets
import sys
_secret_key_path = "/etc/snikket-web-portal/secret_key"
if "SNIKKET_WEB_SECRET_KEY" in os.environ:
print("Using SNIKKET_WEB_SECRET_KEY from environment")
else:
try:
with open(_secret_key_path, "r") as f:
SNIKKET_WEB_SECRET_KEY = f.read()
print("Restored SNIKKET_WEB_SECRET_KEY from", _secret_key_path)
except FileNotFoundError:
print("Generating SNIKKET_WEB_SECRET_KEY ...")
SNIKKET_WEB_SECRET_KEY = secrets.token_urlsafe(nbytes=32)
old_mask = os.umask(0o077)
with open(_secret_key_path, "x") as f:
f.write(SNIKKET_WEB_SECRET_KEY)
os.umask(old_mask)
print("SNIKKET_WEB_SECRET_KEY persisted to", _secret_key_path)
# Ensure that the above output is printed, even if nothing else is.
sys.stdout.flush()
sys.stderr.flush()

View File

@@ -15,8 +15,7 @@
# - attackers may be able to execute things on a properly authenticated users
# behalf.
# - other bad things.
import secrets
SECRET_KEY = secrets.token_urlsafe(nbytes=32)
SNIKKET_WEB_SECRET_KEY=
# URL (without trailing /) of the prosody HTTP server.
#
@@ -24,12 +23,12 @@ SECRET_KEY = secrets.token_urlsafe(nbytes=32)
#
# NOTE: If this does not point at localhost, it MUST use https. Otherwise,
# passwords will be transmitted in plaintext through insecure channels.
PROSODY_ENDPOINT = "http://localhost:5280"
SNIKKET_WEB_PROSODY_ENDPOINT='http://localhost:5280'
# The domain name of the Snikket server
#
# This must be set for login to work correctly.
SNIKKET_DOMAIN = "localhost"
SNIKKET_WEB_DOMAIN='localhost'
# OPTIONAL SETTINGS
@@ -41,14 +40,4 @@ SNIKKET_DOMAIN = "localhost"
# of an avatar is still up-to-date on every request; if it is, the avatar is
# not re-transferred.
#
# AVATAR_CACHE_TTL = 1800
# Which languages to offer
#
# Generally, the web portal will offer all languages it has available. There
# is little point in restricting this, unless if youre in a situation where
# the release youre on has a terrible translation of a specific language
# and not offering that language at all is better than having that terrible
# translation.
#
# LANGUAGES = ["de", "en"]
# SNIKKET_WEB_AVATAR_CACHE_TTL = 1800

11
example.env.py Normal file
View File

@@ -0,0 +1,11 @@
# Please see example.env for a detailed list of supported environment
# variables as well as their semantics.
# NOTE: this file is not meant for production use. Due to the non-constant
# secret key, each server restart will log out all users from the web portal.
import secrets
SNIKKET_WEB_SECRET_KEY = secrets.token_urlsafe(nbytes=32)
SNIKKET_WEB_PROSODY_ENDPOINT = "http://localhost:5280"
SNIKKET_WEB_DOMAIN = "localhost"
# SNIKKET_WEB_AVATAR_CACHE_TTL = 1800

View File

@@ -29,3 +29,6 @@ ignore_missing_imports = True
[mypy-wtforms.*]
ignore_missing_imports = True
[mypy-environ.*]
ignore_missing_imports = True

View File

@@ -4,4 +4,5 @@ flask-wtf~=0.14
hsluv~=0.0.2
flask-babel~=1.0
email-validator~=1.1
environ-config~=20.0
typing-extensions

View File

@@ -1,6 +1,7 @@
import base64
import binascii
import logging
import os
import pathlib
import typing
@@ -11,6 +12,8 @@ from quart import (
url_for,
)
import environ
from . import colour, infra
from ._version import version, version_info # noqa:F401
@@ -36,10 +39,48 @@ def proc() -> typing.Dict[str, typing.Any]:
}
def autosplit(s: typing.Union[str, typing.List[str]]) -> typing.List[str]:
if isinstance(s, str):
return s.split()
return s
@environ.config(prefix="SNIKKET_WEB")
class AppConfig:
secret_key = environ.var()
prosody_endpoint = environ.var()
domain = environ.var()
avatar_cache_ttl = environ.var(1800, converter=int)
languages = environ.var(["de", "en"], converter=autosplit)
_UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1)))
def create_app() -> quart.Quart:
try:
env_init = os.environ["SNIKKET_WEB_PYENV"]
except KeyError:
pass
else:
import runpy
init_vars = runpy.run_path(env_init)
for name, value in init_vars.items():
if not name:
continue
if name[0] not in _UPPER_CASE:
continue
os.environ[name] = value
config = environ.to_config(AppConfig)
app = quart.Quart(__name__)
app.config.setdefault("LANGUAGES", ["de", "en"])
app.config.from_envvar("SNIKKET_WEB_CONFIG")
app.config["LANGUAGES"] = config.languages
app.config["SECRET_KEY"] = config.secret_key
app.config["PROSODY_ENDPOINT"] = config.prosody_endpoint
app.config["SNIKKET_DOMAIN"] = config.domain
app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl
app.context_processor(proc)
logging_config = app.config.get("LOGGING_CONFIG")

View File

@@ -1,74 +0,0 @@
# REQUIRED SETTINGS
# =================
# Secret key used to guard forms and sessions.
#
# This must be both reasonably constant and secret. If the secret gets
# compromised, you can change it (without having to worry about the "constant"
# requirement).
#
# if not constant:
# - sessions will be lost on each server restart
#
# if not secret:
# - users may be able to forge sessions
# - attackers may be able to execute things on a properly authenticated users
# behalf.
# - other bad things.
import os
import sys
import secrets
try:
SECRET_KEY = os.environ['SECRET_KEY']
except KeyError:
print('SECRET_KEY was not provided. It will be automatically generated. '
'To avoid losing sessions on each server restart, please provide '
'a SECRET_KEY.',
file=sys.stderr)
SECRET_KEY = secrets.token_urlsafe(nbytes=32)
# URL (without trailing /) of the prosody HTTP server.
#
# This must be set for anything to work correctly.
#
# NOTE: If this does not point at localhost, it MUST use https. Otherwise,
# passwords will be transmitted in plaintext through insecure channels.
try:
PROSODY_ENDPOINT = os.environ['PROSODY_ENDPOINT']
except KeyError as e:
print(f'Environment variable {e} must be set for the web portal to work',
file=sys.stderr)
sys.exit(2)
# The domain name of the Snikket server
#
# This must be set for login to work correctly.
try:
SNIKKET_DOMAIN = os.environ['SNIKKET_DOMAIN']
except KeyError as e:
print(f'Environment variable {e} must be set for the web portal to work',
file=sys.stderr)
sys.exit(2)
# OPTIONAL SETTINGS
# =================
# How long browers may cache avatars
#
# Setting this to zero forces browsers to check if their locally cached copy
# of an avatar is still up-to-date on every request; if it is, the avatar is
# not re-transferred.
#
# AVATAR_CACHE_TTL = 1800
# Which languages to offer
#
# Generally, the web portal will offer all languages it has available. There
# is little point in restricting this, unless if youre in a situation where
# the release youre on has a terrible translation of a specific language
# and not offering that language at all is better than having that terrible
# translation.
#
# LANGUAGES = ["de", "en"]