From d7f3610b51c691a3cebb5751eb5b8e9c45ec65db Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Thu, 13 Apr 2023 13:34:41 -0400 Subject: [PATCH 01/43] Updated the OAuth service providers to properly respect the new OAuth autoconfiguration settings for each provider. --- powerdnsadmin/services/azure.py | 3 ++- powerdnsadmin/services/github.py | 3 ++- powerdnsadmin/services/google.py | 3 ++- powerdnsadmin/services/oidc.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/powerdnsadmin/services/azure.py b/powerdnsadmin/services/azure.py index 901cc45..faf1ac3 100644 --- a/powerdnsadmin/services/azure.py +++ b/powerdnsadmin/services/azure.py @@ -24,9 +24,10 @@ def azure_oauth(): 'fetch_token': fetch_azure_token, } + auto_configure = Setting().get('azure_oauth_auto_configure') server_metadata_url = Setting().get('azure_oauth_metadata_url') - if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: + if auto_configure and isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url else: authlib_params['access_token_url'] = Setting().get('azure_oauth_token_url') diff --git a/powerdnsadmin/services/github.py b/powerdnsadmin/services/github.py index f322e8c..42862e9 100644 --- a/powerdnsadmin/services/github.py +++ b/powerdnsadmin/services/github.py @@ -26,9 +26,10 @@ def github_oauth(): 'update_token': update_token } + auto_configure = Setting().get('github_oauth_auto_configure') server_metadata_url = Setting().get('github_oauth_metadata_url') - if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: + if auto_configure and isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url else: authlib_params['access_token_url'] = Setting().get('github_oauth_token_url') diff --git a/powerdnsadmin/services/google.py b/powerdnsadmin/services/google.py index 011c120..e3e6362 100644 --- a/powerdnsadmin/services/google.py +++ b/powerdnsadmin/services/google.py @@ -25,9 +25,10 @@ def google_oauth(): 'update_token': update_token } + auto_configure = Setting().get('google_oauth_auto_configure') server_metadata_url = Setting().get('google_oauth_metadata_url') - if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: + if auto_configure and isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url else: authlib_params['access_token_url'] = Setting().get('google_token_url') diff --git a/powerdnsadmin/services/oidc.py b/powerdnsadmin/services/oidc.py index 25c73f0..2e36070 100644 --- a/powerdnsadmin/services/oidc.py +++ b/powerdnsadmin/services/oidc.py @@ -25,9 +25,10 @@ def oidc_oauth(): 'update_token': update_token } + auto_configure = Setting().get('oidc_oauth_auto_configure') server_metadata_url = Setting().get('oidc_oauth_metadata_url') - if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: + if auto_configure and isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url else: authlib_params['access_token_url'] = Setting().get('oidc_oauth_token_url') From 8cfc62e9d045a63c81dd7a1079ccb552d9e18f2d Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Thu, 13 Apr 2023 13:40:06 -0400 Subject: [PATCH 02/43] Corrected issue with `SERVER_EXTERNAL_SSL` setting not being extracted from the app's environment. --- configs/development.py | 2 +- configs/docker_config.py | 2 +- powerdnsadmin/default_config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/configs/development.py b/configs/development.py index 3dbb17b..be6cf3b 100644 --- a/configs/development.py +++ b/configs/development.py @@ -7,7 +7,7 @@ SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu' SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' BIND_ADDRESS = '0.0.0.0' PORT = 9191 -SERVER_EXTERNAL_SSL = None +SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', None) ### DATABASE CONFIG SQLA_DB_USER = 'pda' diff --git a/configs/docker_config.py b/configs/docker_config.py index 4194aa9..ae2e275 100644 --- a/configs/docker_config.py +++ b/configs/docker_config.py @@ -6,7 +6,7 @@ import json # Defaults for Docker image BIND_ADDRESS = '0.0.0.0' PORT = 80 -SERVER_EXTERNAL_SSL = True +SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db' SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_HTTPONLY = True diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py index 723cb85..d9e2759 100644 --- a/powerdnsadmin/default_config.py +++ b/powerdnsadmin/default_config.py @@ -8,7 +8,7 @@ SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' BIND_ADDRESS = '0.0.0.0' PORT = 9191 HSTS_ENABLED = False -SERVER_EXTERNAL_SSL = True +SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) SESSION_TYPE = 'sqlalchemy' SESSION_COOKIE_SAMESITE = 'Lax' From d773e078f5f897f78f0d245e0869836654687892 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Thu, 13 Apr 2023 13:53:39 -0400 Subject: [PATCH 03/43] Updated the Docker image build workflow to hopefully push the additional platform builds to Docker Hub after the build is complete. --- .github/workflows/build-and-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 6aaaaeb..9283860 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -70,9 +70,10 @@ jobs: tags: powerdnsadmin/pda-legacy:${{ github.ref_name }} - name: Docker Image Release Tagging - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v4 if: ${{ startsWith(github.ref, 'refs/tags/v') }} with: + platforms: linux/amd64,linux/arm64 context: ./ file: ./docker/Dockerfile push: true From 7eee3134d44dd2a8777a8343326be72ec02103d4 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Thu, 13 Apr 2023 16:09:10 -0400 Subject: [PATCH 04/43] Corrected the logic flaw in the account add / edit form that would cause all unassigned zones to be selected automatically following the attempt of account creation with an invalid name. --- powerdnsadmin/templates/admin_edit_account.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/powerdnsadmin/templates/admin_edit_account.html b/powerdnsadmin/templates/admin_edit_account.html index ca08ab7..827a3e0 100644 --- a/powerdnsadmin/templates/admin_edit_account.html +++ b/powerdnsadmin/templates/admin_edit_account.html @@ -133,7 +133,7 @@ {% endwith %} {% else %} - From c842d09195a6180b1f7db51c2ce20fa48d2fe128 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 07:12:02 -0400 Subject: [PATCH 05/43] Overhauled app settings implementation to remove redundancy of definitions. Additionally, re-factored settings initialization code to allow for every setting to be defined by environment variable for both bare metal and Docker container deployments. --- configs/docker_config.py | 151 +------- powerdnsadmin/__init__.py | 5 +- powerdnsadmin/decorators.py | 65 +++- powerdnsadmin/default_config.py | 48 +-- powerdnsadmin/lib/settings.py | 629 ++++++++++++++++++++++++++++++++ powerdnsadmin/models/setting.py | 530 ++------------------------- powerdnsadmin/routes/admin.py | 6 +- powerdnsadmin/routes/domain.py | 4 +- 8 files changed, 734 insertions(+), 704 deletions(-) create mode 100644 powerdnsadmin/lib/settings.py diff --git a/configs/docker_config.py b/configs/docker_config.py index ae2e275..1668fc7 100644 --- a/configs/docker_config.py +++ b/configs/docker_config.py @@ -1,151 +1,2 @@ -# import everything from environment variables -import os -import sys -import json - -# Defaults for Docker image -BIND_ADDRESS = '0.0.0.0' PORT = 80 -SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) -SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db' -SESSION_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_HTTPONLY = True -SESSION_TYPE = 'sqlalchemy' - -legal_envvars = ( - 'SECRET_KEY', - 'OIDC_OAUTH_ENABLED', - 'OIDC_OAUTH_KEY', - 'OIDC_OAUTH_SECRET', - 'OIDC_OAUTH_API_URL', - 'OIDC_OAUTH_TOKEN_URL', - 'OIDC_OAUTH_AUTHORIZE_URL', - 'OIDC_OAUTH_METADATA_URL', - 'OIDC_OAUTH_LOGOUT_URL', - 'OIDC_OAUTH_SCOPE', - 'OIDC_OAUTH_USERNAME', - 'OIDC_OAUTH_FIRSTNAME', - 'OIDC_OAUTH_LAST_NAME', - 'OIDC_OAUTH_EMAIL', - 'BIND_ADDRESS', - 'PORT', - 'SERVER_EXTERNAL_SSL', - 'LOG_LEVEL', - 'SALT', - 'SQLALCHEMY_TRACK_MODIFICATIONS', - 'SQLALCHEMY_DATABASE_URI', - 'SQLALCHEMY_ENGINE_OPTIONS', - 'MAIL_SERVER', - 'MAIL_PORT', - 'MAIL_DEBUG', - 'MAIL_USE_TLS', - 'MAIL_USE_SSL', - 'MAIL_USERNAME', - 'MAIL_PASSWORD', - 'MAIL_DEFAULT_SENDER', - 'SAML_ENABLED', - 'SAML_DEBUG', - 'SAML_PATH', - 'SAML_METADATA_URL', - 'SAML_METADATA_CACHE_LIFETIME', - 'SAML_IDP_SSO_BINDING', - 'SAML_IDP_ENTITY_ID', - 'SAML_NAMEID_FORMAT', - 'SAML_ATTRIBUTE_EMAIL', - 'SAML_ATTRIBUTE_GIVENNAME', - 'SAML_ATTRIBUTE_SURNAME', - 'SAML_ATTRIBUTE_NAME', - 'SAML_ATTRIBUTE_USERNAME', - 'SAML_ATTRIBUTE_ADMIN', - 'SAML_ATTRIBUTE_GROUP', - 'SAML_GROUP_ADMIN_NAME', - 'SAML_GROUP_TO_ACCOUNT_MAPPING', - 'SAML_ATTRIBUTE_ACCOUNT', - 'SAML_SP_ENTITY_ID', - 'SAML_SP_CONTACT_NAME', - 'SAML_SP_CONTACT_MAIL', - 'SAML_SIGN_REQUEST', - 'SAML_WANT_MESSAGE_SIGNED', - 'SAML_LOGOUT', - 'SAML_LOGOUT_URL', - 'SAML_ASSERTION_ENCRYPTED', - 'REMOTE_USER_LOGOUT_URL', - 'REMOTE_USER_COOKIES', - 'SIGNUP_ENABLED', - 'LOCAL_DB_ENABLED', - 'LDAP_ENABLED', - 'SAML_CERT', - 'SAML_KEY', - 'SESSION_TYPE', - 'SESSION_COOKIE_SECURE', - 'CSRF_COOKIE_SECURE', - 'CAPTCHA_ENABLE', -) - -legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME') - -legal_envvars_bool = ( - 'SQLALCHEMY_TRACK_MODIFICATIONS', - 'HSTS_ENABLED', - 'MAIL_DEBUG', - 'MAIL_USE_TLS', - 'MAIL_USE_SSL', - 'OIDC_OAUTH_ENABLED', - 'SAML_ENABLED', - 'SAML_DEBUG', - 'SAML_SIGN_REQUEST', - 'SAML_WANT_MESSAGE_SIGNED', - 'SAML_LOGOUT', - 'SAML_ASSERTION_ENCRYPTED', - 'REMOTE_USER_ENABLED', - 'SIGNUP_ENABLED', - 'LOCAL_DB_ENABLED', - 'LDAP_ENABLED', - 'SESSION_COOKIE_SECURE', - 'CSRF_COOKIE_SECURE', - 'CAPTCHA_ENABLE', - 'SERVER_EXTERNAL_SSL', -) - -legal_envvars_dict = ( - 'SQLALCHEMY_ENGINE_OPTIONS', -) - -def str2bool(v): - return v.lower() in ("true", "yes", "1") - - -def dictfromstr(v, ret): - try: - return json.loads(ret) - except Exception as e: - print('Cannot parse json {} for variable {}'.format(ret, v)) - print(e) - raise ValueError - - -for v in legal_envvars: - - ret = None - # _FILE suffix will allow to read value from file, useful for Docker containers. - # secrets feature - if v + '_FILE' in os.environ: - if v in os.environ: - raise AttributeError( - "Both {} and {} are set but are exclusive.".format( - v, v + '_FILE')) - with open(os.environ[v + '_FILE']) as f: - ret = f.read() - f.close() - - elif v in os.environ: - ret = os.environ[v] - - if ret is not None: - if v in legal_envvars_bool: - ret = str2bool(ret) - if v in legal_envvars_int: - ret = int(ret) - if v in legal_envvars_dict: - ret = dictfromstr(v, ret) - sys.modules[__name__].__dict__[v] = ret +SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db' \ No newline at end of file diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index d447a00..32ad19e 100755 --- a/powerdnsadmin/__init__.py +++ b/powerdnsadmin/__init__.py @@ -4,11 +4,11 @@ from flask import Flask from flask_mail import Mail from werkzeug.middleware.proxy_fix import ProxyFix from flask_session import Session - from .lib import utils def create_app(config=None): + from powerdnsadmin.lib.settings import AppSettings from . import models, routes, services from .assets import assets app = Flask(__name__) @@ -50,6 +50,9 @@ def create_app(config=None): elif config.endswith('.py'): app.config.from_pyfile(config) + # Load any settings defined with environment variables + AppSettings.load_environment(app) + # HSTS if app.config.get('HSTS_ENABLED'): from flask_sslify import SSLify diff --git a/powerdnsadmin/decorators.py b/powerdnsadmin/decorators.py index cfa5f9d..560ca45 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -13,6 +13,7 @@ def admin_role_required(f): """ Grant access if user is in Administrator role """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name != 'Administrator': @@ -26,6 +27,7 @@ def operator_role_required(f): """ Grant access if user is in Operator role or higher """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in ['Administrator', 'Operator']: @@ -39,6 +41,7 @@ def history_access_required(f): """ Grant access if user is in Operator role or higher, or Users can view history """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ @@ -57,6 +60,7 @@ def can_access_domain(f): - user is in granted Account, or - user is in granted Domain """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in ['Administrator', 'Operator']: @@ -83,10 +87,11 @@ def can_configure_dnssec(f): - user is in Operator role or higher, or - dnssec_admins_only is off """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and Setting().get('dnssec_admins_only'): abort(403) @@ -94,16 +99,18 @@ def can_configure_dnssec(f): return decorated_function + def can_remove_domain(f): """ Grant access if: - user is in Operator role or higher, or - allow_user_remove_domain is on """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_remove_domain'): abort(403) return f(*args, **kwargs) @@ -111,17 +118,17 @@ def can_remove_domain(f): return decorated_function - def can_create_domain(f): """ Grant access if: - user is in Operator role or higher, or - allow_user_create_domain is on """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): abort(403) return f(*args, **kwargs) @@ -144,11 +151,12 @@ def api_basic_auth(f): # Remove "Basic " from the header value auth_header = auth_header[6:] + auth_components = [] try: auth_header = str(base64.b64decode(auth_header), 'utf-8') - # NK: We use auth_components here as we don't know if we'll have a :, we split it maximum 1 times to grab the - # username, the rest of the string would be the password. + # NK: We use auth_components here as we don't know if we'll have a colon, + # we split it maximum 1 times to grab the username, the rest of the string would be the password. auth_components = auth_header.split(':', maxsplit=1) except (binascii.Error, UnicodeDecodeError) as e: current_app.logger.error( @@ -211,16 +219,19 @@ def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]): If request body contains one or more of specified keys, call :param callback """ + def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): check_current_http_method = not http_methods or request.method in http_methods if (check_current_http_method and - set(request.get_json(force=True).keys()).intersection(set(keys)) + set(request.get_json(force=True).keys()).intersection(set(keys)) ): callback(*args, **kwargs) return f(*args, **kwargs) + return decorated_function + return decorator @@ -246,16 +257,18 @@ def api_role_can(action, roles=None, allow_self=False): except: username = None if ( - (current_user.role.name in roles) or - (allow_self and user_id and current_user.id == user_id) or - (allow_self and username and current_user.username == username) + (current_user.role.name in roles) or + (allow_self and user_id and current_user.id == user_id) or + (allow_self and username and current_user.username == username) ): return f(*args, **kwargs) msg = ( "User {} with role {} does not have enough privileges to {}" ).format(current_user.username, current_user.role.name, action) raise NotEnoughPrivileges(message=msg) + return decorated_function + return decorator @@ -265,15 +278,16 @@ def api_can_create_domain(f): - user is in Operator role or higher, or - allow_user_create_domain is on """ + @wraps(f) def decorated_function(*args, **kwargs): if current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): msg = "User {0} does not have enough privileges to create zone" current_app.logger.error(msg.format(current_user.username)) raise NotEnoughPrivileges() - + if Setting().get('deny_domain_override'): req = request.get_json(force=True) domain = Domain() @@ -294,10 +308,11 @@ def apikey_can_create_domain(f): - deny_domain_override is off or - override_domain is true (from request) """ + @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): msg = "ApiKey #{0} does not have enough privileges to create zone" current_app.logger.error(msg.format(g.apikey.id)) @@ -320,20 +335,23 @@ def apikey_can_remove_domain(http_methods=[]): - user is in Operator role or higher, or - allow_user_remove_domain is on """ + def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): check_current_http_method = not http_methods or request.method in http_methods if (check_current_http_method and - g.apikey.role.name not in ['Administrator', 'Operator'] and - not Setting().get('allow_user_remove_domain') + g.apikey.role.name not in ['Administrator', 'Operator'] and + not Setting().get('allow_user_remove_domain') ): msg = "ApiKey #{0} does not have enough privileges to remove zone" current_app.logger.error(msg.format(g.apikey.id)) raise NotEnoughPrivileges() return f(*args, **kwargs) + return decorated_function + return decorator @@ -341,6 +359,7 @@ def apikey_is_admin(f): """ Grant access if user is in Administrator role """ + @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name != 'Administrator': @@ -358,6 +377,7 @@ def apikey_can_access_domain(f): - user has Operator role or higher, or - user has explicitly been granted access to domain """ + @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name not in ['Administrator', 'Operator']: @@ -382,22 +402,26 @@ def apikey_can_configure_dnssec(http_methods=[]): - user is in Operator role or higher, or - dnssec_admins_only is off """ + def decorator(f=None): @wraps(f) def decorated_function(*args, **kwargs): check_current_http_method = not http_methods or request.method in http_methods if (check_current_http_method and - g.apikey.role.name not in ['Administrator', 'Operator'] and - Setting().get('dnssec_admins_only') + g.apikey.role.name not in ['Administrator', 'Operator'] and + Setting().get('dnssec_admins_only') ): msg = "ApiKey #{0} does not have enough privileges to configure dnssec" current_app.logger.error(msg.format(g.apikey.id)) raise DomainAccessForbidden(message=msg) return f(*args, **kwargs) if f else None + return decorated_function + return decorator + def allowed_record_types(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -423,6 +447,7 @@ def allowed_record_types(f): return decorated_function + def allowed_record_ttl(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -431,12 +456,12 @@ def allowed_record_ttl(f): if request.method == 'GET': return f(*args, **kwargs) - + if g.apikey.role.name in ['Administrator', 'Operator']: return f(*args, **kwargs) allowed_ttls = Setting().get_ttl_options() - allowed_numeric_ttls = [ ttl[0] for ttl in allowed_ttls ] + allowed_numeric_ttls = [ttl[0] for ttl in allowed_ttls] content = request.get_json() try: for record in content['rrsets']: @@ -497,6 +522,7 @@ def dyndns_login_required(f): return decorated_function + def apikey_or_basic_auth(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -505,4 +531,5 @@ def apikey_or_basic_auth(f): return apikey_auth(f)(*args, **kwargs) else: return api_basic_auth(f)(*args, **kwargs) + return decorated_function diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py index d9e2759..ff192b0 100644 --- a/powerdnsadmin/default_config.py +++ b/powerdnsadmin/default_config.py @@ -1,44 +1,32 @@ import os -import urllib.parse + basedir = os.path.abspath(os.path.dirname(__file__)) -### BASIC APP CONFIG -SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu' -SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' BIND_ADDRESS = '0.0.0.0' -PORT = 9191 -HSTS_ENABLED = False -SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) - -SESSION_TYPE = 'sqlalchemy' -SESSION_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_HTTPONLY = True - -#CAPTCHA Config CAPTCHA_ENABLE = True -CAPTCHA_LENGTH = 6 -CAPTCHA_WIDTH = 160 CAPTCHA_HEIGHT = 60 +CAPTCHA_LENGTH = 6 CAPTCHA_SESSION_KEY = 'captcha_image' - -### DATABASE CONFIG -SQLA_DB_USER = 'pda' -SQLA_DB_PASSWORD = 'changeme' -SQLA_DB_HOST = '127.0.0.1' -SQLA_DB_NAME = 'pda' +CAPTCHA_WIDTH = 160 +CSRF_COOKIE_HTTPONLY = True +HSTS_ENABLED = False +PORT = 9191 +SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu' +SAML_ASSERTION_ENCRYPTED = True +SAML_ENABLED = False +SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' +SERVER_EXTERNAL_SSL = os.getenv('SERVER_EXTERNAL_SSL', True) +SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_TYPE = 'sqlalchemy' +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') SQLALCHEMY_TRACK_MODIFICATIONS = True - -### DATABASE - MySQL +# SQLA_DB_USER = 'pda' +# SQLA_DB_PASSWORD = 'changeme' +# SQLA_DB_HOST = '127.0.0.1' +# SQLA_DB_NAME = 'pda' # SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( # urllib.parse.quote_plus(SQLA_DB_USER), # urllib.parse.quote_plus(SQLA_DB_PASSWORD), # SQLA_DB_HOST, # SQLA_DB_NAME # ) - -### DATABASE - SQLite -SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') - -# SAML Authnetication -SAML_ENABLED = False -SAML_ASSERTION_ENCRYPTED = True diff --git a/powerdnsadmin/lib/settings.py b/powerdnsadmin/lib/settings.py new file mode 100644 index 0000000..c6d6c95 --- /dev/null +++ b/powerdnsadmin/lib/settings.py @@ -0,0 +1,629 @@ +import os +from pathlib import Path + +basedir = os.path.abspath(Path(os.path.dirname(__file__)).parent) + +class AppSettings(object): + + defaults = { + # Flask Settings + 'bind_address': '0.0.0.0', + 'csrf_cookie_secure': False, + 'log_level': 'WARNING', + 'port': 9191, + 'salt': '$2b$12$yLUMTIfl21FKJQpTkRQXCu', + 'secret_key': 'e951e5a1f4b94151b360f47edf596dd2', + 'session_cookie_secure': False, + 'session_type': 'sqlalchemy', + 'sqlalchemy_track_modifications': True, + 'sqlalchemy_database_uri': 'sqlite:///' + os.path.join(basedir, 'pdns.db'), + 'sqlalchemy_engine_options': {}, + + # General Settings + 'captcha_enable': True, + 'captcha_height': 60, + 'captcha_length': 6, + 'captcha_session_key': 'captcha_image', + 'captcha_width': 160, + 'mail_server': 'localhost', + 'mail_port': 25, + 'mail_debug': False, + 'mail_use_ssl': False, + 'mail_use_tls': False, + 'mail_username': '', + 'mail_password': '', + 'mail_default_sender': '', + 'remote_user_enabled': False, + 'remote_user_cookies': [], + 'remote_user_logout_url': '', + 'server_external_ssl': True, + 'maintenance': False, + 'fullscreen_layout': True, + 'record_helper': True, + 'login_ldap_first': True, + 'default_record_table_size': 15, + 'default_domain_table_size': 10, + 'auto_ptr': False, + 'record_quick_edit': True, + 'pretty_ipv6_ptr': False, + 'dnssec_admins_only': False, + 'allow_user_create_domain': False, + 'allow_user_remove_domain': False, + 'allow_user_view_history': False, + 'custom_history_header': '', + 'delete_sso_accounts': False, + 'bg_domain_updates': False, + 'enable_api_rr_history': True, + 'preserve_history': False, + 'site_name': 'PowerDNS-Admin', + 'site_url': 'http://localhost:9191', + 'session_timeout': 10, + 'warn_session_timeout': True, + 'pdns_api_url': '', + 'pdns_api_key': '', + 'pdns_api_timeout': 30, + 'pdns_version': '4.1.1', + 'verify_ssl_connections': True, + 'verify_user_email': False, + 'enforce_api_ttl': False, + 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', + 'otp_field_enabled': True, + 'custom_css': '', + 'otp_force': False, + 'max_history_records': 1000, + 'deny_domain_override': False, + 'account_name_extra_chars': False, + 'gravatar_enabled': False, + + # Local Authentication Settings + 'local_db_enabled': True, + 'signup_enabled': True, + 'pwd_enforce_characters': False, + 'pwd_min_len': 10, + 'pwd_min_lowercase': 3, + 'pwd_min_uppercase': 2, + 'pwd_min_digits': 2, + 'pwd_min_special': 1, + 'pwd_enforce_complexity': False, + 'pwd_min_complexity': 11, + + # LDAP Authentication Settings + 'ldap_enabled': False, + 'ldap_type': 'ldap', + 'ldap_uri': '', + 'ldap_base_dn': '', + 'ldap_admin_username': '', + 'ldap_admin_password': '', + 'ldap_domain': '', + 'ldap_filter_basic': '', + 'ldap_filter_username': '', + 'ldap_filter_group': '', + 'ldap_filter_groupname': '', + 'ldap_sg_enabled': False, + 'ldap_admin_group': '', + 'ldap_operator_group': '', + 'ldap_user_group': '', + 'autoprovisioning': False, + 'autoprovisioning_attribute': '', + 'urn_value': '', + 'purge': False, + + # Google OAuth Settings + 'google_oauth_enabled': False, + 'google_oauth_client_id': '', + 'google_oauth_client_secret': '', + 'google_oauth_scope': 'openid email profile', + 'google_base_url': 'https://www.googleapis.com/oauth2/v3/', + 'google_oauth_auto_configure': True, + 'google_oauth_metadata_url': 'https://accounts.google.com/.well-known/openid-configuration', + 'google_token_url': 'https://oauth2.googleapis.com/token', + 'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth', + + # GitHub OAuth Settings + 'github_oauth_enabled': False, + 'github_oauth_key': '', + 'github_oauth_secret': '', + 'github_oauth_scope': 'email', + 'github_oauth_api_url': 'https://api.github.com/user', + 'github_oauth_auto_configure': False, + 'github_oauth_metadata_url': '', + 'github_oauth_token_url': 'https://github.com/login/oauth/access_token', + 'github_oauth_authorize_url': 'https://github.com/login/oauth/authorize', + + # Azure OAuth Settings + 'azure_oauth_enabled': False, + 'azure_oauth_key': '', + 'azure_oauth_secret': '', + 'azure_oauth_scope': 'User.Read openid email profile', + 'azure_oauth_api_url': 'https://graph.microsoft.com/v1.0/', + 'azure_oauth_auto_configure': True, + 'azure_oauth_metadata_url': '', + 'azure_oauth_token_url': '', + 'azure_oauth_authorize_url': '', + 'azure_sg_enabled': False, + 'azure_admin_group': '', + 'azure_operator_group': '', + 'azure_user_group': '', + 'azure_group_accounts_enabled': False, + 'azure_group_accounts_name': 'displayName', + 'azure_group_accounts_name_re': '', + 'azure_group_accounts_description': 'description', + 'azure_group_accounts_description_re': '', + + # OIDC OAuth Settings + 'oidc_oauth_enabled': False, + 'oidc_oauth_key': '', + 'oidc_oauth_secret': '', + 'oidc_oauth_scope': 'email', + 'oidc_oauth_api_url': '', + 'oidc_oauth_auto_configure': True, + 'oidc_oauth_metadata_url': '', + 'oidc_oauth_token_url': '', + 'oidc_oauth_authorize_url': '', + 'oidc_oauth_logout_url': '', + 'oidc_oauth_username': 'preferred_username', + 'oidc_oauth_email': 'email', + 'oidc_oauth_firstname': 'given_name', + 'oidc_oauth_last_name': 'family_name', + 'oidc_oauth_account_name_property': '', + 'oidc_oauth_account_description_property': '', + + # SAML Authentication Settings + 'saml_enabled': False, + 'saml_debug': False, + 'saml_path': os.path.join(basedir, 'saml'), + 'saml_metadata_url': None, + 'saml_metadata_cache_lifetime': 1, + 'saml_idp_sso_binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'saml_idp_entity_id': None, + 'saml_nameid_format': None, + 'saml_attribute_account': None, + 'saml_attribute_email': 'email', + 'saml_attribute_givenname': 'givenname', + 'saml_attribute_surname': 'surname', + 'saml_attribute_name': None, + 'saml_attribute_username': None, + 'saml_attribute_admin': None, + 'saml_attribute_group': None, + 'saml_group_admin_name': None, + 'saml_group_operator_name': None, + 'saml_group_to_account_mapping': None, + 'saml_sp_entity_id': None, + 'saml_sp_contact_name': None, + 'saml_sp_contact_mail': None, + 'saml_sign_request': False, + 'saml_want_message_signed': True, + 'saml_logout': True, + 'saml_logout_url': None, + 'saml_assertion_encrypted': True, + 'saml_cert': None, + 'saml_key': None, + + # Zone Record Settings + 'forward_records_allow_edit': { + 'A': True, + 'AAAA': True, + 'AFSDB': False, + 'ALIAS': False, + 'CAA': True, + 'CERT': False, + 'CDNSKEY': False, + 'CDS': False, + 'CNAME': True, + 'DNSKEY': False, + 'DNAME': False, + 'DS': False, + 'HINFO': False, + 'KEY': False, + 'LOC': True, + 'LUA': False, + 'MX': True, + 'NAPTR': False, + 'NS': True, + 'NSEC': False, + 'NSEC3': False, + 'NSEC3PARAM': False, + 'OPENPGPKEY': False, + 'PTR': True, + 'RP': False, + 'RRSIG': False, + 'SOA': False, + 'SPF': True, + 'SSHFP': False, + 'SRV': True, + 'TKEY': False, + 'TSIG': False, + 'TLSA': False, + 'SMIMEA': False, + 'TXT': True, + 'URI': False + }, + 'reverse_records_allow_edit': { + 'A': False, + 'AAAA': False, + 'AFSDB': False, + 'ALIAS': False, + 'CAA': False, + 'CERT': False, + 'CDNSKEY': False, + 'CDS': False, + 'CNAME': False, + 'DNSKEY': False, + 'DNAME': False, + 'DS': False, + 'HINFO': False, + 'KEY': False, + 'LOC': True, + 'LUA': False, + 'MX': False, + 'NAPTR': False, + 'NS': True, + 'NSEC': False, + 'NSEC3': False, + 'NSEC3PARAM': False, + 'OPENPGPKEY': False, + 'PTR': True, + 'RP': False, + 'RRSIG': False, + 'SOA': False, + 'SPF': False, + 'SSHFP': False, + 'SRV': False, + 'TKEY': False, + 'TSIG': False, + 'TLSA': False, + 'SMIMEA': False, + 'TXT': True, + 'URI': False + }, + } + + types = { + # Flask Settings + 'bind_address': str, + 'csrf_cookie_secure': bool, + 'log_level': str, + 'port': int, + 'salt': str, + 'secret_key': str, + 'session_cookie_secure': bool, + 'session_type': str, + 'sqlalchemy_track_modifications': bool, + 'sqlalchemy_database_uri': str, + 'sqlalchemy_engine_options': dict, + + # General Settings + 'captcha_enable': bool, + 'captcha_height': int, + 'captcha_length': int, + 'captcha_session_key': str, + 'captcha_width': int, + 'mail_server': str, + 'mail_port': int, + 'mail_debug': bool, + 'mail_use_ssl': bool, + 'mail_use_tls': bool, + 'mail_username': str, + 'mail_password': str, + 'mail_default_sender': str, + 'remote_user_enabled': bool, + 'remote_user_cookies': list, + 'remote_user_logout_url': str, + 'maintenance': bool, + 'fullscreen_layout': bool, + 'record_helper': bool, + 'login_ldap_first': bool, + 'default_record_table_size': int, + 'default_domain_table_size': int, + 'auto_ptr': bool, + 'record_quick_edit': bool, + 'pretty_ipv6_ptr': bool, + 'dnssec_admins_only': bool, + 'allow_user_create_domain': bool, + 'allow_user_remove_domain': bool, + 'allow_user_view_history': bool, + 'custom_history_header': str, + 'delete_sso_accounts': bool, + 'bg_domain_updates': bool, + 'enable_api_rr_history': bool, + 'preserve_history': bool, + 'site_name': str, + 'site_url': str, + 'session_timeout': int, + 'warn_session_timeout': bool, + 'pdns_api_url': str, + 'pdns_api_key': str, + 'pdns_api_timeout': int, + 'pdns_version': str, + 'verify_ssl_connections': bool, + 'verify_user_email': bool, + 'enforce_api_ttl': bool, + 'ttl_options': str, + 'otp_field_enabled': bool, + 'custom_css': str, + 'otp_force': bool, + 'max_history_records': int, + 'deny_domain_override': bool, + 'account_name_extra_chars': bool, + 'gravatar_enabled': bool, + 'forward_records_allow_edit': dict, + 'reverse_records_allow_edit': dict, + + # Local Authentication Settings + 'local_db_enabled': bool, + 'signup_enabled': bool, + 'pwd_enforce_characters': bool, + 'pwd_min_len': int, + 'pwd_min_lowercase': int, + 'pwd_min_uppercase': int, + 'pwd_min_digits': int, + 'pwd_min_special': int, + 'pwd_enforce_complexity': bool, + 'pwd_min_complexity': int, + + # LDAP Authentication Settings + 'ldap_enabled': bool, + 'ldap_type': str, + 'ldap_uri': str, + 'ldap_base_dn': str, + 'ldap_admin_username': str, + 'ldap_admin_password': str, + 'ldap_domain': str, + 'ldap_filter_basic': str, + 'ldap_filter_username': str, + 'ldap_filter_group': str, + 'ldap_filter_groupname': str, + 'ldap_sg_enabled': bool, + 'ldap_admin_group': str, + 'ldap_operator_group': str, + 'ldap_user_group': str, + 'autoprovisioning': bool, + 'autoprovisioning_attribute': str, + 'urn_value': str, + 'purge': bool, + + # Google OAuth Settings + 'google_oauth_enabled': bool, + 'google_oauth_client_id': str, + 'google_oauth_client_secret': str, + 'google_oauth_scope': str, + 'google_base_url': str, + 'google_oauth_auto_configure': bool, + 'google_oauth_metadata_url': str, + 'google_token_url': str, + 'google_authorize_url': str, + + # GitHub OAuth Settings + 'github_oauth_enabled': bool, + 'github_oauth_key': str, + 'github_oauth_secret': str, + 'github_oauth_scope': str, + 'github_oauth_api_url': str, + 'github_oauth_auto_configure': bool, + 'github_oauth_metadata_url': str, + 'github_oauth_token_url': str, + 'github_oauth_authorize_url': str, + + # Azure OAuth Settings + 'azure_oauth_enabled': bool, + 'azure_oauth_key': str, + 'azure_oauth_secret': str, + 'azure_oauth_scope': str, + 'azure_oauth_api_url': str, + 'azure_oauth_auto_configure': bool, + 'azure_oauth_metadata_url': str, + 'azure_oauth_token_url': str, + 'azure_oauth_authorize_url': str, + 'azure_sg_enabled': bool, + 'azure_admin_group': str, + 'azure_operator_group': str, + 'azure_user_group': str, + 'azure_group_accounts_enabled': bool, + 'azure_group_accounts_name': str, + 'azure_group_accounts_name_re': str, + 'azure_group_accounts_description': str, + 'azure_group_accounts_description_re': str, + + # OIDC OAuth Settings + 'oidc_oauth_enabled': bool, + 'oidc_oauth_key': str, + 'oidc_oauth_secret': str, + 'oidc_oauth_scope': str, + 'oidc_oauth_api_url': str, + 'oidc_oauth_auto_configure': bool, + 'oidc_oauth_metadata_url': str, + 'oidc_oauth_token_url': str, + 'oidc_oauth_authorize_url': str, + 'oidc_oauth_logout_url': str, + 'oidc_oauth_username': str, + 'oidc_oauth_email': str, + 'oidc_oauth_firstname': str, + 'oidc_oauth_last_name': str, + 'oidc_oauth_account_name_property': str, + 'oidc_oauth_account_description_property': str, + + # SAML Authentication Settings + 'saml_enabled': bool, + 'saml_debug': bool, + 'saml_path': str, + 'saml_metadata_url': str, + 'saml_metadata_cache_lifetime': int, + 'saml_idp_sso_binding': str, + 'saml_idp_entity_id': str, + 'saml_nameid_format': str, + 'saml_attribute_account': str, + 'saml_attribute_email': str, + 'saml_attribute_givenname': str, + 'saml_attribute_surname': str, + 'saml_attribute_name': str, + 'saml_attribute_username': str, + 'saml_attribute_admin': str, + 'saml_attribute_group': str, + 'saml_group_admin_name': str, + 'saml_group_operator_name': str, + 'saml_group_to_account_mapping': str, + 'saml_sp_entity_id': str, + 'saml_sp_contact_name': str, + 'saml_sp_contact_mail': str, + 'saml_sign_request': bool, + 'saml_want_message_signed': bool, + 'saml_logout': bool, + 'saml_logout_url': str, + 'saml_assertion_encrypted': bool, + 'saml_cert': str, + 'saml_key': str, + } + + groups = { + 'authentication': [ + # Local Authentication Settings + 'local_db_enabled', + 'signup_enabled', + 'pwd_enforce_characters', + 'pwd_min_len', + 'pwd_min_lowercase', + 'pwd_min_uppercase', + 'pwd_min_digits', + 'pwd_min_special', + 'pwd_enforce_complexity', + 'pwd_min_complexity', + + # LDAP Authentication Settings + 'ldap_enabled', + 'ldap_type', + 'ldap_uri', + 'ldap_base_dn', + 'ldap_admin_username', + 'ldap_admin_password', + 'ldap_domain', + 'ldap_filter_basic', + 'ldap_filter_username', + 'ldap_filter_group', + 'ldap_filter_groupname', + 'ldap_sg_enabled', + 'ldap_admin_group', + 'ldap_operator_group', + 'ldap_user_group', + 'autoprovisioning', + 'autoprovisioning_attribute', + 'urn_value', + 'purge', + + # Google OAuth Settings + 'google_oauth_enabled', + 'google_oauth_client_id', + 'google_oauth_client_secret', + 'google_oauth_scope', + 'google_base_url', + 'google_oauth_auto_configure', + 'google_oauth_metadata_url', + 'google_token_url', + 'google_authorize_url', + + # GitHub OAuth Settings + 'github_oauth_enabled', + 'github_oauth_key', + 'github_oauth_secret', + 'github_oauth_scope', + 'github_oauth_api_url', + 'github_oauth_auto_configure', + 'github_oauth_metadata_url', + 'github_oauth_token_url', + 'github_oauth_authorize_url', + + # Azure OAuth Settings + 'azure_oauth_enabled', + 'azure_oauth_key', + 'azure_oauth_secret', + 'azure_oauth_scope', + 'azure_oauth_api_url', + 'azure_oauth_auto_configure', + 'azure_oauth_metadata_url', + 'azure_oauth_token_url', + 'azure_oauth_authorize_url', + 'azure_sg_enabled', + 'azure_admin_group', + 'azure_operator_group', + 'azure_user_group', + 'azure_group_accounts_enabled', + 'azure_group_accounts_name', + 'azure_group_accounts_name_re', + 'azure_group_accounts_description', + 'azure_group_accounts_description_re', + + # OIDC OAuth Settings + 'oidc_oauth_enabled', + 'oidc_oauth_key', + 'oidc_oauth_secret', + 'oidc_oauth_scope', + 'oidc_oauth_api_url', + 'oidc_oauth_auto_configure', + 'oidc_oauth_metadata_url', + 'oidc_oauth_token_url', + 'oidc_oauth_authorize_url', + 'oidc_oauth_logout_url', + 'oidc_oauth_username', + 'oidc_oauth_email', + 'oidc_oauth_firstname', + 'oidc_oauth_last_name', + 'oidc_oauth_account_name_property', + 'oidc_oauth_account_description_property', + ] + } + + @staticmethod + def convert_type(name, value): + import json + from json import JSONDecodeError + if name in AppSettings.types: + var_type = AppSettings.types[name] + + # Handle boolean values + if var_type == bool and isinstance(value, str): + if value.lower() in ['True', 'true', '1'] or value is True: + return True + else: + return False + + # Handle float values + if var_type == float: + return float(value) + + # Handle integer values + if var_type == int: + return int(value) + + if (var_type == dict or var_type == list) and isinstance(value, str) and len(value) > 0: + try: + return json.loads(value) + except JSONDecodeError as e: + raise ValueError('Cannot parse json {} for variable {}'.format(value, name)) + + if var_type == str: + return str(value) + + return value + + @staticmethod + def load_environment(app): + """ Load app settings from environment variables when defined. """ + import os + + for var_name, default_value in AppSettings.defaults.items(): + env_name = var_name.upper() + current_value = None + + if env_name + '_FILE' in os.environ: + if env_name in os.environ: + raise AttributeError( + "Both {} and {} are set but are exclusive.".format( + env_name, env_name + '_FILE')) + with open(os.environ[env_name + '_FILE']) as f: + current_value = f.read() + f.close() + + elif env_name in os.environ: + current_value = os.environ[env_name] + + if current_value is not None: + app.config[env_name] = AppSettings.convert_type(var_name, current_value) diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 1932a9c..214d7ac 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -1,450 +1,19 @@ import sys import traceback - import pytimeparse from ast import literal_eval -from distutils.util import strtobool from flask import current_app - from .base import db +from powerdnsadmin.lib.settings import AppSettings class Setting(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), unique=True, index=True) value = db.Column(db.Text()) - - types = { - 'maintenance': bool, - 'fullscreen_layout': bool, - 'record_helper': bool, - 'login_ldap_first': bool, - 'default_record_table_size': int, - 'default_domain_table_size': int, - 'auto_ptr': bool, - 'record_quick_edit': bool, - 'pretty_ipv6_ptr': bool, - 'dnssec_admins_only': bool, - 'allow_user_create_domain': bool, - 'allow_user_remove_domain': bool, - 'allow_user_view_history': bool, - 'custom_history_header': str, - 'delete_sso_accounts': bool, - 'bg_domain_updates': bool, - 'enable_api_rr_history': bool, - 'preserve_history': bool, - 'site_name': str, - 'site_url': str, - 'session_timeout': int, - 'warn_session_timeout': bool, - 'pdns_api_url': str, - 'pdns_api_key': str, - 'pdns_api_timeout': int, - 'pdns_version': str, - 'verify_ssl_connections': bool, - 'verify_user_email': bool, - 'enforce_api_ttl': bool, - 'ttl_options': str, - 'otp_field_enabled': bool, - 'custom_css': str, - 'otp_force': bool, - 'max_history_records': int, - 'deny_domain_override': bool, - 'account_name_extra_chars': bool, - 'gravatar_enabled': bool, - 'forward_records_allow_edit': dict, - 'reverse_records_allow_edit': dict, - 'local_db_enabled': bool, - 'signup_enabled': bool, - 'pwd_enforce_characters': bool, - 'pwd_min_len': int, - 'pwd_min_lowercase': int, - 'pwd_min_uppercase': int, - 'pwd_min_digits': int, - 'pwd_min_special': int, - 'pwd_enforce_complexity': bool, - 'pwd_min_complexity': int, - 'ldap_enabled': bool, - 'ldap_type': str, - 'ldap_uri': str, - 'ldap_base_dn': str, - 'ldap_admin_username': str, - 'ldap_admin_password': str, - 'ldap_domain': str, - 'ldap_filter_basic': str, - 'ldap_filter_username': str, - 'ldap_filter_group': str, - 'ldap_filter_groupname': str, - 'ldap_sg_enabled': bool, - 'ldap_admin_group': str, - 'ldap_operator_group': str, - 'ldap_user_group': str, - 'autoprovisioning': bool, - 'autoprovisioning_attribute': str, - 'urn_value': str, - 'purge': bool, - 'google_oauth_enabled': bool, - 'google_oauth_client_id': str, - 'google_oauth_client_secret': str, - 'google_oauth_scope': str, - 'google_base_url': str, - 'google_oauth_auto_configure': bool, - 'google_oauth_metadata_url': str, - 'google_token_url': str, - 'google_authorize_url': str, - 'github_oauth_enabled': bool, - 'github_oauth_key': str, - 'github_oauth_secret': str, - 'github_oauth_scope': str, - 'github_oauth_api_url': str, - 'github_oauth_auto_configure': bool, - 'github_oauth_metadata_url': str, - 'github_oauth_token_url': str, - 'github_oauth_authorize_url': str, - 'azure_oauth_enabled': bool, - 'azure_oauth_key': str, - 'azure_oauth_secret': str, - 'azure_oauth_scope': str, - 'azure_oauth_api_url': str, - 'azure_oauth_auto_configure': bool, - 'azure_oauth_metadata_url': str, - 'azure_oauth_token_url': str, - 'azure_oauth_authorize_url': str, - 'azure_sg_enabled': bool, - 'azure_admin_group': str, - 'azure_operator_group': str, - 'azure_user_group': str, - 'azure_group_accounts_enabled': bool, - 'azure_group_accounts_name': str, - 'azure_group_accounts_name_re': str, - 'azure_group_accounts_description': str, - 'azure_group_accounts_description_re': str, - 'oidc_oauth_enabled': bool, - 'oidc_oauth_key': str, - 'oidc_oauth_secret': str, - 'oidc_oauth_scope': str, - 'oidc_oauth_api_url': str, - 'oidc_oauth_auto_configure': bool, - 'oidc_oauth_metadata_url': str, - 'oidc_oauth_token_url': str, - 'oidc_oauth_authorize_url': str, - 'oidc_oauth_logout_url': str, - 'oidc_oauth_username': str, - 'oidc_oauth_email': str, - 'oidc_oauth_firstname': str, - 'oidc_oauth_last_name': str, - 'oidc_oauth_account_name_property': str, - 'oidc_oauth_account_description_property': str, - } - - defaults = { - # General Settings - 'maintenance': False, - 'fullscreen_layout': True, - 'record_helper': True, - 'login_ldap_first': True, - 'default_record_table_size': 15, - 'default_domain_table_size': 10, - 'auto_ptr': False, - 'record_quick_edit': True, - 'pretty_ipv6_ptr': False, - 'dnssec_admins_only': False, - 'allow_user_create_domain': False, - 'allow_user_remove_domain': False, - 'allow_user_view_history': False, - 'custom_history_header': '', - 'delete_sso_accounts': False, - 'bg_domain_updates': False, - 'enable_api_rr_history': True, - 'preserve_history': False, - 'site_name': 'PowerDNS-Admin', - 'site_url': 'http://localhost:9191', - 'session_timeout': 10, - 'warn_session_timeout': True, - 'pdns_api_url': '', - 'pdns_api_key': '', - 'pdns_api_timeout': 30, - 'pdns_version': '4.1.1', - 'verify_ssl_connections': True, - 'verify_user_email': False, - 'enforce_api_ttl': False, - 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', - 'otp_field_enabled': True, - 'custom_css': '', - 'otp_force': False, - 'max_history_records': 1000, - 'deny_domain_override': False, - 'account_name_extra_chars': False, - 'gravatar_enabled': False, - # Local Authentication Settings - 'local_db_enabled': True, - 'signup_enabled': True, - 'pwd_enforce_characters': False, - 'pwd_min_len': 10, - 'pwd_min_lowercase': 3, - 'pwd_min_uppercase': 2, - 'pwd_min_digits': 2, - 'pwd_min_special': 1, - 'pwd_enforce_complexity': False, - 'pwd_min_complexity': 11, - - # LDAP Authentication Settings - 'ldap_enabled': False, - 'ldap_type': 'ldap', - 'ldap_uri': '', - 'ldap_base_dn': '', - 'ldap_admin_username': '', - 'ldap_admin_password': '', - 'ldap_domain': '', - 'ldap_filter_basic': '', - 'ldap_filter_username': '', - 'ldap_filter_group': '', - 'ldap_filter_groupname': '', - 'ldap_sg_enabled': False, - 'ldap_admin_group': '', - 'ldap_operator_group': '', - 'ldap_user_group': '', - 'autoprovisioning': False, - 'autoprovisioning_attribute': '', - 'urn_value': '', - 'purge': False, - - # Google OAuth2 Settings - 'google_oauth_enabled': False, - 'google_oauth_client_id': '', - 'google_oauth_client_secret': '', - 'google_oauth_scope': 'openid email profile', - 'google_base_url': 'https://www.googleapis.com/oauth2/v3/', - 'google_oauth_auto_configure': True, - 'google_oauth_metadata_url': 'https://accounts.google.com/.well-known/openid-configuration', - 'google_token_url': 'https://oauth2.googleapis.com/token', - 'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth', - - # GitHub OAuth2 Settings - 'github_oauth_enabled': False, - 'github_oauth_key': '', - 'github_oauth_secret': '', - 'github_oauth_scope': 'email', - 'github_oauth_api_url': 'https://api.github.com/user', - 'github_oauth_auto_configure': False, - 'github_oauth_metadata_url': '', - 'github_oauth_token_url': 'https://github.com/login/oauth/access_token', - 'github_oauth_authorize_url': 'https://github.com/login/oauth/authorize', - - # Azure OAuth2 Settings - 'azure_oauth_enabled': False, - 'azure_oauth_key': '', - 'azure_oauth_secret': '', - 'azure_oauth_scope': 'User.Read openid email profile', - 'azure_oauth_api_url': 'https://graph.microsoft.com/v1.0/', - 'azure_oauth_auto_configure': True, - 'azure_oauth_metadata_url': '', - 'azure_oauth_token_url': '', - 'azure_oauth_authorize_url': '', - 'azure_sg_enabled': False, - 'azure_admin_group': '', - 'azure_operator_group': '', - 'azure_user_group': '', - 'azure_group_accounts_enabled': False, - 'azure_group_accounts_name': 'displayName', - 'azure_group_accounts_name_re': '', - 'azure_group_accounts_description': 'description', - 'azure_group_accounts_description_re': '', - - # OIDC OAuth2 Settings - 'oidc_oauth_enabled': False, - 'oidc_oauth_key': '', - 'oidc_oauth_secret': '', - 'oidc_oauth_scope': 'email', - 'oidc_oauth_api_url': '', - 'oidc_oauth_auto_configure': True, - 'oidc_oauth_metadata_url': '', - 'oidc_oauth_token_url': '', - 'oidc_oauth_authorize_url': '', - 'oidc_oauth_logout_url': '', - 'oidc_oauth_username': 'preferred_username', - 'oidc_oauth_email': 'email', - 'oidc_oauth_firstname': 'given_name', - 'oidc_oauth_last_name': 'family_name', - 'oidc_oauth_account_name_property': '', - 'oidc_oauth_account_description_property': '', - - # Zone Record Settings - 'forward_records_allow_edit': { - 'A': True, - 'AAAA': True, - 'AFSDB': False, - 'ALIAS': False, - 'CAA': True, - 'CERT': False, - 'CDNSKEY': False, - 'CDS': False, - 'CNAME': True, - 'DNSKEY': False, - 'DNAME': False, - 'DS': False, - 'HINFO': False, - 'KEY': False, - 'LOC': True, - 'LUA': False, - 'MX': True, - 'NAPTR': False, - 'NS': True, - 'NSEC': False, - 'NSEC3': False, - 'NSEC3PARAM': False, - 'OPENPGPKEY': False, - 'PTR': True, - 'RP': False, - 'RRSIG': False, - 'SOA': False, - 'SPF': True, - 'SSHFP': False, - 'SRV': True, - 'TKEY': False, - 'TSIG': False, - 'TLSA': False, - 'SMIMEA': False, - 'TXT': True, - 'URI': False - }, - 'reverse_records_allow_edit': { - 'A': False, - 'AAAA': False, - 'AFSDB': False, - 'ALIAS': False, - 'CAA': False, - 'CERT': False, - 'CDNSKEY': False, - 'CDS': False, - 'CNAME': False, - 'DNSKEY': False, - 'DNAME': False, - 'DS': False, - 'HINFO': False, - 'KEY': False, - 'LOC': True, - 'LUA': False, - 'MX': False, - 'NAPTR': False, - 'NS': True, - 'NSEC': False, - 'NSEC3': False, - 'NSEC3PARAM': False, - 'OPENPGPKEY': False, - 'PTR': True, - 'RP': False, - 'RRSIG': False, - 'SOA': False, - 'SPF': False, - 'SSHFP': False, - 'SRV': False, - 'TKEY': False, - 'TSIG': False, - 'TLSA': False, - 'SMIMEA': False, - 'TXT': True, - 'URI': False - }, - } - - groups = { - 'authentication': [ - # Local Authentication Settings - 'local_db_enabled', - 'signup_enabled', - 'pwd_enforce_characters', - 'pwd_min_len', - 'pwd_min_lowercase', - 'pwd_min_uppercase', - 'pwd_min_digits', - 'pwd_min_special', - 'pwd_enforce_complexity', - 'pwd_min_complexity', - - # LDAP Authentication Settings - 'ldap_enabled', - 'ldap_type', - 'ldap_uri', - 'ldap_base_dn', - 'ldap_admin_username', - 'ldap_admin_password', - 'ldap_domain', - 'ldap_filter_basic', - 'ldap_filter_username', - 'ldap_filter_group', - 'ldap_filter_groupname', - 'ldap_sg_enabled', - 'ldap_admin_group', - 'ldap_operator_group', - 'ldap_user_group', - 'autoprovisioning', - 'autoprovisioning_attribute', - 'urn_value', - 'purge', - - # Google OAuth2 Settings - 'google_oauth_enabled', - 'google_oauth_client_id', - 'google_oauth_client_secret', - 'google_oauth_scope', - 'google_base_url', - 'google_oauth_auto_configure', - 'google_oauth_metadata_url', - 'google_token_url', - 'google_authorize_url', - - # GitHub OAuth2 Settings - 'github_oauth_enabled', - 'github_oauth_key', - 'github_oauth_secret', - 'github_oauth_scope', - 'github_oauth_api_url', - 'github_oauth_auto_configure', - 'github_oauth_metadata_url', - 'github_oauth_token_url', - 'github_oauth_authorize_url', - - # Azure OAuth2 Settings - 'azure_oauth_enabled', - 'azure_oauth_key', - 'azure_oauth_secret', - 'azure_oauth_scope', - 'azure_oauth_api_url', - 'azure_oauth_auto_configure', - 'azure_oauth_metadata_url', - 'azure_oauth_token_url', - 'azure_oauth_authorize_url', - 'azure_sg_enabled', - 'azure_admin_group', - 'azure_operator_group', - 'azure_user_group', - 'azure_group_accounts_enabled', - 'azure_group_accounts_name', - 'azure_group_accounts_name_re', - 'azure_group_accounts_description', - 'azure_group_accounts_description_re', - - # OIDC OAuth2 Settings - 'oidc_oauth_enabled', - 'oidc_oauth_key', - 'oidc_oauth_secret', - 'oidc_oauth_scope', - 'oidc_oauth_api_url', - 'oidc_oauth_auto_configure', - 'oidc_oauth_metadata_url', - 'oidc_oauth_token_url', - 'oidc_oauth_authorize_url', - 'oidc_oauth_logout_url', - 'oidc_oauth_username', - 'oidc_oauth_email', - 'oidc_oauth_firstname', - 'oidc_oauth_last_name', - 'oidc_oauth_account_name_property', - 'oidc_oauth_account_description_property', - ] - } + ZONE_TYPE_FORWARD = 'forward' + ZONE_TYPE_REVERSE = 'reverse' def __init__(self, id=None, name=None, value=None): self.id = id @@ -457,44 +26,12 @@ class Setting(db.Model): self.name = name self.value = value - def convert_type(self, name, value): - import json - from json import JSONDecodeError - if name in self.types: - var_type = self.types[name] - - # Handle boolean values - if var_type == bool: - if value == 'True' or value == 'true' or value == '1' or value is True: - return True - else: - return False - - # Handle float values - if var_type == float: - return float(value) - - # Handle integer values - if var_type == int: - return int(value) - - if (var_type == dict or var_type == list) and isinstance(value, str) and len(value) > 0: - try: - return json.loads(value) - except JSONDecodeError as e: - pass - - if var_type == str: - return str(value) - - return value - def set_maintenance(self, mode): maintenance = Setting.query.filter( Setting.name == 'maintenance').first() if maintenance is None: - value = self.defaults['maintenance'] + value = AppSettings.defaults['maintenance'] maintenance = Setting(name='maintenance', value=str(value)) db.session.add(maintenance) @@ -516,7 +53,7 @@ class Setting(db.Model): current_setting = Setting.query.filter(Setting.name == setting).first() if current_setting is None: - value = self.defaults[setting] + value = AppSettings.defaults[setting] current_setting = Setting(name=setting, value=str(value)) db.session.add(current_setting) @@ -541,21 +78,20 @@ class Setting(db.Model): current_setting = Setting(name=setting, value=None) db.session.add(current_setting) - value = str(self.convert_type(setting, value)) + value = str(AppSettings.convert_type(setting, value)) try: current_setting.value = value db.session.commit() return True except Exception as e: - current_app.logger.error('Cannot edit setting {0}. DETAIL: {1}'.format( - setting, e)) + current_app.logger.error('Cannot edit setting {0}. DETAIL: {1}'.format(setting, e)) current_app.logger.debug(traceback.format_exec()) db.session.rollback() return False def get(self, setting): - if setting in self.defaults: + if setting in AppSettings.defaults: if setting.upper() in current_app.config: result = current_app.config[setting.upper()] @@ -566,51 +102,45 @@ class Setting(db.Model): if hasattr(result, 'value'): result = result.value - return self.convert_type(setting, result) + return AppSettings.convert_type(setting, result) else: - return self.defaults[setting] + return AppSettings.defaults[setting] else: current_app.logger.error('Unknown setting queried: {0}'.format(setting)) def get_group(self, group): if not isinstance(group, list): - group = self.groups[group] + group = AppSettings.groups[group] result = {} - records = self.query.all() - for record in records: - if record.name in group: - result[record.name] = self.convert_type(record.name, record.value) + for var_name, default_value in AppSettings.defaults.items(): + if var_name in group: + result[var_name] = self.get(var_name) return result def get_records_allow_to_edit(self): return list( - set(self.get_forward_records_allow_to_edit() + - self.get_reverse_records_allow_to_edit())) + set(self.get_supported_record_types(self.ZONE_TYPE_FORWARD) + + self.get_supported_record_types(self.ZONE_TYPE_REVERSE))) - def get_forward_records_allow_to_edit(self): - records = self.get('forward_records_allow_edit') - f_records = literal_eval(records) if isinstance(records, - str) else records - r_name = [r for r in f_records if f_records[r]] - # Sort alphabetically if python version is smaller than 3.6 - if sys.version_info[0] < 3 or (sys.version_info[0] == 3 - and sys.version_info[1] < 6): - r_name.sort() - return r_name + def get_supported_record_types(self, zone_type): + setting_value = [] + + if zone_type == self.ZONE_TYPE_FORWARD: + setting_value = self.get('forward_records_allow_edit') + elif zone_type == self.ZONE_TYPE_REVERSE: + setting_value = self.get('reverse_records_allow_edit') + + records = literal_eval(setting_value) if isinstance(setting_value, str) else setting_value + types = [r for r in records if records[r]] - def get_reverse_records_allow_to_edit(self): - records = self.get('reverse_records_allow_edit') - r_records = literal_eval(records) if isinstance(records, - str) else records - r_name = [r for r in r_records if r_records[r]] # Sort alphabetically if python version is smaller than 3.6 - if sys.version_info[0] < 3 or (sys.version_info[0] == 3 - and sys.version_info[1] < 6): - r_name.sort() - return r_name + if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 6): + types.sort() + + return types def get_ttl_options(self): return [(pytimeparse.parse(ttl), ttl) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 5b96b18..3ed5f9e 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1460,6 +1460,7 @@ def setting_pdns(): @login_required @operator_role_required def setting_records(): + from powerdnsadmin.lib.settings import AppSettings if request.method == 'GET': _fr = Setting().get('forward_records_allow_edit') _rr = Setting().get('reverse_records_allow_edit') @@ -1472,7 +1473,7 @@ def setting_records(): elif request.method == 'POST': fr = {} rr = {} - records = Setting().defaults['forward_records_allow_edit'] + records = AppSettings.defaults['forward_records_allow_edit'] for r in records: fr[r] = True if request.form.get('fr_{0}'.format( r.lower())) else False @@ -1517,6 +1518,7 @@ def setting_authentication(): @login_required @admin_role_required def setting_authentication_api(): + from powerdnsadmin.lib.settings import AppSettings result = {'status': 1, 'messages': [], 'data': {}} if request.form.get('commit') == '1': @@ -1524,7 +1526,7 @@ def setting_authentication_api(): data = json.loads(request.form.get('data')) for key, value in data.items(): - if key in model.groups['authentication']: + if key in AppSettings.groups['authentication']: model.set(key, value) result['data'] = Setting().get_group('authentication') diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index b1734fc..56ca405 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -72,9 +72,9 @@ def domain(domain_name): quick_edit = Setting().get('record_quick_edit') records_allow_to_edit = Setting().get_records_allow_to_edit() forward_records_allow_to_edit = Setting( - ).get_forward_records_allow_to_edit() + ).get_supported_record_types(Setting().ZONE_TYPE_FORWARD) reverse_records_allow_to_edit = Setting( - ).get_reverse_records_allow_to_edit() + ).get_supported_record_types(Setting().ZONE_TYPE_REVERSE) ttl_options = Setting().get_ttl_options() records = [] From ccd7373efe85c09a494a05666a20963c2b4f6e9f Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 18:52:27 -0400 Subject: [PATCH 06/43] Corrected issue with encoding / decoding of dictionary and list type settings values. Updated zone record settings management to use valid JSON format with backwards compatibility support for the non-JSON literal format. --- powerdnsadmin/lib/settings.py | 7 ++++- powerdnsadmin/models/setting.py | 6 ++++- powerdnsadmin/routes/admin.py | 46 ++++++++++++++++++--------------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/powerdnsadmin/lib/settings.py b/powerdnsadmin/lib/settings.py index c6d6c95..bebc9de 100644 --- a/powerdnsadmin/lib/settings.py +++ b/powerdnsadmin/lib/settings.py @@ -597,7 +597,12 @@ class AppSettings(object): try: return json.loads(value) except JSONDecodeError as e: - raise ValueError('Cannot parse json {} for variable {}'.format(value, name)) + # Provide backwards compatibility for legacy non-JSON format + value = value.replace("'", '"').replace('True', 'true').replace('False', 'false') + try: + return json.loads(value) + except JSONDecodeError as e: + raise ValueError('Cannot parse json {} for variable {}'.format(value, name)) if var_type == str: return str(value) diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 214d7ac..2bcd8c5 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -72,13 +72,17 @@ class Setting(db.Model): return False def set(self, setting, value): + import json current_setting = Setting.query.filter(Setting.name == setting).first() if current_setting is None: current_setting = Setting(name=setting, value=None) db.session.add(current_setting) - value = str(AppSettings.convert_type(setting, value)) + value = AppSettings.convert_type(setting, value) + + if isinstance(value, dict) or isinstance(value, list): + value = json.dumps(value) try: current_setting.value = value diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 3ed5f9e..b563555 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -128,14 +128,17 @@ def extract_changelogs_from_history(histories, record_name=None, record_type=Non # filter only the records with the specific record_name, record_type if record_name != None and record_type != None: - details['add_rrsets'] = list(filter_rr_list_by_name_and_type(details['add_rrsets'], record_name, record_type)) - details['del_rrsets'] = list(filter_rr_list_by_name_and_type(details['del_rrsets'], record_name, record_type)) + details['add_rrsets'] = list( + filter_rr_list_by_name_and_type(details['add_rrsets'], record_name, record_type)) + details['del_rrsets'] = list( + filter_rr_list_by_name_and_type(details['del_rrsets'], record_name, record_type)) if not details['add_rrsets'] and not details['del_rrsets']: continue # same record name and type RR are being deleted and created in same entry. - del_add_changes = set([(r['name'], r['type']) for r in details['add_rrsets']]).intersection([(r['name'], r['type']) for r in details['del_rrsets']]) + del_add_changes = set([(r['name'], r['type']) for r in details['add_rrsets']]).intersection( + [(r['name'], r['type']) for r in details['del_rrsets']]) for del_add_change in del_add_changes: changes.append(HistoryRecordEntry( entry, @@ -155,8 +158,8 @@ def extract_changelogs_from_history(histories, record_name=None, record_type=Non # sort changes by the record name if changes: changes.sort(key=lambda change: - change.del_rrset['name'] if change.del_rrset else change.add_rrset['name'] - ) + change.del_rrset['name'] if change.del_rrset else change.add_rrset['name'] + ) out_changes.extend(changes) return out_changes @@ -1149,10 +1152,10 @@ def history_table(): # ajax call data .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ .filter(db.or_( - DomainUser.user_id == current_user.id, - AccountUser.user_id == current_user.id - )) \ - .subquery() + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )) \ + .subquery() base_query = base_query.filter(History.domain_id.in_(allowed_domain_id_subquery)) domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \ @@ -1271,7 +1274,8 @@ def history_table(): # ajax call data ) ).order_by(History.created_on.desc()) \ .limit(lim).all() - elif user_name != None and current_user.role.name in ['Administrator', 'Operator']: # only admins can see the user login-logouts + elif user_name != None and current_user.role.name in ['Administrator', + 'Operator']: # only admins can see the user login-logouts histories = base_query.filter( db.and_( @@ -1296,7 +1300,8 @@ def history_table(): # ajax call data temp.append(h) break histories = temp - elif (changed_by != None or max_date != None) and current_user.role.name in ['Administrator', 'Operator']: # select changed by and date filters only + elif (changed_by != None or max_date != None) and current_user.role.name in ['Administrator', + 'Operator']: # select changed by and date filters only histories = base_query.filter( db.and_( History.created_on <= max_date if max_date != None else True, @@ -1305,7 +1310,8 @@ def history_table(): # ajax call data ) ) \ .order_by(History.created_on.desc()).limit(lim).all() - elif (changed_by != None or max_date != None): # special filtering for user because one user does not have access to log-ins logs + elif ( + changed_by != None or max_date != None): # special filtering for user because one user does not have access to log-ins logs histories = base_query.filter( db.and_( History.created_on <= max_date if max_date != None else True, @@ -1462,14 +1468,11 @@ def setting_pdns(): def setting_records(): from powerdnsadmin.lib.settings import AppSettings if request.method == 'GET': - _fr = Setting().get('forward_records_allow_edit') - _rr = Setting().get('reverse_records_allow_edit') - f_records = literal_eval(_fr) if isinstance(_fr, str) else _fr - r_records = literal_eval(_rr) if isinstance(_rr, str) else _rr - + forward_records = Setting().get('forward_records_allow_edit') + reverse_records = Setting().get('reverse_records_allow_edit') return render_template('admin_setting_records.html', - f_records=f_records, - r_records=r_records) + f_records=forward_records, + r_records=reverse_records) elif request.method == 'POST': fr = {} rr = {} @@ -1480,8 +1483,9 @@ def setting_records(): rr[r] = True if request.form.get('rr_{0}'.format( r.lower())) else False - Setting().set('forward_records_allow_edit', str(fr)) - Setting().set('reverse_records_allow_edit', str(rr)) + Setting().set('forward_records_allow_edit', json.dumps(fr)) + Setting().set('reverse_records_allow_edit', json.dumps(rr)) + return redirect(url_for('admin.setting_records')) From 601539f16e943fb2ed04eba0f25625a110f7baa6 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 19:00:39 -0400 Subject: [PATCH 07/43] Removed unnecessary parenthesis. --- powerdnsadmin/routes/admin.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index b563555..a69e46a 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1402,7 +1402,7 @@ def setting_basic_edit(setting): new_value = jdata['value'] result = Setting().set(setting, new_value) - if (result): + if result: return make_response( jsonify({ 'status': 'ok', @@ -1489,28 +1489,6 @@ def setting_records(): return redirect(url_for('admin.setting_records')) -def has_an_auth_method(local_db_enabled=None, - ldap_enabled=None, - google_oauth_enabled=None, - github_oauth_enabled=None, - oidc_oauth_enabled=None, - azure_oauth_enabled=None): - if local_db_enabled is None: - local_db_enabled = Setting().get('local_db_enabled') - if ldap_enabled is None: - ldap_enabled = Setting().get('ldap_enabled') - if google_oauth_enabled is None: - google_oauth_enabled = Setting().get('google_oauth_enabled') - if github_oauth_enabled is None: - github_oauth_enabled = Setting().get('github_oauth_enabled') - if oidc_oauth_enabled is None: - oidc_oauth_enabled = Setting().get('oidc_oauth_enabled') - if azure_oauth_enabled is None: - azure_oauth_enabled = Setting().get('azure_oauth_enabled') - return local_db_enabled or ldap_enabled or google_oauth_enabled or github_oauth_enabled or oidc_oauth_enabled \ - or azure_oauth_enabled - - @admin_bp.route('/setting/authentication', methods=['GET', 'POST']) @login_required @admin_role_required From c74c06c3271a1e834431d2b4fb1f4dc5ef307759 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 19:26:02 -0400 Subject: [PATCH 08/43] Added some missing settings to the new `AppSettings` class. Corrected typo in app setup method. --- powerdnsadmin/__init__.py | 2 +- powerdnsadmin/lib/settings.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index 32ad19e..660f96b 100755 --- a/powerdnsadmin/__init__.py +++ b/powerdnsadmin/__init__.py @@ -87,7 +87,7 @@ def create_app(config=None): app.jinja_env.filters['format_datetime_local'] = utils.format_datetime app.jinja_env.filters['format_zone_type'] = utils.format_zone_type - # Register context proccessors + # Register context processors from .models.setting import Setting @app.context_processor diff --git a/powerdnsadmin/lib/settings.py b/powerdnsadmin/lib/settings.py index bebc9de..b154112 100644 --- a/powerdnsadmin/lib/settings.py +++ b/powerdnsadmin/lib/settings.py @@ -36,6 +36,7 @@ class AppSettings(object): 'remote_user_enabled': False, 'remote_user_cookies': [], 'remote_user_logout_url': '', + 'hsts_enabled': False, 'server_external_ssl': True, 'maintenance': False, 'fullscreen_layout': True, @@ -74,6 +75,7 @@ class AppSettings(object): 'deny_domain_override': False, 'account_name_extra_chars': False, 'gravatar_enabled': False, + 'pdns_admin_log_level': 'WARNING', # Local Authentication Settings 'local_db_enabled': True, @@ -306,6 +308,7 @@ class AppSettings(object): 'mail_username': str, 'mail_password': str, 'mail_default_sender': str, + 'hsts_enabled': bool, 'remote_user_enabled': bool, 'remote_user_cookies': list, 'remote_user_logout_url': str, @@ -346,6 +349,7 @@ class AppSettings(object): 'deny_domain_override': bool, 'account_name_extra_chars': bool, 'gravatar_enabled': bool, + 'pdns_admin_log_level': str, 'forward_records_allow_edit': dict, 'reverse_records_allow_edit': dict, From 66f422754b330a726ba389f84c7383ed7f75bff3 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 19:33:32 -0400 Subject: [PATCH 09/43] Updated the application version references in preparation for the next release of `0.4.2`. --- .github/ISSUE_TEMPLATE/bug_report.yaml | 1 + .github/ISSUE_TEMPLATE/feature_request.yaml | 1 + VERSION | 2 +- powerdnsadmin/templates/base.html | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index ea05799..2554d81 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -15,6 +15,7 @@ body: label: PDA version description: What version of PDA are you currently running? options: + - "0.4.2" - "0.4.1" - "0.4.0" - "0.3.0" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 45950a1..438a1f5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -15,6 +15,7 @@ body: label: PDA version description: What version of PDA are you currently running? options: + - "0.4.2" - "0.4.1" - "0.4.0" - "0.3.0" diff --git a/VERSION b/VERSION index 44bb5d1..f7abe27 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 \ No newline at end of file +0.4.2 \ No newline at end of file diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html index c8708e8..eaa9068 100644 --- a/powerdnsadmin/templates/base.html +++ b/powerdnsadmin/templates/base.html @@ -228,7 +228,7 @@
PowerDNS-Admin - A PowerDNS web interface with advanced features. - Version 0.4.1 + Version 0.4.2
From 055ac761e3b729e9c2a5ab5237c61c0f9ef02276 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Fri, 14 Apr 2023 19:38:54 -0400 Subject: [PATCH 10/43] Updating documentation to reflect the latest app settings changes. --- README.md | 8 ++++---- docs/wiki/README.md | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6070d5f..6ed4926 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,10 @@ This creates a volume named `pda-data` to persist the default SQLite database wi 1. Update the configuration Edit the `docker-compose.yml` file to update the database connection string in `SQLALCHEMY_DATABASE_URI`. Other environment variables are mentioned in - the [legal_envvars](https://github.com/PowerDNS-Admin/PowerDNS-Admin/blob/master/configs/docker_config.py#L5-L46). - To use the Docker secrets feature it is possible to append `_FILE` to the environment variables and point to a file - with the values stored in it. - Make sure to set the environment variable `SECRET_KEY` to a long random + the [AppSettings.defaults](https://github.com/PowerDNS-Admin/PowerDNS-Admin/blob/master/powerdnsadmin/lib/settings.py) dictionary. + To use a Docker-style secrets convention, one may append `_FILE` to the environment variables with a path to a file + containing the intended value of the variable (e.g. `SQLALCHEMY_DATABASE_URI_FILE=/run/secrets/db_uri`). + Make sure to set the environment variable `SECRET_KEY` to a long, random string (https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY) 2. Start docker container diff --git a/docs/wiki/README.md b/docs/wiki/README.md index 36fd312..65165c2 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -40,7 +40,6 @@ - Setting up a zone - Adding a record -- ## Feature usage From f0e32a035d3337931556d19be9dfe602db5cff07 Mon Sep 17 00:00:00 2001 From: corubba Date: Sun, 16 Apr 2023 16:03:44 +0200 Subject: [PATCH 11/43] Fix deletion of comments The compaitibility for backends that don't support comments broke the normal deletion of comments. This is fixed by limiting the compaitibility to when we don't know for certain whether the backend of that zone supports comments or not. This is done by checking if the zone currently contains any comments: If it does, the backend definitly supports comment; if it doesn't, we don't know and have to assume it doesn't. The check is done by the "modified_at" attribute, because this only exists on persistent comments from pdns and not on the "fill-up" comments that PDA adds. Luckily this also works perfectly for the deletion case, because to delete a comment it had to already exist previously. Fixes #1516 --- powerdnsadmin/models/record.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index 3b239b4..a43b057 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -251,6 +251,7 @@ class Record(object): Returns: new_rrsets(list): List of rrsets to be added del_rrsets(list): List of rrsets to be deleted + zone_has_comments(bool): True if the zone currently contains persistent comments """ # Create submitted rrsets from submitted records submitted_rrsets = self.build_rrsets(domain_name, submitted_records) @@ -266,9 +267,11 @@ class Record(object): # PDNS API always return the comments with modified_at # info, we have to remove it to be able to do the dict # comparison between current and submitted rrsets + zone_has_comments = False for r in current_rrsets: for comment in r['comments']: if 'modified_at' in comment: + zone_has_comments = True del comment['modified_at'] # List of rrsets to be added @@ -290,7 +293,7 @@ class Record(object): current_app.logger.debug("new_rrsets: \n{}".format(utils.pretty_json(new_rrsets))) current_app.logger.debug("del_rrsets: \n{}".format(utils.pretty_json(del_rrsets))) - return new_rrsets, del_rrsets + return new_rrsets, del_rrsets, zone_has_comments def apply_rrsets(self, domain_name, rrsets): headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -304,7 +307,7 @@ class Record(object): return jdata @staticmethod - def to_api_payload(new_rrsets, del_rrsets): + def to_api_payload(new_rrsets, del_rrsets, comments_supported): """Turn the given changes into a single api payload.""" def replace_for_api(rrset): @@ -312,9 +315,13 @@ class Record(object): if not rrset or rrset.get('changetype', None) != 'REPLACE': return rrset replace_copy = dict(rrset) - # For compatibility with some backends: Remove comments from rrset if all are blank - if not any((bool(c.get('content', None)) for c in replace_copy.get('comments', []))): - replace_copy.pop('comments', None) + has_nonempty_comments = any(bool(c.get('content', None)) for c in replace_copy.get('comments', [])) + if not has_nonempty_comments: + if comments_supported: + replace_copy['comments'] = [] + else: + # For backends that don't support comments: Remove the attribute altogether + replace_copy.pop('comments', None) return replace_copy def rrset_in(needle, haystack): @@ -351,11 +358,11 @@ class Record(object): "submitted_records: {}".format(submitted_records)) # Get the list of rrsets to be added and deleted - new_rrsets, del_rrsets = self.compare(domain_name, submitted_records) + new_rrsets, del_rrsets, zone_has_comments = self.compare(domain_name, submitted_records) # The history logic still needs *all* the deletes with full data to display a useful diff. # So create a "minified" copy for the api call, and return the original data back up - api_payload = self.to_api_payload(new_rrsets['rrsets'], del_rrsets['rrsets']) + api_payload = self.to_api_payload(new_rrsets['rrsets'], del_rrsets['rrsets'], zone_has_comments) current_app.logger.debug(f"api payload: \n{utils.pretty_json(api_payload)}") # Submit the changes to PDNS API From 9350c98ea2ab4200e1582729ea6a692aa1163297 Mon Sep 17 00:00:00 2001 From: Roel Schroeven Date: Wed, 26 Apr 2023 17:26:52 +0200 Subject: [PATCH 12/43] Autofocus username field on Login screen --- powerdnsadmin/templates/login.html | 1 + 1 file changed, 1 insertion(+) diff --git a/powerdnsadmin/templates/login.html b/powerdnsadmin/templates/login.html index 0436e5d..84e247d 100644 --- a/powerdnsadmin/templates/login.html +++ b/powerdnsadmin/templates/login.html @@ -40,6 +40,7 @@
From 11be125e3bd55ce7c16cf55fbb51b869734a9599 Mon Sep 17 00:00:00 2001 From: Tyler Todd Date: Thu, 22 Jun 2023 08:48:50 -0400 Subject: [PATCH 13/43] Update domain.html --- powerdnsadmin/templates/domain.html | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/powerdnsadmin/templates/domain.html b/powerdnsadmin/templates/domain.html index e594a2b..8a34127 100755 --- a/powerdnsadmin/templates/domain.html +++ b/powerdnsadmin/templates/domain.html @@ -22,6 +22,15 @@ {% block content %}
+
@@ -251,6 +260,7 @@ $("#button_delete_confirm").unbind().one('click', function (e) { table.row(nRow).remove().draw(); + detectUnsavedChanges(table); modal.modal('hide'); }); @@ -355,6 +365,7 @@ e.stopPropagation(); var table = $("#tbl_records").DataTable(); saveRow(table, nEditing); + detectUnsavedChanges(table); nEditing = null; nNew = false; }); @@ -368,6 +379,94 @@ }, $SCRIPT_ROOT + '/domain/' + domain + '/update', true); }); + var unsavedChanges = false; + + function detectUnsavedChanges(table) { + // Reset unsavedChanges to false at the start of the function + unsavedChanges = false; + var index = 0; + var origcount = {{ records|length }}; + var count = table.page.info().recordsTotal; + var changes = {}; // Dictionary to store changes + + if (count != origcount) { + unsavedChanges = true; //a record was either added or deleted. + } else { + {% for record in records %} + var origrecordttl = '{{ record.ttl }}'; + var origrecordname = '{{ (record.name,domain.name) | display_record_name }}'; + var origrecorddata = '{{ record.data }}'; + origrecorddata = origrecorddata.replace(/"/g, '\"'); + var origrecordtype = '{{ record.type }}'; + var origrecordstatus = '{{ record.status }}'; + var origrecordcomment = '{{ record.comment }}'; + if (!table.row(index) || typeof table.row(index) == 'undefined') { + unsavedChanges = true; //sanity check otherwise below code throws error if row at that index doesn't exist. + } else { + var editrecordname = table.row(index).data()[0]; + var editrecordtype = table.row(index).data()[1]; + var editrecordstatus = table.row(index).data()[2]; + var editrecordttl = table.row(index).data()[3]; + var editrecorddata = table.row(index).data()[4]; + var editrecordcomment = table.row(index).data()[5]; + if (origrecordttl != editrecordttl || origrecordname != editrecordname || origrecorddata != editrecorddata || origrecordtype != editrecordtype |> + unsavedChanges = true; + } + } + index++; + {% endfor %} + } + unsavedChangesWarning(unsavedChanges); + + // Get the modal and the navigation links + var modal = document.getElementById('WarnLeave'); + var navLinks = document.querySelectorAll('.nav-link'); + + // Listen for clicks on navigation links + navLinks.forEach(function(link) { + if (!link.classList.contains('no-prompt')) { + link.addEventListener('click', function(event) { + if (unsavedChanges) { + event.preventDefault(); // Prevent navigation + modal.style.display = "block"; // Show the modal + // Get the buttons + var stayButton = document.getElementById('stay'); + var leaveButton = document.getElementById('leave'); + + // When the user clicks on "Stay", close the modal + stayButton.onclick = function() { + modal.style.display = "none"; + } + + // When the user clicks on "Leave", navigate away + leaveButton.onclick = function() { + unsavedChanges = false; // No unsaved changes anymore + location.href = link.href; // Navigate to the clicked link + } + } + }); + } + }); + } + + function unsavedChangesWarning(unsavedChanges) { + var card = document.getElementById("unsaved-changes-card"); + var cardBody = card.querySelector(".card-body"); + + if(unsavedChanges){ + var message = 'There are unsaved changes for the zone. Please click Save Changes to save all of your changes.'; + card.style.display = 'block'; // to show the card + cardBody.innerHTML = message; + return false; + } else { + card.style.display = 'none'; // to hide the card + } + } + + + + + {% if SETTING.get('record_helper') %} //handle wacky record types $(document.body).on("focus", "#current_edit_record_data", function (e) { @@ -677,4 +776,20 @@
+ {% endblock %} From 953221578bd5db9dde2f761800d09bcdd62e88a2 Mon Sep 17 00:00:00 2001 From: Tyler Todd Date: Thu, 22 Jun 2023 08:50:14 -0400 Subject: [PATCH 14/43] Update base.html --- powerdnsadmin/templates/base.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html index eaa9068..ff69f3b 100644 --- a/powerdnsadmin/templates/base.html +++ b/powerdnsadmin/templates/base.html @@ -31,14 +31,14 @@