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 diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index fec231e..fa29545 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -12,6 +12,9 @@ on: - "main" - "master" - "dependabot/**" + - "feature/**" + - "issues/**" + - "release/**" env: # Comment env block if you do not want to apply fixes # Apply linter fixes configuration diff --git a/README.md b/README.md index 055e68d..d1615c5 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,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/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/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..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 = 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/docker/Dockerfile b/docker/Dockerfile index 55ccdfd..519e077 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -92,6 +92,6 @@ RUN chown ${USER}:${USER} ./configs /app && \ EXPOSE 80/tcp USER ${USER} -HEALTHCHECK CMD ["wget","--output-document=-","--quiet","--tries=1","http://127.0.0.1/"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=20s --retries=5 CMD wget --output-document=- --quiet --tries=1 http://127.0.0.1${SCRIPT_NAME:-/} ENTRYPOINT ["entrypoint.sh"] CMD ["gunicorn","powerdnsadmin:create_app()"] diff --git a/docs/announcements/project-update-2022-12-09.md b/docs/announcements/project-update-2022-12-09.md new file mode 100644 index 0000000..427751b --- /dev/null +++ b/docs/announcements/project-update-2022-12-09.md @@ -0,0 +1,100 @@ +# PDA Project Update + +## Introduction + +Hello PDA community members, + +My name is Matt Scott, and I am the owner of [Azorian Solutions](https://azorian.solutions), a consultancy for the +Internet Service Provider (ISP) industry. I'm pleased to announce that I have taken ownership of the PDA project and +will be taking over the lead maintainer role, effective immediately. + +Please always remember and thank both [Khanh Ngo](https://github.com/ngoduykhanh) and +[Jérôme Becot](https://github.com/jbe-dw) for their efforts in keeping this project alive thus far. Without the effort +of Khanh creating the PDA project and community, and the efforts of Jérôme for holding up the lead maintainer role after +Khanh had to step down, this project would not still be alive today. + +With that being said, please read through all the following announcements as they are important if you're an active PDA +user or community member. I intend to make many great enhancements to the project, but it could be a bumpy road ahead. + +### Project Maintenance + +As it stands today, contributions to the project are at a low. At this point, there is a rather large backlog of issues +and feature requests in contrast to the current maintenance capacities. This is not to say you should lose hope though! +As part of this project transition, some additional contribution interest has been generated and I expect to attract +more with the changes I'm planning to make. In the near future, I may by-pass some usual maintenance processes in order +to expedite some changes to the project that have been outstanding for some time. + +This is to say however that unless the project attracts a healthy new contribution base, issues may continue to pile up +as maintenance capacity is rather limited. This is further complicated by the fact that the current code base is harder +to follow naturally since it largely lacks uniformity and standards. This lack of uniformity has lead to a difficult +situation that makes implementing certain changes less effective. This status quo is not uncommon with projects born how +PDA was born, so it's unfortunate but not unexpected. + +### Change of Direction + +In order to reorganize the project and get it on a track to a future that allows it to contend with other commercial +quality products, I had to make many considerations to the proficiencies of two unique paths forward to achieve this +goal. One path forward is seemingly obvious, continue maintaining the current code base while overhauling it to shift it +towards the envisioned goal. The other path is a fresh solution design with a complete rebuild. + +The answer to the aforementioned decision might seem obvious to those of you who typically favor the "don't reinvent the +wheel" mentality. I'm unclear of the details surrounding the original use-case that drove the development of this +project, but I don't believe it was on-par with some use-cases we see today which include operators handling many tens +of thousands of zones and/or records. There are many changes that have been (sometimes) haphazardly implemented which +has lead to the previously mentioned lack of uniformity among other issues. To put it simply, I'm not sure if the +project ever had a grand vision per se but instead was mostly reactionary to community requests. + +I believe that the current project has served the community fairly well from what I can tell. I know the product has +certainly helped me in my professional efforts with many environments. I also believe that it's time to pivot so that +the project can realize it's true potential, considering the existing user base. For this reason, I am beginning the +planning phase of a project overhaul. This effort will involve a complete re-engineering of the project's contribution +standards and requirements, technology stack, and project structure. + +This was not an easy decision to come to but one must appreciate that there aren't as many people that can get very +excited about working on the current project code base. The current project has many barriers to entry which I intend to +drastically impact with future changes. The reality is that it's easier to gain contribution participation with a new +build effort as it offers an opportunity to own a part of the project with impactful contributions. + +### Project Enhancements + +Since this is the beginning of a rebirth of the project so to speak, I want to implement a new operational tactic that +will hopefully drive contributions through incentive. Many of us understand that any project, needs a leader to stay on +track and organized. If everything were a democratic process, it would take too long and suffer unnecessary challenges. +With that being said, I do believe that there is plenty of opportunity through-out various development phases of the +project to allow for a democratic process where the community contributors and members can participate in the +decision-making. + +The plan to achieve the aforementioned democratic goal is to centralize communications and define some basic structured +processes. To do this, more effective methods of communication have been implemented to allow those interested in +contributing to easily participate in fluid, open communication. This has already been proving to be quite effective for +exchanging ideas and visions while addressing the issue with contributors living in vastly different time zones. This is +effectively a private chat hosted by the PDA project using Mattermost (a Slack-like alternative). + +Even if you aren't in a position to directly contribute work to the project, you can still contribute by participating +in these very important and early discussions that will impact the solution engineering. If the PDA project is an +important tool in your organization, I encourage you to join the conversation and contribute where applicable your +use-cases. Having more insight on the community use-cases will only benefit the future of this project. + +If you're interested in joining the conversation, please email me at +[admin@powerdnsadmin.org](mailto:admin@powerdnsadmin.org) for an invitation. + +### Re-branding + +As part of this project transition, I will also be changing the naming scheme in order to support the future development +efforts toward a newly engineered solution. The current PDA project will ultimately become known as the "PDA Legacy" +application. This change will help facilitate the long-term solution to take the branding position of the existing +solution. Another effort I will be making is to get an app landing page online at the project's new domain: +[powerdnsadmin.org](https://powerdnsadmin.org). This will act as one more point of online exposure for the project which +will hopefully lend itself well to attracting additional community members. + +### Contribution Requirements + +Another big change that will be made with the new project, will be well-defined contribution requirements. I realize +these requirements can be demotivating for some, but they are a necessary evil to ensure the project actually achieves +its goals effectively. It's important to always remember that strict requirements are to everyone's benefit as they push +for order where chaos is quite destructive. + +### Closing + +I hope these announcements garner more participation in the PDA community. The project definitely needs more help to +achieve any goal at this point, so your participation is valued! diff --git a/docs/announcements/project-update-2023-11-25.md b/docs/announcements/project-update-2023-11-25.md new file mode 100644 index 0000000..5e1719f --- /dev/null +++ b/docs/announcements/project-update-2023-11-25.md @@ -0,0 +1,109 @@ +# PDA Project Update + +## Introduction + +Hello PDA community members, + +I know it has been quite awhile since the last formal announcement like this. Things have been quite busy and difficult +for me both professional and personally. While I try hard to never make my problems someone else's problems, I do +believe it's important to be transparent with the community. I'm not going to go into details, but I will say that I +have been dealing with some mental health issues that have been quite challenging. I'm not one to give up though, +so I'm pushing through and trying to get back on track. + +With that being said, let's jump into the announcements. + +### Project Maintenance + +Granted I haven't been nearly as active on the project as I would like to be, I have been keeping an eye on things and +trying to keep up with the maintenance. I know there are a lot of issues and feature requests that have been piling up, +and I'm sorry for that. Even if I had been more active in recent months, it would have not changed the true root cause +of the issue. + +This project was started out of a need for an individual's own use-case. I don't believe it was never intended to be a +commercial quality product nor a community project. It did however gain traction quickly and the community grew. This +is a great thing, but it also comes with some challenges. The biggest challenge is that the project was never designed +to be a community project. This means that the project lacks many of the things that are required to effectively manage +a community project. This is not to say that the project is doomed, but many of the fast-paced changes combined with +the lack of standards has lead to a difficult situation that makes implementing certain changes incredibly unproductive +and quite often, entirely counter-productive. + +After many years of accepting contributions from those who are not professional developers, the project has become quite +difficult to maintain. This is not to say that I don't appreciate the contributions, but it's important to understand +that the state of the code-base for the project is not in a good place. This is not uncommon with projects born how PDA +was born, so it's unfortunate but not unexpected. + +As of today, there are so many dependencies and a large amount of very poorly implemented features that it's difficult +to make any changes without breaking many other pieces. This is further complicated by the fact that the current code +base is harder to follow naturally since it largely lacks uniformity and standards. This lack of uniformity has lead to +a situation where automated regression testing is not possible. This is a very important aspect of any project that +expects to be able to make changes without breaking things. This is also a very important aspect of any project that +expects to be able to accept contributions from the community with minimum management resources. + +The hard reality is that the majority of stakeholders in the project are not professional developers. This naturally +means the amount of people that can offer quality contributions is very limited. This problem is further aggravated by +the poor quality feature implementation which is very hard to follow, even for seasoned developers like myself. So many +seemingly small issues that have been reported, have lead to finding that the resolution is not as simple as it seems. + +### New Direction + +As I previously stated in my last formal announcement, we would be working towards a total replacement of the project. +Unfortunately, this is not a simple task, and it's not something that can be done quickly. Furthermore, with +increasingly limited capacity in our own lives to work on this, we are essentially drowning in a sea of technical debt +created by the past decisions of the project to accept all contributions. We have essentially reached a point where +far too much time and resources are being wasted just to attempt to meet the current demand of requests on the current +edition of PDA. This is a tragedy because the efforts that are invested into the current edition, really aren't +creating true progress for the project, but instead merely delaying the inevitable. + +As I have stated before to many community members, one aspect of taking over management of this project to ultimately +save it and keep it alive, would involve making hard decisions that many will not agree with. It's unfortunate that +many of those who are less than supportive of these decisions, often lack the appropriate experience to understand the +importance of these decisions. I'm not saying that I'm always right, but I am saying that it's not hard to see where +this is headed without some drastic changes. + +With all of that being said, it's time for me to make some hard decisions. I have decided that the best course of +action is to stop accepting contributions to the current edition of PDA. At this point, due to the aforementioned +issues that lead to breaking the application with seemingly simple changes, it's just not worth the effort to try to +keep up with the current edition. This is not to say that I'm giving up on the project, but instead I'm going to +re-focus my efforts on the new edition of PDA. This is the only way to ensure that the project will survive and +hopefully thrive in the future. + +I will not abandon the current set of updates that were planned for the next release of `0.4.2` however. I have +re-scheduled that release to be out by the end of the year. This will be the last release of the current edition of +PDA. The consensus from some users is that the current edition is stable enough to be used in production environments. +I don't necessarily agree with that, but I do believe that it's stable enough to be used in production +environments with the understanding that it's not a commercial quality product. + +### Future Contributions + +For those of you wondering about contributions to the new edition of PDA, the answer for now is simple. I won't be +accepting any contributions to the new edition until I can achieve a stable release that delivers the core features of +the current edition. This is not to say that I won't be accepting any contributions at all, but instead that I will be +very selective about what contributions I accept. I believe this is the only way to ensure that a solid foundation not +only takes shape, but remains solid. + +It is well understood that many developers have their own ways of doing things, but it's important to understand +that this project is not a personal project. This project is a community project and therefore must be treated as such. +This means that the project must be engineered in a way that allows for the community to participate in the development +process. This is not possible if the project is not engineered in a way that is easy to follow and understand. + +### Project Enhancements + +It should be understood that one of the greatest benefits of this pivot is that it will allow for a more structured +development process. As a result of that, the project could potentially see a future where it adopts a whole new set of +features that weren't previously imagined. One prime example of this could be integration with registrar APIs. This +could make easy work of tasks such as DNSSEC key rotation, which is currently a very manual process. + +I am still working on final project requirements for additional phases of the new PDA edition, but these additions +won't receive any attention until the core features are implemented. I will be sure to make announcements as these +requirements are finalized. It is my intention to follow a request for proposal (RFP) process for these additional +features. This will allow the community to participate in the decision-making process for future expansion of the +project. + +### Closing + +I hope that by the time you have reached this point in the announcement, that I have elicited new hope for the +long-term future of the project. I know that many of you have been waiting for a long time for some of the features that have been +requested. I know that many of you have been waiting for a long time for some of the issues to be resolved, for +requested features to be implemented, and for the project to be more stable. It's unfortunate that it has taken this +long to get to this point, but this is the nature of life itself. I hope that you can understand that this is the only +reasonable gamble that the project survives and thrives in the future. 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 diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index d447a00..660f96b 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 @@ -84,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/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 723cb85..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 = 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..b154112 --- /dev/null +++ b/powerdnsadmin/lib/settings.py @@ -0,0 +1,638 @@ +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': '', + 'hsts_enabled': False, + '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, + 'pdns_admin_log_level': 'WARNING', + + # 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, + 'hsts_enabled': bool, + '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, + 'pdns_admin_log_level': str, + '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: + # 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) + + 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/domain.py b/powerdnsadmin/models/domain.py index bfa0445..f0b9a30 100644 --- a/powerdnsadmin/models/domain.py +++ b/powerdnsadmin/models/domain.py @@ -643,6 +643,8 @@ class Domain(db.Model): """ Update records from Master DNS server """ + import urllib.parse + domain = Domain.query.filter(Domain.name == domain_name).first() if domain: headers = {'X-API-Key': self.PDNS_API_KEY} @@ -650,7 +652,7 @@ class Domain(db.Model): r = utils.fetch_json(urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + '/servers/localhost/zones/{0}/axfr-retrieve'.format( - domain.name)), + urllib.parse.quote_plus(domain.name))), headers=headers, timeout=int( Setting().get('pdns_api_timeout')), @@ -673,6 +675,8 @@ class Domain(db.Model): """ Get zone DNSSEC information """ + import urllib.parse + domain = Domain.query.filter(Domain.name == domain_name).first() if domain: headers = {'X-API-Key': self.PDNS_API_KEY} @@ -681,7 +685,7 @@ class Domain(db.Model): urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + '/servers/localhost/zones/{0}/cryptokeys'.format( - domain.name)), + urllib.parse.quote_plus(domain.name))), headers=headers, timeout=int(Setting().get('pdns_api_timeout')), method='GET', @@ -709,6 +713,8 @@ class Domain(db.Model): """ Enable zone DNSSEC """ + import urllib.parse + domain = Domain.query.filter(Domain.name == domain_name).first() if domain: headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -718,7 +724,9 @@ class Domain(db.Model): jdata = utils.fetch_json( urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + - '/servers/localhost/zones/{0}'.format(domain.name)), + '/servers/localhost/zones/{0}'.format( + urllib.parse.quote_plus(domain.name) + )), headers=headers, timeout=int(Setting().get('pdns_api_timeout')), method='PUT', @@ -738,7 +746,8 @@ class Domain(db.Model): urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + '/servers/localhost/zones/{0}/cryptokeys'.format( - domain.name)), + urllib.parse.quote_plus(domain.name) + )), headers=headers, timeout=int(Setting().get('pdns_api_timeout')), method='POST', @@ -775,6 +784,8 @@ class Domain(db.Model): """ Remove keys DNSSEC """ + import urllib.parse + domain = Domain.query.filter(Domain.name == domain_name).first() if domain: headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -784,7 +795,7 @@ class Domain(db.Model): urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + '/servers/localhost/zones/{0}/cryptokeys/{1}'.format( - domain.name, key_id)), + urllib.parse.quote_plus(domain.name), key_id)), headers=headers, timeout=int(Setting().get('pdns_api_timeout')), method='DELETE', 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 diff --git a/powerdnsadmin/models/sessions.py b/powerdnsadmin/models/sessions.py new file mode 100644 index 0000000..b699a3d --- /dev/null +++ b/powerdnsadmin/models/sessions.py @@ -0,0 +1,39 @@ +from flask import current_app, session +from flask_login import current_user +from .base import db + + +class Sessions(db.Model): + id = db.Column(db.Integer, primary_key=True) + session_id = db.Column(db.String(255), index=True, unique=True) + data = db.Column(db.BLOB) + expiry = db.Column(db.DateTime) + + def __init__(self, + id=None, + session_id=None, + data=None, + expiry=None): + self.id = id + self.session_id = session_id + self.data = data + self.expiry = expiry + + def __repr__(self): + return ''.format(self.id) + + @staticmethod + def clean_up_expired_sessions(): + """Clean up expired sessions in the database""" + from datetime import datetime + from sqlalchemy import or_ + from sqlalchemy.exc import SQLAlchemyError + + try: + db.session.query(Sessions).filter(or_(Sessions.expiry < datetime.now(), Sessions.expiry is None)).delete() + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + current_app.logger.error(e) + return False + return True diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 1932a9c..2bcd8c5 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) @@ -535,27 +72,30 @@ 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(self.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 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 +106,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/models/user.py b/powerdnsadmin/models/user.py index e989aa0..42f894f 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -133,6 +133,16 @@ class User(db.Model): conn.protocol_version = ldap.VERSION3 return conn + def escape_filter_chars(self, filter_str): + """ + Escape chars for ldap search + """ + escape_chars = ['\\', '*', '(', ')', '\x00'] + replace_chars = ['\\5c', '\\2a', '\\28', '\\29', '\\00'] + for escape_char in escape_chars: + filter_str = filter_str.replace(escape_char, replace_chars[escape_chars.index(escape_char)]) + return filter_str + def ldap_search(self, searchFilter, baseDN, retrieveAttributes=None): searchScope = ldap.SCOPE_SUBTREE @@ -280,7 +290,7 @@ class User(db.Model): Operator=LDAP_OPERATOR_GROUP, User=LDAP_USER_GROUP, ) - user_dn = ldap_result[0][0][0] + user_dn = self.escape_filter_chars(ldap_result[0][0][0]) sf_groups = "" for group in ldap_group_security_roles.values(): @@ -408,12 +418,12 @@ class User(db.Model): Create local user witch stores username / password in the DB """ # check if username existed - user = User.query.filter(User.username == self.username).first() + user = User.query.filter(str(User.username).lower() == self.username.lower()).first() if user: return {'status': False, 'msg': 'Username is already in use'} # check if email existed - user = User.query.filter(User.email == self.email).first() + user = User.query.filter(str(User.email).lower() == self.email.lower()).first() if user: return {'status': False, 'msg': 'Email address is already in use'} diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 5b96b18..4887a87 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -119,6 +119,9 @@ def extract_changelogs_from_history(histories, record_name=None, record_type=Non for entry in histories: changes = [] + if entry.detail is None: + continue + if "add_rrsets" in entry.detail: details = json.loads(entry.detail) if not details['add_rrsets'] and not details['del_rrsets']: @@ -128,14 +131,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 +161,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 +1155,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 +1277,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 +1303,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 +1313,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, @@ -1396,7 +1405,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', @@ -1460,52 +1469,29 @@ 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') - 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 = {} - 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 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')) -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 @@ -1517,6 +1503,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 +1511,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..6cd9d38 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -66,15 +66,15 @@ def domain(domain_name): current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets))) # API server might be down, misconfigured - if not rrsets and domain.type != 'slave': + if not rrsets and str(domain.type).lower() != 'slave': abort(500) 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 = [] diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index d56ce61..23d88bb 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -258,7 +258,7 @@ def login(): result = user.create_local_user() if not result['status']: - current_app.logger.warning('Unable to create ' + azure_username) + current_app.logger.warning('Unable to create ' + azure_username + ' Reasoning: ' + result['msg']) session.pop('azure_token', None) # note: a redirect to login results in an endless loop, so render the login page instead return render_template('login.html', diff --git a/powerdnsadmin/routes/user.py b/powerdnsadmin/routes/user.py index adba502..469b459 100644 --- a/powerdnsadmin/routes/user.py +++ b/powerdnsadmin/routes/user.py @@ -37,6 +37,11 @@ def before_request(): minutes=int(Setting().get('session_timeout'))) session.modified = True + # Clean up expired sessions in the database + if Setting().get('session_type') == 'sqlalchemy': + from ..models.sessions import Sessions + Sessions().clean_up_expired_sessions() + @user_bp.route('/profile', methods=['GET', 'POST']) @login_required 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') diff --git a/powerdnsadmin/static/assets/css/roboto_mono.css b/powerdnsadmin/static/assets/css/roboto_mono.css index dc14ffb..ceb7256 100644 --- a/powerdnsadmin/static/assets/css/roboto_mono.css +++ b/powerdnsadmin/static/assets/css/roboto_mono.css @@ -4,8 +4,8 @@ font-style: normal; font-weight: 300; src: local('Roboto Mono Light'), local('RobotoMono-Light'), - url('/static/assets/fonts/roboto-mono-v7-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('/static/assets/fonts/roboto-mono-v7-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url('../fonts/roboto-mono-v7-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-mono-v7-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* roboto-mono-regular - latin */ @font-face { @@ -13,8 +13,8 @@ font-style: normal; font-weight: 400; src: local('Roboto Mono'), local('RobotoMono-Regular'), - url('/static/assets/fonts/roboto-mono-v7-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('/static/assets/fonts/roboto-mono-v7-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url('../fonts/roboto-mono-v7-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-mono-v7-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* roboto-mono-700 - latin */ @font-face { @@ -22,6 +22,6 @@ font-style: normal; font-weight: 700; src: local('Roboto Mono Bold'), local('RobotoMono-Bold'), - url('/static/assets/fonts/roboto-mono-v7-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url('/static/assets/fonts/roboto-mono-v7-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url('../fonts/roboto-mono-v7-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('../fonts/roboto-mono-v7-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } \ No newline at end of file diff --git a/powerdnsadmin/static/assets/css/source_sans_pro.css b/powerdnsadmin/static/assets/css/source_sans_pro.css index 06ef9f4..8cd030a 100644 --- a/powerdnsadmin/static/assets/css/source_sans_pro.css +++ b/powerdnsadmin/static/assets/css/source_sans_pro.css @@ -3,89 +3,89 @@ font-family: 'Source Sans Pro'; font-style: normal; font-weight: 300; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-300.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-300.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), - url('/static/assets/fonts/source-sans-pro-v13-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-300.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-300.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-300.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-300.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } /* source-sans-pro-300italic - latin */ @font-face { font-family: 'Source Sans Pro'; font-style: italic; font-weight: 300; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-300italic.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-300italic.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), - url('/static/assets/fonts/source-sans-pro-v13-latin-300italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300italic.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300italic.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300italic.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-300italic.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-300italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-300italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-300italic.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-300italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-300italic.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } /* source-sans-pro-regular - latin */ @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 400; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-regular.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-regular.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), - url('/static/assets/fonts/source-sans-pro-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-regular.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-regular.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-regular.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-regular.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } /* source-sans-pro-italic - latin */ @font-face { font-family: 'Source Sans Pro'; font-style: italic; font-weight: 400; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-italic.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-italic.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), - url('/static/assets/fonts/source-sans-pro-v13-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-italic.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-italic.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-italic.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-italic.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } /* source-sans-pro-600 - latin */ @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 600; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-600.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-600.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), - url('/static/assets/fonts/source-sans-pro-v13-latin-600.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-600.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-600.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-600.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-600.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-600.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } /* source-sans-pro-600italic - latin */ @font-face { font-family: 'Source Sans Pro'; font-style: italic; font-weight: 600; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-600italic.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-600italic.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), - url('/static/assets/fonts/source-sans-pro-v13-latin-600italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600italic.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600italic.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600italic.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-600italic.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-600italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-600italic.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-600italic.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-600italic.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-600italic.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } /* source-sans-pro-700 - latin */ @font-face { font-family: 'Source Sans Pro'; font-style: normal; font-weight: 700; - src: url('/static/assets/fonts/source-sans-pro-v13-latin-700.eot'); /* IE9 Compat Modes */ + src: url('../fonts/source-sans-pro-v13-latin-700.eot'); /* IE9 Compat Modes */ src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), - url('/static/assets/fonts/source-sans-pro-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('/static/assets/fonts/source-sans-pro-v13-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-700.woff') format('woff'), /* Modern Browsers */ - url('/static/assets/fonts/source-sans-pro-v13-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */ - url('/static/assets/fonts/source-sans-pro-v13-latin-700.svg#SourceSansPro') format('svg'); /* Legacy iOS */ + url('../fonts/source-sans-pro-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('../fonts/source-sans-pro-v13-latin-700.woff2') format('woff2'), /* Super Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-700.woff') format('woff'), /* Modern Browsers */ + url('../fonts/source-sans-pro-v13-latin-700.ttf') format('truetype'), /* Safari, Android, iOS */ + url('../fonts/source-sans-pro-v13-latin-700.svg#SourceSansPro') format('svg'); /* Legacy iOS */ } \ No newline at end of file diff --git a/powerdnsadmin/static/custom/js/custom.js b/powerdnsadmin/static/custom/js/custom.js index 1b7a983..e4890d9 100644 --- a/powerdnsadmin/static/custom/js/custom.js +++ b/powerdnsadmin/static/custom/js/custom.js @@ -30,14 +30,14 @@ function applyChanges(data, url, showResult, refreshPage) { function applyRecordChanges(data, domain) { $.ajax({ type : "POST", - url : $SCRIPT_ROOT + '/domain/' + domain + '/apply', + url : $SCRIPT_ROOT + '/domain/' + encodeURIComponent(domain) + '/apply', data : JSON.stringify(data),// now data come in this function contentType : "application/json; charset=utf-8", crossDomain : true, dataType : "json", success : function(data, status, jqXHR) { // update Apply button value - $.getJSON($SCRIPT_ROOT + '/domain/' + domain + '/info', function(data) { + $.getJSON($SCRIPT_ROOT + '/domain/' + encodeURIComponent(domain) + '/info', function(data) { $(".button_apply_changes").val(data['serial']); }); 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 %} - diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html index c8708e8..ff69f3b 100644 --- a/powerdnsadmin/templates/base.html +++ b/powerdnsadmin/templates/base.html @@ -31,14 +31,14 @@