Updating master branch to the latest release of 0.4.2 (#1743)

This commit is contained in:
Matt Scott 2024-01-31 16:40:41 -05:00 committed by GitHub
commit d255cb3d16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1292 additions and 879 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1 +1 @@
0.4.1
0.4.2

View File

@ -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'

View File

@ -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'

View File

@ -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()"]

View File

@ -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!

View File

@ -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.

View File

@ -40,7 +40,6 @@
- Setting up a zone
- Adding a record
- <whatever else>
## Feature usage

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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',

View File

@ -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

View File

@ -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 '<Sessions {0}>'.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

View File

@ -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)

View File

@ -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'}

View File

@ -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')

View File

@ -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 = []

View File

@ -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',

View File

@ -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

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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+ */
}

View File

@ -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 */
}

View File

@ -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']);
});

View File

@ -133,7 +133,7 @@
</option>
{% endwith %}
{% else %}
<option {% if account.id == domain.account_id %}selected{% endif %}
<option {% if account.id and account.id == domain.account_id %}selected{% endif %}
value="{{ domain.name }}">
{{ domain.name }}
</option>

View File

@ -31,14 +31,14 @@
<!-- Sidebar toggle button-->
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button">
<a class="nav-link no-prompt" data-widget="pushmenu" href="#" role="button">
<i class="fa-solid fa-bars"></i>
</a>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" data-widget="fullscreen" href="#" role="button">
<a class="nav-link no-prompt" data-widget="fullscreen" href="#" role="button">
<i class="fa-solid fa-expand-arrows-alt"></i>
</a>
</li>
@ -154,7 +154,7 @@
</a>
</li>
<li class="{{ 'nav-item active' if active_page == 'admin_settings' else 'nav-item' }}">
<a href="#" class="nav-link">
<a href="#" class="nav-link no-prompt">
<i class="nav-icon fa-solid fa-cog"></i>
<p>
Settings
@ -228,7 +228,7 @@
<footer class="main-footer">
<strong><a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin">PowerDNS-Admin</a></strong> - A PowerDNS web
interface with advanced features.
<span class="float-right">Version 0.4.1</span>
<span class="float-right">Version 0.4.2</span>
</footer>
</div>
<!-- ./wrapper -->
@ -418,4 +418,4 @@
{% block modals %}
{% endblock %}
</body>
</html>
</html>

View File

@ -181,17 +181,17 @@
{% if current_user.role.name in ['Administrator', 'Operator'] or not SETTING.get('dnssec_admins_only') %}
$(document.body).on("click", ".button_dnssec", function () {
var domain = $(this).prop('id');
getdnssec($SCRIPT_ROOT + '/domain/' + domain + '/dnssec', domain);
getdnssec($SCRIPT_ROOT + '/domain/' + encodeURIComponent(domain) + '/dnssec', domain);
});
$(document.body).on("click", ".button_dnssec_enable", function () {
var domain = $(this).prop('id');
enable_dns_sec($SCRIPT_ROOT + '/domain/' + domain + '/dnssec/enable', '{{ csrf_token() }}');
enable_dns_sec($SCRIPT_ROOT + '/domain/' + encodeURIComponent(domain) + '/dnssec/enable', '{{ csrf_token() }}');
});
$(document.body).on("click", ".button_dnssec_disable", function () {
var domain = $(this).prop('id');
enable_dns_sec($SCRIPT_ROOT + '/domain/' + domain + '/dnssec/disable', '{{ csrf_token() }}');
enable_dns_sec($SCRIPT_ROOT + '/domain/' + encodeURIComponent(domain) + '/dnssec/disable', '{{ csrf_token() }}');
});
{% endif %}
</script>

View File

@ -22,6 +22,15 @@
{% block content %}
<section class="content">
<div class="container-fluid">
<div class="card" id="unsaved-changes-card" style="display: none; position: sticky; top: 0; z-index: 999;">
<div class="card-header" style="background-color: yellow;">
<h3 class="card-title" style="color: red;">
Warning: Unsaved Changes
</h3>
</div>
<div class="card-body" style="font-size: 1rem; color: black; background-color: yellow;">
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
@ -77,11 +86,7 @@
<th>Data</th>
{% if domain.type != 'Slave' %}
<th>Comment</th>
<th>Edit</th>
<th>Delete</th>
{% if current_user.role.name in ['Administrator', 'Operator'] or allow_user_view_history %}
<th>Changelog</th>
{% endif %}
<th>Actions</th>
{% else %}
<th>Invisible Sorting Column</th>
{% endif %}
@ -99,7 +104,7 @@
<td>{{ record.comment }}</td>
<td>
{% if record.is_allowed_edit() %}
<button type="button" class="btn btn-sm btn-warning button_edit">
<button type="button" title="Edit" class="btn btn-sm btn-warning button_edit">
<i class="fa-solid fa-edit"></i>
</button>
{% else %}
@ -107,23 +112,19 @@
<i class="fa-solid fa-exclamation-circle"></i>
</button>
{% endif %}
</td>
<td>
{% if record.is_allowed_delete() %}
<button type="button" class="btn btn-sm btn-danger button_delete">
<button type="button" title="Delete" class="btn btn-sm btn-danger button_delete">
<i class="fa-solid fa-trash"></i>
</button>
{% endif %}
</td>
{% if current_user.role.name in ['Administrator', 'Operator'] or allow_user_view_history %}
<td>
<button type="button"
{% if current_user.role.name in ['Administrator', 'Operator'] or allow_user_view_history %}
<button type="button" title="Changelog"
onclick="show_record_changelog('{{ record.name }}','{{ record.type }}',event)"
class="btn btn-primary">
class="btn btn-primary btn-sm">
<i class="fa-solid fa-history" aria-hidden="true"></i>
</button>
</td>
{% endif %}
{% endif %}
</td>
{% endif %}
<!-- hidden column that we can sort on -->
<td>1</td>
@ -146,6 +147,7 @@
table#tbl_records thead th:nth-child(2),
table#tbl_records thead th:nth-child(3),
table#tbl_records thead th:nth-child(4) { width: 100px; }
table#tbl_records thead th:nth-child(7) { width: 80px; }
table#tbl_records tbody td { text-align: center; }
table#tbl_records tbody td:nth-child(0n+5),
table#tbl_records tbody td:nth-child(0n+6) { text-align: left; word-break: break-all; }
@ -194,11 +196,7 @@
// regardless of whatever sorting is done. See orderFixed
visible: false,
{% if domain.type != 'Slave' %}
{% if current_user.role.name in ['Administrator', 'Operator'] or allow_user_view_history %}
targets: [9]
{% else %}
targets: [8]
{% endif %}
targets: [7]
{% else %}
targets: [5]
{% endif %}
@ -216,11 +214,7 @@
}
],
{% if domain.type != 'Slave' %}
{% if current_user.role.name in ['Administrator', 'Operator'] or allow_user_view_history %}
"orderFixed": [[9, 'asc']]
{% else %}
"orderFixed": [[8, 'asc']]
{% endif %}
"orderFixed": [[7, 'asc']]
{% else %}
"orderFixed": [[5, 'asc']]
{% endif %}
@ -251,6 +245,7 @@
$("#button_delete_confirm").unbind().one('click', function (e) {
table.row(nRow).remove().draw();
detectUnsavedChanges(table);
modal.modal('hide');
});
@ -355,6 +350,7 @@
e.stopPropagation();
var table = $("#tbl_records").DataTable();
saveRow(table, nEditing);
detectUnsavedChanges(table);
nEditing = null;
nNew = false;
});
@ -368,6 +364,94 @@
}, $SCRIPT_ROOT + '/domain/' + domain + '/update', true);
});
var unsavedChanges = false;
function detectUnsavedChanges(table) {
// Reset unsavedChanges to false at the start of the function
unsavedChanges = false;
var index = 0;
var origcount = {{ records|length }};
var count = table.page.info().recordsTotal;
var changes = {}; // Dictionary to store changes
if (count != origcount) {
unsavedChanges = true; //a record was either added or deleted.
} else {
{% for record in records %}
var origrecordname = '{{ (record.name,domain.name) | display_record_name }}';
var origrecordtype = '{{ record.type }}';
var origrecordstatus = '{{ record.status }}';
var origrecordttl = '{{ record.ttl }}';
var origrecorddata = '{{ record.data }}';
origrecorddata = origrecorddata.replace(/&#34;/g, '\"');
var origrecordcomment = '{{ record.comment }}';
if (!table.row(index) || typeof table.row(index) == 'undefined') {
unsavedChanges = true; //sanity check otherwise below code throws error if row at that index doesn't exist.
} else {
var editrecordname = table.row(index).data()[0];
var editrecordtype = table.row(index).data()[1];
var editrecordstatus = table.row(index).data()[2];
var editrecordttl = table.row(index).data()[3];
var editrecorddata = table.row(index).data()[4];
var editrecordcomment = table.row(index).data()[5];
if (origrecordname != editrecordname || origrecordtype != editrecordtype || origrecordstatus != editrecordstatus || origrecordttl != editrecordttl || origrecorddata != editrecorddata || origrecordcomment != editrecordcomment) {
unsavedChanges = true;
}
}
index++;
{% endfor %}
}
unsavedChangesWarning(unsavedChanges);
// Get the modal and the navigation links
var modal = document.getElementById('WarnLeave');
var navLinks = document.querySelectorAll('.nav-link');
// Listen for clicks on navigation links
navLinks.forEach(function(link) {
if (!link.classList.contains('no-prompt')) {
link.addEventListener('click', function(event) {
if (unsavedChanges) {
event.preventDefault(); // Prevent navigation
modal.style.display = "block"; // Show the modal
// Get the buttons
var stayButton = document.getElementById('stay');
var leaveButton = document.getElementById('leave');
// When the user clicks on "Stay", close the modal
stayButton.onclick = function() {
modal.style.display = "none";
}
// When the user clicks on "Leave", navigate away
leaveButton.onclick = function() {
unsavedChanges = false; // No unsaved changes anymore
location.href = link.href; // Navigate to the clicked link
}
}
});
}
});
}
function unsavedChangesWarning(unsavedChanges) {
var card = document.getElementById("unsaved-changes-card");
var cardBody = card.querySelector(".card-body");
if(unsavedChanges){
var message = 'There are unsaved changes for the zone. Please click Save Changes to save all of your changes.';
card.style.display = 'block'; // to show the card
cardBody.innerHTML = message;
return false;
} else {
card.style.display = 'none'; // to hide the card
}
}
{% if SETTING.get('record_helper') %}
//handle wacky record types
$(document.body).on("focus", "#current_edit_record_data", function (e) {
@ -597,10 +681,6 @@
});
{% endif %}
window.onload = function () {
document.getElementById("loading-spinner").style.display = "none";
}
</script>
{% endblock %}
@ -677,4 +757,20 @@
</div>
</div>
</div>
<div class="modal" tabindex="-1" id="WarnLeave">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Unsaved Changes</h5>
</div>
<div class="modal-body">
<p>You have unsaved changes. Are you sure you want to navigate away?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="stay">Stay</button>
<button type="button" class="btn btn-secondary" id="leave">Leave</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -40,6 +40,7 @@
<div class="form-group">
<input type="text" class="form-control" placeholder="Username" name="username"
data-error="Please input your username" required
autofocus
{% if username %}value="{{ username }}" {% endif %}>
<span class="help-block with-errors"></span>
</div>

View File

@ -7,17 +7,17 @@ Flask-SQLAlchemy==2.5.1
Flask-SSLify==0.1.5
Flask-SeaSurf==1.1.1
Flask-Session==0.4.0
Flask==2.1.3
Flask==2.2.5
Jinja2==3.1.3
PyYAML==5.4
SQLAlchemy==1.3.24
PyYAML==6.0.1
SQLAlchemy==1.4.51
#alembic==1.9.0
bcrypt==4.0.1
bcrypt==4.1.2
bravado-core==5.17.1
certifi==2022.12.7
certifi==2023.11.17
cffi==1.15.1
configobj==5.0.8
cryptography==39.0.2 # fixes CVE-2023-0286, CVE-2023-23931
cryptography==42.0.2
cssmin==0.2.0
dnspython>=2.3.0
flask_session_captcha==1.3.0
@ -26,23 +26,23 @@ itsdangerous==2.1.2
jsonschema[format]>=2.5.1,<4.0.0 # until https://github.com/Yelp/bravado-core/pull/385
lima==0.5
--use-feature=no-binary-enable-wheel-cache lxml==4.9.0
mysqlclient==2.0.1
mysqlclient==2.2.1
passlib==1.7.4
#pyOpenSSL==22.1.0
pyasn1==0.4.8
pyotp==2.8.0
pytest==7.2.1
pytest==7.4.4
python-ldap==3.4.3
python3-saml==1.15.0
pytimeparse==1.1.8
pytz==2022.7.1
qrcode==7.3.1
requests==2.28.2
requests==2.31.0
rjsmin==1.2.1
webcolors==1.12
werkzeug==2.1.2
werkzeug==2.3.8
zipp==3.11.0
rcssmin==1.1.1
zxcvbn==4.4.28
psycopg2==2.9.5
setuptools==65.5.1 # fixes CVE-2022-40897
setuptools==65.5.1 # fixes CVE-2022-40897