Allow passing settings via environment variables (Issue #100)

This commit is contained in:
Daniel Perna 2018-06-28 23:38:24 +02:00
parent ec470ebb4a
commit c24654418b
4 changed files with 60 additions and 33 deletions

View file

@ -35,7 +35,8 @@ There are no dependencies on Python modules that are not part of the standard li
### Configuration ### Configuration
Near the top of the py-file you will find some global variables you can change to customize the configurator a little bit. If you are unfamiliar with Python: when setting variables of the type _string_, you have to write that within quotation marks. The default settings are fine for just checking this out quickly. With more customized setups you will have to change some settings though. Near the top of the py-file you will find some global variables you can change to customize the configurator a little bit. If you are unfamiliar with Python: when setting variables of the type _string_, you have to write that within quotation marks. The default settings are fine for just checking this out quickly. With more customized setups you will have to change some settings though.
To keep your setting across updates it is also possible to save settings in an external file. In that case copy [settings.conf](https://github.com/danielperna84/hass-configurator/blob/master/settings.conf) whereever you like and append the full path to the file to the command when starting the configurator. E.g. `sudo .configurator.py /home/homeassistant/.homeassistant/mysettings.conf`. This file is in JSON format. So make sure it has a valid syntax (you can set the editor to JSON to get syntax highlighting for the settings). The major difference to the settings in the py-file is, that `None` becomes `null`. To keep your setting across updates it is also possible to save settings in an external file. In that case copy [settings.conf](https://github.com/danielperna84/hass-configurator/blob/master/settings.conf) whereever you like and append the full path to the file to the command when starting the configurator. E.g. `sudo .configurator.py /home/homeassistant/.homeassistant/mysettings.conf`. This file is in JSON format. So make sure it has a valid syntax (you can set the editor to JSON to get syntax highlighting for the settings). The major difference to the settings in the py-file is, that `None` becomes `null`.
Another way of passing settings (except those defined as lists like e.g. `ALLOWED_NETWORKS`) is by using [environment variables](https://en.wikipedia.org/wiki/Environment_variable). All settings passed via environment variables will overwrite the settings you have set in the `settings.conf` file. This allows you to provide settings in you systemd service file or the way it is usually done with Docker. The names of the environment variables have to be named exactly like the regular ones, prepended with the prefix `HC_`. You can customize this prefix in the `settings.conf` by setting `ENV_PREFIX` to something you like. `ENV_PREFIX` can not be set via environment variable.
#### LISTENIP (string) #### LISTENIP (string)
The IP address the service is listening on. By default it is binding to `0.0.0.0`, which is every IPv4 interface on the system. When using `::`, all available IPv6- and IPv4-addresses will be used. The IP address the service is listening on. By default it is binding to `0.0.0.0`, which is every IPv4 interface on the system. When using `::`, all available IPv6- and IPv4-addresses will be used.
@ -72,6 +73,8 @@ If set to `true`, directories will be displayed at the top.
If set to _somesecretkeynobodycanguess_, you can browse to `https://your.configurator:3218/somesecretkeynobodycanguess` from any IP, and it will be removed from the `BANNED_IPS` list (in case it has been banned before) and added to the `ALLOWED_NETWORKS` list. Once the request has been processed you will automatically be redirected to the configurator. Think of this as dynamically allowing access from untrusted IPs by providing a secret key (_open sesame!_). Keep in mind, that once the IP has been added, you will either have to restart the configurator or manually remove the IP through the _Network status_ to revoke access. If set to _somesecretkeynobodycanguess_, you can browse to `https://your.configurator:3218/somesecretkeynobodycanguess` from any IP, and it will be removed from the `BANNED_IPS` list (in case it has been banned before) and added to the `ALLOWED_NETWORKS` list. Once the request has been processed you will automatically be redirected to the configurator. Think of this as dynamically allowing access from untrusted IPs by providing a secret key (_open sesame!_). Keep in mind, that once the IP has been added, you will either have to restart the configurator or manually remove the IP through the _Network status_ to revoke access.
#### VERIFY_HOSTNAME (string) #### VERIFY_HOSTNAME (string)
HTTP requests include the hostname to which the request has been made. To improve security you can set this parameter to `yourdomain.example.com`. This will check if the hostname within the request matches the one you are expecting. If it does not match, a `403 Forbidden` response will be sent. As a result attackers that scan your IP address won't be able to connect unless they know the correct hostname. Be careful with this option though, because it prohibits you from accessing the configurator directly via IP. HTTP requests include the hostname to which the request has been made. To improve security you can set this parameter to `yourdomain.example.com`. This will check if the hostname within the request matches the one you are expecting. If it does not match, a `403 Forbidden` response will be sent. As a result attackers that scan your IP address won't be able to connect unless they know the correct hostname. Be careful with this option though, because it prohibits you from accessing the configurator directly via IP.
#### ENV_PREFIX (string)
To modify the default prefix for settings passed as environment variables (`HC_`) change this setting to another value that meets your demands.
__Note regarding `ALLOWED_NETWORKS`, `BANNED_IPS` and `BANLIMIT`__: __Note regarding `ALLOWED_NETWORKS`, `BANNED_IPS` and `BANLIMIT`__:
The way this is implemented works in the following order: The way this is implemented works in the following order:

View file

@ -1,4 +1,5 @@
Version 0.3.0 (2018-) Version 0.3.0 (2018-)
- Allow passing settings (except lists) via environment variables (Issue #100) @danielperna84
Version 0.2.9 (2018-06-22) Version 0.2.9 (2018-06-22)
- Material Icons and HASS-help now open in new tab instead of modal (Issues #85 and #34) @danielperna84 - Material Icons and HASS-help now open in new tab instead of modal (Issues #85 and #34) @danielperna84

View file

@ -66,6 +66,8 @@ SESAME = None
# Verify the hostname used in the request. Block access if it doesn't match # Verify the hostname used in the request. Block access if it doesn't match
# this value # this value
VERIFY_HOSTNAME = None VERIFY_HOSTNAME = None
# Prefix for environment variables
ENV_PREFIX = "HC_"
### End of options ### End of options
LOGLEVEL = logging.INFO LOGLEVEL = logging.INFO
@ -3352,37 +3354,55 @@ def signal_handler(sig, frame):
def load_settings(settingsfile): def load_settings(settingsfile):
global LISTENIP, LISTENPORT, BASEPATH, SSL_CERTIFICATE, SSL_KEY, HASS_API, \ global LISTENIP, LISTENPORT, BASEPATH, SSL_CERTIFICATE, SSL_KEY, HASS_API, \
HASS_API_PASSWORD, CREDENTIALS, ALLOWED_NETWORKS, BANNED_IPS, BANLIMIT, \ HASS_API_PASSWORD, CREDENTIALS, ALLOWED_NETWORKS, BANNED_IPS, BANLIMIT, \
DEV, IGNORE_PATTERN, DIRSFIRST, SESAME, VERIFY_HOSTNAME, ENFORCE_BASEPATH DEV, IGNORE_PATTERN, DIRSFIRST, SESAME, VERIFY_HOSTNAME, ENFORCE_BASEPATH, \
try: ENV_PREFIX
if os.path.isfile(settingsfile): settings = {}
with open(settingsfile) as fptr: if settingsfile:
settings = json.loads(fptr.read()) try:
LISTENIP = settings.get("LISTENIP", LISTENIP) if os.path.isfile(settingsfile):
LISTENPORT = settings.get("LISTENPORT", LISTENPORT) with open(settingsfile) as fptr:
BASEPATH = settings.get("BASEPATH", BASEPATH) settings = json.loads(fptr.read())
ENFORCE_BASEPATH = settings.get("ENFORCE_BASEPATH", ENFORCE_BASEPATH) except Exception as err:
SSL_CERTIFICATE = settings.get("SSL_CERTIFICATE", SSL_CERTIFICATE) LOG.warning(err)
SSL_KEY = settings.get("SSL_KEY", SSL_KEY) LOG.warning("Not loading settings from file")
HASS_API = settings.get("HASS_API", HASS_API) ENV_PREFIX = settings.get('ENV_PREFIX', ENV_PREFIX)
HASS_API_PASSWORD = settings.get("HASS_API_PASSWORD", HASS_API_PASSWORD) for key, value in os.environ.items():
CREDENTIALS = settings.get("CREDENTIALS", CREDENTIALS) if key.startswith(ENV_PREFIX):
ALLOWED_NETWORKS = settings.get("ALLOWED_NETWORKS", ALLOWED_NETWORKS) # Convert booleans
BANNED_IPS = settings.get("BANNED_IPS", BANNED_IPS) if value in ['true', 'false', 'True', 'False']:
BANLIMIT = settings.get("BANLIMIT", BANLIMIT) value = True if value in ['true', 'True'] else False
DEV = settings.get("DEV", DEV) # Convert None / null
IGNORE_PATTERN = settings.get("IGNORE_PATTERN", IGNORE_PATTERN) elif value in ['none', 'None' 'null']:
DIRSFIRST = settings.get("DIRSFIRST", DIRSFIRST) value = None
SESAME = settings.get("SESAME", SESAME) # Convert plain numbers
VERIFY_HOSTNAME = settings.get("VERIFY_HOSTNAME", VERIFY_HOSTNAME) elif value.isnumeric():
except Exception as err: value = int(value)
LOG.warning(err) settings[key[len(ENV_PREFIX):]] = value
LOG.warning("Not loading static settings") LISTENIP = settings.get("LISTENIP", LISTENIP)
return False LISTENPORT = settings.get("LISTENPORT", LISTENPORT)
BASEPATH = settings.get("BASEPATH", BASEPATH)
ENFORCE_BASEPATH = settings.get("ENFORCE_BASEPATH", ENFORCE_BASEPATH)
SSL_CERTIFICATE = settings.get("SSL_CERTIFICATE", SSL_CERTIFICATE)
SSL_KEY = settings.get("SSL_KEY", SSL_KEY)
HASS_API = settings.get("HASS_API", HASS_API)
HASS_API_PASSWORD = settings.get("HASS_API_PASSWORD", HASS_API_PASSWORD)
CREDENTIALS = settings.get("CREDENTIALS", CREDENTIALS)
ALLOWED_NETWORKS = settings.get("ALLOWED_NETWORKS", ALLOWED_NETWORKS)
BANNED_IPS = settings.get("BANNED_IPS", BANNED_IPS)
BANLIMIT = settings.get("BANLIMIT", BANLIMIT)
DEV = settings.get("DEV", DEV)
IGNORE_PATTERN = settings.get("IGNORE_PATTERN", IGNORE_PATTERN)
DIRSFIRST = settings.get("DIRSFIRST", DIRSFIRST)
SESAME = settings.get("SESAME", SESAME)
VERIFY_HOSTNAME = settings.get("VERIFY_HOSTNAME", VERIFY_HOSTNAME)
def is_safe_path(basedir, path, follow_symlinks=True): def is_safe_path(basedir, path, follow_symlinks=True):
if basedir is None:
return True
if follow_symlinks: if follow_symlinks:
return os.path.realpath(path).startswith(basedir) return os.path.realpath(path).startswith(basedir.encode('utf-8'))
return os.path.abspath(path).startswith(basedir) return os.path.abspath(path).startswith(basedir.encode('utf-8'))
def get_dircontent(path, repo=None): def get_dircontent(path, repo=None):
dircontent = [] dircontent = []
@ -3520,7 +3540,7 @@ class RequestHandler(BaseHTTPRequestHandler):
try: try:
if filename: if filename:
filename = unquote(filename[0]).encode('utf-8') filename = unquote(filename[0]).encode('utf-8')
if ENFORCE_BASEPATH and not is_safe_path(BASEPATH.encode('utf-8'), filename): if ENFORCE_BASEPATH and not is_safe_path(BASEPATH, filename):
raise OSError('Access denied.') raise OSError('Access denied.')
if os.path.isfile(os.path.join(BASEDIR.encode('utf-8'), filename)): if os.path.isfile(os.path.join(BASEDIR.encode('utf-8'), filename)):
with open(os.path.join(BASEDIR.encode('utf-8'), filename)) as fptr: with open(os.path.join(BASEDIR.encode('utf-8'), filename)) as fptr:
@ -3538,7 +3558,7 @@ class RequestHandler(BaseHTTPRequestHandler):
try: try:
if filename: if filename:
filename = unquote(filename[0]).encode('utf-8') filename = unquote(filename[0]).encode('utf-8')
if ENFORCE_BASEPATH and not is_safe_path(BASEPATH.encode('utf-8'), filename): if ENFORCE_BASEPATH and not is_safe_path(BASEPATH, filename):
raise OSError('Access denied.') raise OSError('Access denied.')
LOG.info(filename) LOG.info(filename)
if os.path.isfile(os.path.join(BASEDIR.encode('utf-8'), filename)): if os.path.isfile(os.path.join(BASEDIR.encode('utf-8'), filename)):
@ -3565,7 +3585,7 @@ class RequestHandler(BaseHTTPRequestHandler):
if dirpath: if dirpath:
dirpath = unquote(dirpath[0]).encode('utf-8') dirpath = unquote(dirpath[0]).encode('utf-8')
if os.path.isdir(dirpath): if os.path.isdir(dirpath):
if ENFORCE_BASEPATH and not is_safe_path(BASEPATH.encode('utf-8'), dirpath): if ENFORCE_BASEPATH and not is_safe_path(BASEPATH, dirpath):
raise OSError('Access denied.') raise OSError('Access denied.')
repo = None repo = None
activebranch = None activebranch = None
@ -4396,6 +4416,8 @@ def main(args):
global HTTPD, CREDENTIALS global HTTPD, CREDENTIALS
if args: if args:
load_settings(args[0]) load_settings(args[0])
else:
load_settings(None)
LOG.info("Starting server") LOG.info("Starting server")
CustomServer = SimpleServer CustomServer = SimpleServer
if ':' in LISTENIP: if ':' in LISTENIP:

View file

@ -14,5 +14,6 @@
"IGNORE_PATTERN": [], "IGNORE_PATTERN": [],
"DIRSFIRST": false, "DIRSFIRST": false,
"SESAME": null, "SESAME": null,
"VERIFY_HOSTNAME": null "VERIFY_HOSTNAME": null,
"ENV_PREFIX": "HC_"
} }