+
+ You can use Google Authenticator (Android
+ - iOS)
+
+ or FreeOTP (Android
+ - iOS)
+ on your smartphone to scan the QR code or type the secret key.
+
+ Make sure only you can see this QR Code
+ and secret key, and nobody can capture them.
+
+
+ Please input your OTP token to continue, to ensure the seed has been scanned correctly.
+
+
+
+
+
+ {% assets "js_login" -%}
+
+ {%- endassets %}
+ {% assets "js_validation" -%}
+
+ {%- endassets %}
+
\ No newline at end of file
diff --git a/powerdnsadmin/templates/user_profile.html b/powerdnsadmin/templates/user_profile.html
index a13d3d3..8570f7e 100644
--- a/powerdnsadmin/templates/user_profile.html
+++ b/powerdnsadmin/templates/user_profile.html
@@ -93,6 +93,14 @@
{% if current_user.otp_secret %}
+
+ Your secret key is:
+
+
You can use Google Authenticator (Android
- iOS)
on your smartphone to scan the QR code.
- Make sure only you can see this QR Code and
- nobody can capture it.
+ Make sure only you can see this QR Code and secret key and
+ nobody can capture them.
{% endif %}
From 9ef0f2b8d6982d460eac49a8db576eb3a7adab76 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 29 Nov 2021 17:59:34 +0000
Subject: [PATCH 005/203] Bump python-ldap from 3.3.1 to 3.4.0
Bumps [python-ldap](https://github.com/python-ldap/python-ldap) from 3.3.1 to 3.4.0.
- [Release notes](https://github.com/python-ldap/python-ldap/releases)
- [Commits](https://github.com/python-ldap/python-ldap/compare/python-ldap-3.3.1...python-ldap-3.4.0)
---
updated-dependencies:
- dependency-name: python-ldap
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 5e97a12..c33d0ce 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,7 +8,7 @@ mysqlclient==2.0.1
configobj==5.0.6
bcrypt>=3.1.7
requests==2.24.0
-python-ldap==3.3.1
+python-ldap==3.4.0
pyotp==2.4.0
qrcode==6.1
dnspython>=1.16.0
From 7808febad8fb0a56cdecce2d7cdb9a92ec30ff06 Mon Sep 17 00:00:00 2001
From: zoeller-freinet <86965592+zoeller-freinet@users.noreply.github.com>
Date: Fri, 17 Dec 2021 12:48:11 +0100
Subject: [PATCH 006/203] login.html: don't suggest previous OTP tokens
This change has been tested to work with:
- Chromium 96.0.4664.93
- Firefox 95.0
- Edge 96.0.1054.57
---
powerdnsadmin/templates/login.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/powerdnsadmin/templates/login.html b/powerdnsadmin/templates/login.html
index dcf96cf..6b017af 100644
--- a/powerdnsadmin/templates/login.html
+++ b/powerdnsadmin/templates/login.html
@@ -50,7 +50,7 @@
{% if SETTING.get('otp_field_enabled') %}
-
+
{% endif %}
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
From 328780e2d41b7120afecf22c83b8d88864eaeaf7 Mon Sep 17 00:00:00 2001
From: RGanor <44501230+RGanor@users.noreply.github.com>
Date: Sat, 25 Dec 2021 16:17:54 +0200
Subject: [PATCH 007/203] Revert "Merge branch 'master' into master"
This reverts commit ca4c145a1809b3fee8510b49fb517a844c3524eb, reversing
changes made to 7808febad8fb0a56cdecce2d7cdb9a92ec30ff06.
---
powerdnsadmin/routes/dashboard.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/powerdnsadmin/routes/dashboard.py b/powerdnsadmin/routes/dashboard.py
index a23ffa1..8cf1b12 100644
--- a/powerdnsadmin/routes/dashboard.py
+++ b/powerdnsadmin/routes/dashboard.py
@@ -208,7 +208,6 @@ def dashboard():
history_number=history_number,
uptime=uptime,
histories=detailedHistories,
- histories=history,
show_bg_domain_button=show_bg_domain_button,
pdns_version=Setting().get('pdns_version'))
From 302e793665a54ce6b083133f471edfc1cf120e96 Mon Sep 17 00:00:00 2001
From: Christian
Date: Fri, 31 Dec 2021 00:55:59 +0100
Subject: [PATCH 008/203] Add button for admin page in single Domain view
(#1076)
* Added button for admin page in domain overview
---
powerdnsadmin/templates/domain.html | 5 +++++
1 file changed, 5 insertions(+)
mode change 100644 => 100755 powerdnsadmin/templates/domain.html
diff --git a/powerdnsadmin/templates/domain.html b/powerdnsadmin/templates/domain.html
old mode 100644
new mode 100755
index 3ac96d1..8366c74
--- a/powerdnsadmin/templates/domain.html
+++ b/powerdnsadmin/templates/domain.html
@@ -33,6 +33,11 @@
Update from Master
{% endif %}
+ {% if current_user.role.name in ['Administrator', 'Operator'] %}
+
+ {% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
Fill in all the fields to the in the form to the left.
- Name is an account identifier. It will be stored as all lowercase letters (no
- spaces, special characters etc).
+ Name is an account identifier. It will be lowercased and can contain alphanumeric
+ characters, hyphens and underscores (no space or other special character is allowed). Description is a user friendly name for this account. Contact person is the name of a contact person at the account. Mail Address is an e-mail address for the contact person.
From eb13b37e096f65e448cc09373d9d9708d0a220e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20BECOT?=
Date: Wed, 1 Dec 2021 14:35:05 +0100
Subject: [PATCH 069/203] feat: Add the extra chars as an option
---
powerdnsadmin/models/account.py | 6 +++++-
powerdnsadmin/models/setting.py | 11 ++++++-----
powerdnsadmin/routes/admin.py | 3 ++-
powerdnsadmin/routes/index.py | 10 +++++++---
powerdnsadmin/templates/admin_edit_account.html | 5 +++--
5 files changed, 23 insertions(+), 12 deletions(-)
diff --git a/powerdnsadmin/models/account.py b/powerdnsadmin/models/account.py
index 4e3ca97..7425afc 100644
--- a/powerdnsadmin/models/account.py
+++ b/powerdnsadmin/models/account.py
@@ -34,8 +34,12 @@ class Account(db.Model):
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
if self.name is not None:
+ if Setting().get('account_name_extra_chars'):
+ char_list = "abcdefghijklmnopqrstuvwxyz0123456789_-."
+ else:
+ char_list = "abcdefghijklmnopqrstuvwxyz0123456789"
self.name = ''.join(c for c in self.name.lower()
- if c in "abcdefghijklmnopqrstuvwxyz0123456789_-")
+ if c in char_list)
def __repr__(self):
return ''.format(self.name)
diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py
index cdeb865..51e78e5 100644
--- a/powerdnsadmin/models/setting.py
+++ b/powerdnsadmin/models/setting.py
@@ -192,7 +192,8 @@ class Setting(db.Model):
'custom_css': '',
'otp_force': False,
'max_history_records': 1000,
- 'deny_domain_override': False
+ 'deny_domain_override': False,
+ 'account_name_extra_chars': False
}
def __init__(self, id=None, name=None, value=None):
@@ -273,15 +274,15 @@ class Setting(db.Model):
def get(self, setting):
if setting in self.defaults:
-
+
if setting.upper() in current_app.config:
result = current_app.config[setting.upper()]
else:
result = self.query.filter(Setting.name == setting).first()
-
+
if result is not None:
if hasattr(result,'value'):
- result = result.value
+ result = result.value
return strtobool(result) if result in [
'True', 'False'
] else result
@@ -289,7 +290,7 @@ class Setting(db.Model):
return self.defaults[setting]
else:
current_app.logger.error('Unknown setting queried: {0}'.format(setting))
-
+
def get_records_allow_to_edit(self):
return list(
set(self.get_forward_records_allow_to_edit() +
diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py
index b0adc61..c5f28d1 100644
--- a/powerdnsadmin/routes/admin.py
+++ b/powerdnsadmin/routes/admin.py
@@ -1268,7 +1268,8 @@ def setting_basic():
'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name',
'session_timeout', 'warn_session_timeout', 'ttl_options',
'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email',
- 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records', 'otp_force', 'deny_domain_override', 'enforce_api_ttl'
+ 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records', 'otp_force',
+ 'deny_domain_override', 'enforce_api_ttl', 'account_name_extra_chars'
]
return render_template('admin_setting_basic.html', settings=settings)
diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py
index 342b06e..6f5f0e2 100644
--- a/powerdnsadmin/routes/index.py
+++ b/powerdnsadmin/routes/index.py
@@ -401,7 +401,7 @@ def login():
if name_prop in me and desc_prop in me:
accounts_name_prop = [me[name_prop]] if type(me[name_prop]) is not list else me[name_prop]
accounts_desc_prop = [me[desc_prop]] if type(me[desc_prop]) is not list else me[desc_prop]
-
+
#Run on all groups the user is in by the index num.
for i in range(len(accounts_name_prop)):
description = ''
@@ -411,7 +411,7 @@ def login():
account_to_add.append(account)
user_accounts = user.get_accounts()
-
+
# Add accounts
for account in account_to_add:
if account not in user_accounts:
@@ -1092,8 +1092,12 @@ def create_group_to_account_mapping():
def handle_account(account_name, account_description=""):
+ if Setting().get('account_name_extra_chars'):
+ char_list = "abcdefghijklmnopqrstuvwxyz0123456789_-."
+ else:
+ char_list = "abcdefghijklmnopqrstuvwxyz0123456789"
clean_name = ''.join(c for c in account_name.lower()
- if c in "abcdefghijklmnopqrstuvwxyz0123456789_-")
+ if c in char_list)
if len(clean_name) > Account.name.type.length:
current_app.logger.error(
"Account name {0} too long. Truncated.".format(clean_name))
diff --git a/powerdnsadmin/templates/admin_edit_account.html b/powerdnsadmin/templates/admin_edit_account.html
index 0514ead..1946bc9 100644
--- a/powerdnsadmin/templates/admin_edit_account.html
+++ b/powerdnsadmin/templates/admin_edit_account.html
@@ -49,7 +49,7 @@
{% if invalid_accountname %}
Cannot be blank and must only contain alphanumeric
- characters, hyphens or underscores.
+ characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens or underscores{% endif %}.
{% elif duplicate_accountname %}
Account name already in use.
{% endif %}
@@ -113,7 +113,8 @@
Fill in all the fields to the in the form to the left.
Name is an account identifier. It will be lowercased and can contain alphanumeric
- characters, hyphens and underscores (no space or other special character is allowed).
+ characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens and underscores (no space or other special character is allowed)
+ {% else %} (no extra character is allowed){% endif %}. Description is a user friendly name for this account. Contact person is the name of a contact person at the account. Mail Address is an e-mail address for the contact person.
From a87b9315202fbe6af50293874df094ccde2a54e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20BECOT?=
Date: Fri, 31 Dec 2021 09:51:12 +0100
Subject: [PATCH 070/203] feat: Move the account parse calls to a method
---
powerdnsadmin/lib/errors.py | 8 ++++++-
powerdnsadmin/models/account.py | 40 +++++++++++++++++++++++----------
powerdnsadmin/routes/api.py | 22 ++++++++++--------
powerdnsadmin/routes/index.py | 26 +++++++++------------
4 files changed, 58 insertions(+), 38 deletions(-)
diff --git a/powerdnsadmin/lib/errors.py b/powerdnsadmin/lib/errors.py
index d432bf4..e1e0785 100644
--- a/powerdnsadmin/lib/errors.py
+++ b/powerdnsadmin/lib/errors.py
@@ -136,6 +136,13 @@ class AccountNotExists(StructuredException):
self.message = message
self.name = name
+class InvalidAccountNameException(StructuredException):
+ status_code = 400
+
+ def __init__(self, name=None, message="The account name is invalid"):
+ StructuredException.__init__(self)
+ self.message = message
+ self.name = name
class UserCreateFail(StructuredException):
status_code = 500
@@ -145,7 +152,6 @@ class UserCreateFail(StructuredException):
self.message = message
self.name = name
-
class UserCreateDuplicate(StructuredException):
status_code = 409
diff --git a/powerdnsadmin/models/account.py b/powerdnsadmin/models/account.py
index 7425afc..ab2341e 100644
--- a/powerdnsadmin/models/account.py
+++ b/powerdnsadmin/models/account.py
@@ -3,6 +3,7 @@ from flask import current_app
from urllib.parse import urljoin
from ..lib import utils
+from ..lib.errors import InvalidAccountNameException
from .base import db
from .setting import Setting
from .user import User
@@ -22,7 +23,7 @@ class Account(db.Model):
back_populates="accounts")
def __init__(self, name=None, description=None, contact=None, mail=None):
- self.name = name
+ self.name = Account.sanitize_name(name) if name is not None else name
self.description = description
self.contact = contact
self.mail = mail
@@ -33,13 +34,30 @@ class Account(db.Model):
self.PDNS_VERSION = Setting().get('pdns_version')
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
- if self.name is not None:
- if Setting().get('account_name_extra_chars'):
- char_list = "abcdefghijklmnopqrstuvwxyz0123456789_-."
- else:
- char_list = "abcdefghijklmnopqrstuvwxyz0123456789"
- self.name = ''.join(c for c in self.name.lower()
- if c in char_list)
+
+ @staticmethod
+ def sanitize_name(name):
+ """
+ Formats the provided name to fit into the constraint
+ """
+ if not isinstance(name, str):
+ raise InvalidAccountNameException("Account name must be a string")
+
+ allowed_characters = "abcdefghijklmnopqrstuvwxyz0123456789"
+
+ if Setting().get('account_name_extra_chars'):
+ allowed_characters += "_-."
+
+ sanitized_name = ''.join(c for c in name.lower() if c in allowed_characters)
+
+ if len(sanitized_name) > Account.name.type.length:
+ current_app.logger.error("Account name {0} too long. Truncated to: {1}".format(
+ sanitized_name, sanitized_name[:Account.name.type.length]))
+
+ if not sanitized_name:
+ raise InvalidAccountNameException("Empty string is not a valid account name")
+
+ return sanitized_name[:Account.name.type.length]
def __repr__(self):
return ''.format(self.name)
@@ -72,11 +90,9 @@ class Account(db.Model):
"""
Create a new account
"""
- # Sanity check - account name
- if self.name == "":
- return {'status': False, 'msg': 'No account name specified'}
+ self.name = Account.sanitize_name(self.name)
- # check that account name is not already used
+ # Check that account name is not already used
account = Account.query.filter(Account.name == self.name).first()
if account:
return {'status': False, 'msg': 'Account already exists'}
diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py
index 1f00b31..672e84a 100644
--- a/powerdnsadmin/routes/api.py
+++ b/powerdnsadmin/routes/api.py
@@ -23,7 +23,7 @@ from ..lib.errors import (
AccountCreateFail, AccountUpdateFail, AccountDeleteFail,
AccountCreateDuplicate, AccountNotExists,
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
- UserUpdateFailEmail
+ UserUpdateFailEmail, InvalidAccountNameException
)
from ..decorators import (
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
@@ -870,12 +870,15 @@ def api_create_account():
contact = data['contact'] if 'contact' in data else None
mail = data['mail'] if 'mail' in data else None
if not name:
- current_app.logger.debug("Account name missing")
- abort(400)
+ current_app.logger.debug("Account creation failed: name missing")
+ raise InvalidAccountNameException(message="Account name missing")
+
+ sanitized_name = Account.sanitize_name(name)
+ account_exists = Account.query.filter(Account.name == sanitized_name).all()
- account_exists = [] or Account.query.filter(Account.name == name).all()
if len(account_exists) > 0:
- msg = "Account {} already exists".format(name)
+ msg = ("Requested Account {} would be translated to {}"
+ " which already exists").format(name, sanitized_name)
current_app.logger.debug(msg)
raise AccountCreateDuplicate(message=msg)
@@ -913,8 +916,9 @@ def api_update_account(account_id):
if not account:
abort(404)
- if name and name != account.name:
- abort(400)
+ if name and Account.sanitize_name(name) != account.name:
+ msg = "Account name is immutable"
+ raise AccountUpdateFail(message=msg)
if current_user.role.name not in ['Administrator', 'Operator']:
msg = "User role update accounts"
@@ -1212,13 +1216,13 @@ def sync_domains():
def health():
domain = Domain()
domain_to_query = domain.query.first()
-
+
if not domain_to_query:
current_app.logger.error("No domain found to query a health check")
return make_response("Unknown", 503)
try:
- domain.get_domain_info(domain_to_query.name)
+ domain.get_domain_info(domain_to_query.name)
except Exception as e:
current_app.logger.error("Health Check - Failed to query authoritative server for domain {}".format(domain_to_query.name))
return make_response("Down", 503)
diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py
index 6f5f0e2..93823ce 100644
--- a/powerdnsadmin/routes/index.py
+++ b/powerdnsadmin/routes/index.py
@@ -323,8 +323,8 @@ def login():
# Regexp didn't match, continue to next iteration
continue
- account = Account()
- account_id = account.get_id_by_name(account_name=group_name)
+ sanitized_group_name = Account.sanitize_name(group_name)
+ account_id = account.get_id_by_name(account_name=sanitized_group_name)
if account_id:
account = Account.query.get(account_id)
@@ -345,10 +345,12 @@ def login():
current_app.logger.info('User {} added to Account {}'.format(
user.username, account.name))
else:
- account.name = group_name
- account.description = group_description
- account.contact = ''
- account.mail = ''
+ account = Account(
+ name=sanitized_group_name,
+ description=group_description,
+ contact='',
+ mail=''
+ )
account.create_account()
history = History(msg='Create account {0}'.format(
account.name),
@@ -1092,18 +1094,10 @@ def create_group_to_account_mapping():
def handle_account(account_name, account_description=""):
- if Setting().get('account_name_extra_chars'):
- char_list = "abcdefghijklmnopqrstuvwxyz0123456789_-."
- else:
- char_list = "abcdefghijklmnopqrstuvwxyz0123456789"
- clean_name = ''.join(c for c in account_name.lower()
- if c in char_list)
- if len(clean_name) > Account.name.type.length:
- current_app.logger.error(
- "Account name {0} too long. Truncated.".format(clean_name))
+ clean_name = Account.sanitize_name(account_name)
account = Account.query.filter_by(name=clean_name).first()
if not account:
- account = Account(name=clean_name.lower(),
+ account = Account(name=clean_name,
description=account_description,
contact='',
mail='')
From 3e462dab1749887a24bfdc39fabdecea5bf7d9cb Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 12:53:19 +0200
Subject: [PATCH 071/203] Fix csrf configuration
CSRF has been initialized *before* the app config was fully read. That
made it impossible to configure CSRF properly. Moved the CSRF init into
the routes module, and switched from programmatic to decorated
exemptions. GET routes don't need to be exempted because they are by
default.
---
powerdnsadmin/__init__.py | 28 +---------------------------
powerdnsadmin/routes/__init__.py | 3 ++-
powerdnsadmin/routes/api.py | 20 ++++++++++++++++++++
powerdnsadmin/routes/base.py | 4 ++++
powerdnsadmin/routes/index.py | 5 ++++-
5 files changed, 31 insertions(+), 29 deletions(-)
diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py
index c70b273..02479a5 100755
--- a/powerdnsadmin/__init__.py
+++ b/powerdnsadmin/__init__.py
@@ -1,7 +1,6 @@
import os
import logging
from flask import Flask
-from flask_seasurf import SeaSurf
from flask_mail import Mail
from werkzeug.middleware.proxy_fix import ProxyFix
from flask_session import Session
@@ -33,31 +32,6 @@ def create_app(config=None):
# Proxy
app.wsgi_app = ProxyFix(app.wsgi_app)
- # CSRF protection
- csrf = SeaSurf(app)
- csrf.exempt(routes.index.dyndns_checkip)
- csrf.exempt(routes.index.dyndns_update)
- csrf.exempt(routes.index.saml_authorized)
- csrf.exempt(routes.api.api_login_create_zone)
- csrf.exempt(routes.api.api_login_delete_zone)
- csrf.exempt(routes.api.api_generate_apikey)
- csrf.exempt(routes.api.api_delete_apikey)
- csrf.exempt(routes.api.api_update_apikey)
- csrf.exempt(routes.api.api_zone_subpath_forward)
- csrf.exempt(routes.api.api_zone_forward)
- csrf.exempt(routes.api.api_create_zone)
- csrf.exempt(routes.api.api_create_account)
- csrf.exempt(routes.api.api_delete_account)
- csrf.exempt(routes.api.api_update_account)
- csrf.exempt(routes.api.api_create_user)
- csrf.exempt(routes.api.api_delete_user)
- csrf.exempt(routes.api.api_update_user)
- csrf.exempt(routes.api.api_list_account_users)
- csrf.exempt(routes.api.api_add_account_user)
- csrf.exempt(routes.api.api_remove_account_user)
- csrf.exempt(routes.api.api_zone_cryptokeys)
- csrf.exempt(routes.api.api_zone_cryptokey)
-
# Load config from env variables if using docker
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):
app.config.from_object('powerdnsadmin.docker_config')
@@ -69,7 +43,7 @@ def create_app(config=None):
if 'FLASK_CONF' in os.environ:
app.config.from_envvar('FLASK_CONF')
- # Load app sepecified configuration
+ # Load app specified configuration
if config is not None:
if isinstance(config, dict):
app.config.update(config)
diff --git a/powerdnsadmin/routes/__init__.py b/powerdnsadmin/routes/__init__.py
index b22324b..7d8aa9a 100644
--- a/powerdnsadmin/routes/__init__.py
+++ b/powerdnsadmin/routes/__init__.py
@@ -1,5 +1,5 @@
from .base import (
- login_manager, handle_bad_request, handle_unauthorized_access,
+ csrf, login_manager, handle_bad_request, handle_unauthorized_access,
handle_access_forbidden, handle_page_not_found, handle_internal_server_error
)
@@ -13,6 +13,7 @@ from .api import api_bp, apilist_bp
def init_app(app):
login_manager.init_app(app)
+ csrf.init_app(app)
app.register_blueprint(index_bp)
app.register_blueprint(user_bp)
diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py
index 672e84a..3df31e1 100644
--- a/powerdnsadmin/routes/api.py
+++ b/powerdnsadmin/routes/api.py
@@ -6,6 +6,7 @@ from flask import (
)
from flask_login import current_user
+from .base import csrf
from ..models.base import db
from ..models import (
User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey,
@@ -187,6 +188,7 @@ def index():
@api_bp.route('/pdnsadmin/zones', methods=['POST'])
@api_basic_auth
@api_can_create_domain
+@csrf.exempt
def api_login_create_zone():
pdns_api_url = Setting().get('pdns_api_url')
pdns_api_key = Setting().get('pdns_api_key')
@@ -255,6 +257,7 @@ def api_login_list_zones():
@api_bp.route('/pdnsadmin/zones/', methods=['DELETE'])
@api_basic_auth
@api_can_create_domain
+@csrf.exempt
def api_login_delete_zone(domain_name):
pdns_api_url = Setting().get('pdns_api_url')
pdns_api_key = Setting().get('pdns_api_key')
@@ -310,6 +313,7 @@ def api_login_delete_zone(domain_name):
@api_bp.route('/pdnsadmin/apikeys', methods=['POST'])
@api_basic_auth
+@csrf.exempt
def api_generate_apikey():
data = request.get_json()
description = None
@@ -466,6 +470,7 @@ def api_get_apikey(apikey_id):
@api_bp.route('/pdnsadmin/apikeys/', methods=['DELETE'])
@api_basic_auth
+@csrf.exempt
def api_delete_apikey(apikey_id):
apikey = ApiKey.query.get(apikey_id)
@@ -503,6 +508,7 @@ def api_delete_apikey(apikey_id):
@api_bp.route('/pdnsadmin/apikeys/', methods=['PUT'])
@api_basic_auth
+@csrf.exempt
def api_update_apikey(apikey_id):
# if role different and user is allowed to change it, update
# if apikey domains are different and user is allowed to handle
@@ -664,6 +670,7 @@ def api_list_users(username=None):
@api_bp.route('/pdnsadmin/users', methods=['POST'])
@api_basic_auth
@api_role_can('create users', allow_self=True)
+@csrf.exempt
def api_create_user():
"""
Create new user
@@ -737,6 +744,7 @@ def api_create_user():
@api_bp.route('/pdnsadmin/users/', methods=['PUT'])
@api_basic_auth
@api_role_can('update users', allow_self=True)
+@csrf.exempt
def api_update_user(user_id):
"""
Update existing user
@@ -809,6 +817,7 @@ def api_update_user(user_id):
@api_bp.route('/pdnsadmin/users/', methods=['DELETE'])
@api_basic_auth
@api_role_can('delete users')
+@csrf.exempt
def api_delete_user(user_id):
user = User.query.get(user_id)
if not user:
@@ -860,6 +869,7 @@ def api_list_accounts(account_name):
@api_bp.route('/pdnsadmin/accounts', methods=['POST'])
@api_basic_auth
+@csrf.exempt
def api_create_account():
if current_user.role.name not in ['Administrator', 'Operator']:
msg = "{} role cannot create accounts".format(current_user.role.name)
@@ -904,6 +914,7 @@ def api_create_account():
@api_bp.route('/pdnsadmin/accounts/', methods=['PUT'])
@api_basic_auth
@api_role_can('update accounts')
+@csrf.exempt
def api_update_account(account_id):
data = request.get_json()
name = data['name'] if 'name' in data else None
@@ -945,6 +956,7 @@ def api_update_account(account_id):
@api_bp.route('/pdnsadmin/accounts/', methods=['DELETE'])
@api_basic_auth
@api_role_can('delete accounts')
+@csrf.exempt
def api_delete_account(account_id):
account_list = [] or Account.query.filter(Account.id == account_id).all()
if len(account_list) == 1:
@@ -996,6 +1008,7 @@ def api_list_account_users(account_id):
methods=['PUT'])
@api_basic_auth
@api_role_can('add user to account')
+@csrf.exempt
def api_add_account_user(account_id, user_id):
account = Account.query.get(account_id)
if not account:
@@ -1023,6 +1036,7 @@ def api_add_account_user(account_id, user_id):
methods=['DELETE'])
@api_basic_auth
@api_role_can('remove user from account')
+@csrf.exempt
def api_remove_account_user(account_id, user_id):
account = Account.query.get(account_id)
if not account:
@@ -1054,6 +1068,7 @@ def api_remove_account_user(account_id, user_id):
@apikey_auth
@apikey_can_access_domain
@apikey_can_configure_dnssec(http_methods=['POST'])
+@csrf.exempt
def api_zone_cryptokeys(server_id, zone_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@@ -1065,6 +1080,7 @@ def api_zone_cryptokeys(server_id, zone_id):
@apikey_auth
@apikey_can_access_domain
@apikey_can_configure_dnssec()
+@csrf.exempt
def api_zone_cryptokey(server_id, zone_id, cryptokey_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@@ -1075,6 +1091,7 @@ def api_zone_cryptokey(server_id, zone_id, cryptokey_id):
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
@apikey_auth
@apikey_can_access_domain
+@csrf.exempt
def api_zone_subpath_forward(server_id, zone_id, subpath):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@@ -1090,6 +1107,7 @@ def api_zone_subpath_forward(server_id, zone_id, subpath):
@callback_if_request_body_contains_key(apikey_can_configure_dnssec()(),
http_methods=['PUT'],
keys=['dnssec', 'nsec3param'])
+@csrf.exempt
def api_zone_forward(server_id, zone_id):
resp = helper.forward_request()
if not Setting().get('bg_domain_updates'):
@@ -1127,6 +1145,7 @@ def api_zone_forward(server_id, zone_id):
@api_bp.route('/servers/', methods=['GET', 'PUT'])
@apikey_auth
@apikey_is_admin
+@csrf.exempt
def api_server_sub_forward(subpath):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@@ -1135,6 +1154,7 @@ def api_server_sub_forward(subpath):
@api_bp.route('/servers//zones', methods=['POST'])
@apikey_auth
@apikey_can_create_domain
+@csrf.exempt
def api_create_zone(server_id):
resp = helper.forward_request()
diff --git a/powerdnsadmin/routes/base.py b/powerdnsadmin/routes/base.py
index 48ef1c0..16ed00a 100644
--- a/powerdnsadmin/routes/base.py
+++ b/powerdnsadmin/routes/base.py
@@ -1,9 +1,13 @@
import base64
+
from flask import render_template, url_for, redirect, session, request, current_app
from flask_login import LoginManager
+from flask_seasurf import SeaSurf
from ..models.user import User
+
+csrf = SeaSurf()
login_manager = LoginManager()
diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py
index 93823ce..3a6f55c 100644
--- a/powerdnsadmin/routes/index.py
+++ b/powerdnsadmin/routes/index.py
@@ -10,7 +10,7 @@ from yaml import Loader, load
from flask import Blueprint, render_template, make_response, url_for, current_app, g, session, request, redirect, abort
from flask_login import login_user, logout_user, login_required, current_user
-from .base import login_manager
+from .base import csrf, login_manager
from ..lib import utils
from ..decorators import dyndns_login_required
from ..models.base import db
@@ -763,6 +763,7 @@ def resend_confirmation_email():
@index_bp.route('/nic/checkip.html', methods=['GET', 'POST'])
+@csrf.exempt
def dyndns_checkip():
# This route covers the default ddclient 'web' setting for the checkip service
return render_template('dyndns.html',
@@ -771,6 +772,7 @@ def dyndns_checkip():
@index_bp.route('/nic/update', methods=['GET', 'POST'])
+@csrf.exempt
@dyndns_login_required
def dyndns_update():
# dyndns protocol response codes in use are:
@@ -961,6 +963,7 @@ def saml_metadata():
@index_bp.route('/saml/authorized', methods=['GET', 'POST'])
+@csrf.exempt
def saml_authorized():
errors = []
if not current_app.config.get('SAML_ENABLED'):
From ae2ad6527a81adef5c4bf858fdd4f3865fb351be Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 12:53:28 +0200
Subject: [PATCH 072/203] Set csrf cookie to httponly
The CSRF token is currently inserted directly in the template and not
in the browser via JavaScript from the cookie, so making it inaccessible
is not a problem.
The Sesson-cookie is already httponly by default [0].
[0] https://flask.palletsprojects.com/en/2.1.x/config/?highlight=session_cookie_httponly#SESSION_COOKIE_HTTPONLY
---
configs/docker_config.py | 1 +
powerdnsadmin/default_config.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/configs/docker_config.py b/configs/docker_config.py
index 6666fc2..ba0a233 100644
--- a/configs/docker_config.py
+++ b/configs/docker_config.py
@@ -2,6 +2,7 @@
BIND_ADDRESS = '0.0.0.0'
PORT = 80
SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
+CSRF_COOKIE_HTTPONLY = True
legal_envvars = (
'SECRET_KEY',
diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py
index 16b8161..8737680 100644
--- a/powerdnsadmin/default_config.py
+++ b/powerdnsadmin/default_config.py
@@ -10,6 +10,7 @@ PORT = 9191
HSTS_ENABLED = False
OFFLINE_MODE = False
FILESYSTEM_SESSIONS_ENABLED = False
+CSRF_COOKIE_HTTPONLY = True
### DATABASE CONFIG
SQLA_DB_USER = 'pda'
From 1a7752444735a33e471981e5fb85a789a1cd8777 Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 12:53:32 +0200
Subject: [PATCH 073/203] Allow secure cookies in docker
Setting these two options to True is recommended if (and only if) you
serve PDA via TLS. It will break things on plain-HTTP deployments.
For plain deployments these can be set in the flask config file, for
docker they have to be whitelisted to be set via env vars.
---
configs/docker_config.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/configs/docker_config.py b/configs/docker_config.py
index ba0a233..2cc6310 100644
--- a/configs/docker_config.py
+++ b/configs/docker_config.py
@@ -57,7 +57,9 @@ legal_envvars = (
'LDAP_ENABLED',
'SAML_CERT',
'SAML_KEY',
- 'FILESYSTEM_SESSIONS_ENABLED'
+ 'FILESYSTEM_SESSIONS_ENABLED',
+ 'SESSION_COOKIE_SECURE',
+ 'CSRF_COOKIE_SECURE',
)
legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
@@ -79,7 +81,9 @@ legal_envvars_bool = (
'SIGNUP_ENABLED',
'LOCAL_DB_ENABLED',
'LDAP_ENABLED',
- 'FILESYSTEM_SESSIONS_ENABLED'
+ 'FILESYSTEM_SESSIONS_ENABLED',
+ 'SESSION_COOKIE_SECURE',
+ 'CSRF_COOKIE_SECURE',
)
# import everything from environment variables
From 52b704baeb00b43e393bb3157f8eb24a4a7955cb Mon Sep 17 00:00:00 2001
From: corubba
Date: Tue, 31 May 2022 00:35:04 +0200
Subject: [PATCH 074/203] Set SameSite on cookies
Setting this attribute on a cookie marks it as non-cross-site, so it
is only send in requests to our own server. It is reasonable that no
one else should need our session or csrf data. Setting it explicitly
also prevents any issues from the ongoing change in browser behaviour [0]
when it is unset.
Seasurf supports the SameSite attribute starting with v0.3. As nothing
obviously broke, I used the opportunity and updated all the way to the
most recent version.
The SeaSurf default for SameSite is already `Lax`, so it only needs to
be set for the session cookie.
[0] https://developers.google.com/search/blog/2020/01/get-ready-for-new-samesitenone-secure
---
configs/docker_config.py | 1 +
powerdnsadmin/default_config.py | 1 +
requirements.txt | 2 +-
3 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/configs/docker_config.py b/configs/docker_config.py
index 2cc6310..7285252 100644
--- a/configs/docker_config.py
+++ b/configs/docker_config.py
@@ -2,6 +2,7 @@
BIND_ADDRESS = '0.0.0.0'
PORT = 80
SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
+SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
legal_envvars = (
diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py
index 8737680..93b97b7 100644
--- a/powerdnsadmin/default_config.py
+++ b/powerdnsadmin/default_config.py
@@ -10,6 +10,7 @@ PORT = 9191
HSTS_ENABLED = False
OFFLINE_MODE = False
FILESYSTEM_SESSIONS_ENABLED = False
+SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
### DATABASE CONFIG
diff --git a/requirements.txt b/requirements.txt
index ce24450..ec2ecbb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -18,7 +18,7 @@ pytz==2020.1
cssmin==0.2.0
jsmin==3.0.0
Authlib==0.15
-Flask-SeaSurf==0.2.2
+Flask-SeaSurf==1.1.1
bravado-core==5.17.0
lima==0.5
pytest==6.1.1
From af902f24a2b58ba9ad408217ef3f17bbdef9433b Mon Sep 17 00:00:00 2001
From: corubba
Date: Thu, 19 May 2022 00:56:13 +0200
Subject: [PATCH 075/203] Update using only one api call
Starting with the very first commit, the update was always done with
two api calls: one for DELETE and one for REPLACE. It is however
perfectly valid and save to do both at once, which makes it atomic, so
no need for the rollback. Plus it only updates the serial once.
There is no point in sending the full RRset data when deleting it, the
key attributes to identify it are enough. This also make the behaviour
consistent with the api docs [0] where it says "MUST NOT be included
when changetype is set to DELETE."
[0] https://doc.powerdns.com/authoritative/http-api/zone.html#rrset
---
powerdnsadmin/models/record.py | 97 ++++++++++++++++------------------
1 file changed, 45 insertions(+), 52 deletions(-)
diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py
index 112b9e3..b5e3050 100644
--- a/powerdnsadmin/models/record.py
+++ b/powerdnsadmin/models/record.py
@@ -303,10 +303,47 @@ class Record(object):
data=rrsets)
return jdata
+ @staticmethod
+ def to_api_payload(new_rrsets, del_rrsets):
+ """Turn the given changes into a single api payload."""
+
+ def replace_for_api(rrset):
+ """Return a modified copy of the given RRset with changetype REPLACE."""
+ 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)
+ return replace_copy
+
+ def rrset_in(needle, haystack):
+ """Return whether the given RRset (identified by name and type) is in the list."""
+ for hay in haystack:
+ if needle['name'] == hay['name'] and needle['type'] == hay['type']:
+ return True
+ return False
+
+ def delete_for_api(rrset):
+ """Return a minified copy of the given RRset with changetype DELETE."""
+ if not rrset or rrset.get('changetype', None) != 'DELETE':
+ return rrset
+ delete_copy = dict(rrset)
+ delete_copy.pop('ttl', None)
+ delete_copy.pop('records', None)
+ delete_copy.pop('comments', None)
+ return delete_copy
+
+ replaces = [replace_for_api(r) for r in new_rrsets]
+ deletes = [delete_for_api(r) for r in del_rrsets if not rrset_in(r, replaces)]
+ return {
+ 'rrsets': replaces + deletes
+ }
+
def apply(self, domain_name, submitted_records):
"""
Apply record changes to a domain. This function
- will make 2 calls to the PDNS API to DELETE and
+ will make 1 call to the PDNS API to DELETE and
REPLACE records (rrsets)
"""
current_app.logger.debug(
@@ -315,68 +352,24 @@ class Record(object):
# Get the list of rrsets to be added and deleted
new_rrsets, del_rrsets = self.compare(domain_name, submitted_records)
- # Remove blank comments from rrsets for compatibility with some backends
- def remove_blank_comments(rrset):
- if not rrset['comments']:
- del rrset['comments']
- elif isinstance(rrset['comments'], list):
- # Merge all non-blank comment values into a list
- merged_comments = [
- v
- for c in rrset['comments']
- for v in c.values()
- if v
- ]
- # Delete comment if all values are blank (len(merged_comments) == 0)
- if not merged_comments:
- del rrset['comments']
-
- for r in new_rrsets['rrsets']:
- remove_blank_comments(r)
-
- for r in del_rrsets['rrsets']:
- remove_blank_comments(r)
+ # 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'])
+ current_app.logger.debug(f"api payload: \n{utils.pretty_json(api_payload)}")
# Submit the changes to PDNS API
try:
- if del_rrsets["rrsets"]:
- result = self.apply_rrsets(domain_name, del_rrsets)
+ if api_payload["rrsets"]:
+ result = self.apply_rrsets(domain_name, api_payload)
if 'error' in result.keys():
current_app.logger.error(
- 'Cannot apply record changes with deleting rrsets step. PDNS error: {}'
+ 'Cannot apply record changes. PDNS error: {}'
.format(result['error']))
return {
'status': 'error',
'msg': result['error'].replace("'", "")
}
- if new_rrsets["rrsets"]:
- result = self.apply_rrsets(domain_name, new_rrsets)
- if 'error' in result.keys():
- current_app.logger.error(
- 'Cannot apply record changes with adding rrsets step. PDNS error: {}'
- .format(result['error']))
-
- # rollback - re-add the removed record if the adding operation is failed.
- if del_rrsets["rrsets"]:
- rollback_rrsets = del_rrsets
- for r in del_rrsets["rrsets"]:
- r['changetype'] = 'REPLACE'
- rollback = self.apply_rrsets(domain_name, rollback_rrsets)
- if 'error' in rollback.keys():
- return dict(status='error',
- msg='Failed to apply changes. Cannot rollback previous failed operation: {}'
- .format(rollback['error'].replace("'", "")))
- else:
- return dict(status='error',
- msg='Failed to apply changes. Rolled back previous failed operation: {}'
- .format(result['error'].replace("'", "")))
- else:
- return {
- 'status': 'error',
- 'msg': result['error'].replace("'", "")
- }
-
self.auto_ptr(domain_name, new_rrsets, del_rrsets)
self.update_db_serial(domain_name)
current_app.logger.info('Record was applied successfully.')
From 674704609b1c5d8c94a4db5a799b47fffaa82920 Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 13:01:13 +0200
Subject: [PATCH 076/203] Always use local fonts
---
powerdnsadmin/templates/base.html | 5 -----
1 file changed, 5 deletions(-)
diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html
index 649663a..cafe8eb 100644
--- a/powerdnsadmin/templates/base.html
+++ b/powerdnsadmin/templates/base.html
@@ -8,13 +8,8 @@
{% block title %}{{ SITE_NAME }}{% endblock %}
- {% if OFFLINE_MODE %}
- {% else %}
-
-
- {% endif %}
From 54b2c5918f1bf87099f95c7b9e71caa9710c82c6 Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 13:01:32 +0200
Subject: [PATCH 077/203] Serve the IE8 polyfills from local
---
package.json | 4 +++-
powerdnsadmin/assets.py | 6 ++++++
powerdnsadmin/templates/base.html | 5 +++--
powerdnsadmin/templates/login.html | 5 +++--
powerdnsadmin/templates/register.html | 5 +++--
5 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/package.json b/package.json
index 76982c8..c60b05d 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,8 @@
"jquery-ui-dist": "^1.12.1",
"jquery.quicksearch": "^2.4.0",
"jtimeout": "^3.1.0",
- "multiselect": "^0.9.12"
+ "multiselect": "^0.9.12",
+ "html5shiv": "^3.7.3",
+ "respond.js": "^1.4.2"
}
}
diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py
index e7c6354..136a4f2 100644
--- a/powerdnsadmin/assets.py
+++ b/powerdnsadmin/assets.py
@@ -64,9 +64,15 @@ js_main = Bundle('node_modules/jquery/dist/jquery.js',
filters=(ConcatFilter, 'jsmin'),
output='generated/main.js')
+js_ie8 = Bundle('node_modules/html5shiv/dist/html5shiv.js',
+ 'node_modules/respond.js/dest/respond.min.js',
+ filters=(ConcatFilter, 'jsmin'),
+ output='generated/ie8.js')
+
assets = Environment()
assets.register('js_login', js_login)
assets.register('js_validation', js_validation)
assets.register('css_login', css_login)
assets.register('js_main', js_main)
assets.register('css_main', css_main)
+assets.register('js_ie8', js_ie8)
diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html
index cafe8eb..bf17995 100644
--- a/powerdnsadmin/templates/base.html
+++ b/powerdnsadmin/templates/base.html
@@ -20,12 +20,13 @@
{% if SETTING.get('custom_css') %}
{% endif %}
+ {% assets "js_ie8" -%}
+ {%- endassets %}
{% endblock %}
diff --git a/powerdnsadmin/templates/login.html b/powerdnsadmin/templates/login.html
index 45afd7f..65d71f1 100644
--- a/powerdnsadmin/templates/login.html
+++ b/powerdnsadmin/templates/login.html
@@ -15,12 +15,13 @@
{% if SETTING.get('custom_css') %}
{% endif %}
+ {% assets "js_ie8" -%}
+ {%- endassets %}
diff --git a/powerdnsadmin/templates/register.html b/powerdnsadmin/templates/register.html
index 04cb1a8..ebbff7f 100644
--- a/powerdnsadmin/templates/register.html
+++ b/powerdnsadmin/templates/register.html
@@ -12,12 +12,13 @@
{%- endassets %}
+ {% assets "js_ie8" -%}
+ {%- endassets %}
From fee26b84ba779c22e97e0a5999e7beb758e38c9b Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 13:01:36 +0200
Subject: [PATCH 078/203] Remove IE8 polyfills
These old browsers are EOL since 2016 [0], let them finally rest in
peace.
This effectively reverts/replaces commit b8dee5d17056788c2dc9940d14308648e32186d8.
[0] https://web.archive.org/web/20160115070611/https://www.microsoft.com/en-us/WindowsForBusiness/End-of-IE-support
---
package.json | 4 +---
powerdnsadmin/assets.py | 6 ------
powerdnsadmin/templates/base.html | 7 -------
powerdnsadmin/templates/login.html | 7 -------
powerdnsadmin/templates/register.html | 8 --------
5 files changed, 1 insertion(+), 31 deletions(-)
diff --git a/package.json b/package.json
index c60b05d..76982c8 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,6 @@
"jquery-ui-dist": "^1.12.1",
"jquery.quicksearch": "^2.4.0",
"jtimeout": "^3.1.0",
- "multiselect": "^0.9.12",
- "html5shiv": "^3.7.3",
- "respond.js": "^1.4.2"
+ "multiselect": "^0.9.12"
}
}
diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py
index 136a4f2..e7c6354 100644
--- a/powerdnsadmin/assets.py
+++ b/powerdnsadmin/assets.py
@@ -64,15 +64,9 @@ js_main = Bundle('node_modules/jquery/dist/jquery.js',
filters=(ConcatFilter, 'jsmin'),
output='generated/main.js')
-js_ie8 = Bundle('node_modules/html5shiv/dist/html5shiv.js',
- 'node_modules/respond.js/dest/respond.min.js',
- filters=(ConcatFilter, 'jsmin'),
- output='generated/ie8.js')
-
assets = Environment()
assets.register('js_login', js_login)
assets.register('js_validation', js_validation)
assets.register('css_login', css_login)
assets.register('js_main', js_main)
assets.register('css_main', css_main)
-assets.register('js_ie8', js_ie8)
diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html
index bf17995..92e3a1c 100644
--- a/powerdnsadmin/templates/base.html
+++ b/powerdnsadmin/templates/base.html
@@ -20,13 +20,6 @@
{% if SETTING.get('custom_css') %}
{% endif %}
- {% assets "js_ie8" -%}
-
-
-
- {%- endassets %}
{% endblock %}
diff --git a/powerdnsadmin/templates/login.html b/powerdnsadmin/templates/login.html
index 65d71f1..00f9183 100644
--- a/powerdnsadmin/templates/login.html
+++ b/powerdnsadmin/templates/login.html
@@ -15,13 +15,6 @@
{% if SETTING.get('custom_css') %}
{% endif %}
- {% assets "js_ie8" -%}
-
-
-
- {%- endassets %}
diff --git a/powerdnsadmin/templates/register.html b/powerdnsadmin/templates/register.html
index ebbff7f..9a25fbb 100644
--- a/powerdnsadmin/templates/register.html
+++ b/powerdnsadmin/templates/register.html
@@ -11,14 +11,6 @@
{% assets "css_login" -%}
{%- endassets %}
-
- {% assets "js_ie8" -%}
-
-
-
- {%- endassets %}
From b795f1eadf17f9e9fa279430442ff386de59da62 Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 13:01:40 +0200
Subject: [PATCH 079/203] Use the doc search directly
---
powerdnsadmin/templates/admin_pdns_stats.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/powerdnsadmin/templates/admin_pdns_stats.html b/powerdnsadmin/templates/admin_pdns_stats.html
index 7f22c4f..cd5a000 100644
--- a/powerdnsadmin/templates/admin_pdns_stats.html
+++ b/powerdnsadmin/templates/admin_pdns_stats.html
@@ -35,7 +35,7 @@
{% for statistic in statistics %}
From b809308d31229649a26d1bfbc7e7b8a759b7d32b Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 13:01:51 +0200
Subject: [PATCH 081/203] Add LDAP user images
---
powerdnsadmin/routes/user.py | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/powerdnsadmin/routes/user.py b/powerdnsadmin/routes/user.py
index 709156f..65d7e08 100644
--- a/powerdnsadmin/routes/user.py
+++ b/powerdnsadmin/routes/user.py
@@ -1,5 +1,7 @@
import datetime
import hashlib
+import imghdr
+import mimetypes
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, \
current_app, after_this_request, abort
@@ -115,12 +117,33 @@ def image():
response_.cache_control.max_age = int(datetime.timedelta(days=1).total_seconds())
return response_
+ def return_image(content, content_type=None):
+ """Return the given binary image content. Guess the type if not given."""
+ if not content_type:
+ guess = mimetypes.guess_type('example.' + imghdr.what(None, h=content))
+ if guess and guess[0]:
+ content_type = guess[0]
+
+ return content, 200, {'Content-Type': content_type}
+
# To prevent "cache poisoning", the username query parameter is required
if request.args.get('username', None) != current_user.username:
abort(400)
setting = Setting()
+ if session['authentication_type'] == 'LDAP':
+ search_filter = '(&({0}={1}){2})'.format(setting.get('ldap_filter_username'),
+ current_user.username,
+ setting.get('ldap_filter_basic'))
+ result = User().ldap_search(search_filter, setting.get('ldap_base_dn'))
+ if result and result[0] and result[0][0] and result[0][0][1]:
+ user_obj = result[0][0][1]
+ for key in ['jpegPhoto', 'thumbnailPhoto']:
+ if key in user_obj and user_obj[key] and user_obj[key][0]:
+ current_app.logger.debug(f'Return {key} from ldap as user image')
+ return return_image(user_obj[key][0])
+
email = current_user.email
if email and setting.get('gravatar_enabled'):
hash_ = hashlib.md5(email.encode('utf-8')).hexdigest()
From 3a8ad7c4440fb84a9a74a013573215a530caf50c Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 27 May 2022 13:02:00 +0200
Subject: [PATCH 082/203] Remove OFFLINE_MODE config option
---
configs/development.py | 1 -
configs/docker_config.py | 2 --
docker-compose.yml | 1 -
powerdnsadmin/__init__.py | 5 -----
powerdnsadmin/default_config.py | 1 -
5 files changed, 10 deletions(-)
diff --git a/configs/development.py b/configs/development.py
index cdced36..d4bd24f 100644
--- a/configs/development.py
+++ b/configs/development.py
@@ -7,7 +7,6 @@ SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
BIND_ADDRESS = '0.0.0.0'
PORT = 9191
-OFFLINE_MODE = False
### DATABASE CONFIG
SQLA_DB_USER = 'pda'
diff --git a/configs/docker_config.py b/configs/docker_config.py
index 6666fc2..7019a34 100644
--- a/configs/docker_config.py
+++ b/configs/docker_config.py
@@ -48,7 +48,6 @@ legal_envvars = (
'SAML_LOGOUT',
'SAML_LOGOUT_URL',
'SAML_ASSERTION_ENCRYPTED',
- 'OFFLINE_MODE',
'REMOTE_USER_LOGOUT_URL',
'REMOTE_USER_COOKIES',
'SIGNUP_ENABLED',
@@ -73,7 +72,6 @@ legal_envvars_bool = (
'SAML_WANT_MESSAGE_SIGNED',
'SAML_LOGOUT',
'SAML_ASSERTION_ENCRYPTED',
- 'OFFLINE_MODE',
'REMOTE_USER_ENABLED',
'SIGNUP_ENABLED',
'LOCAL_DB_ENABLED',
diff --git a/docker-compose.yml b/docker-compose.yml
index e18d683..1237827 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,4 +15,3 @@ services:
- GUNICORN_TIMEOUT=60
- GUNICORN_WORKERS=2
- GUNICORN_LOGLEVEL=DEBUG
- - OFFLINE_MODE=False # True for offline, False for external resources
diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py
index 3f68a58..467b3cd 100755
--- a/powerdnsadmin/__init__.py
+++ b/powerdnsadmin/__init__.py
@@ -117,9 +117,4 @@ def create_app(config=None):
setting = Setting()
return dict(SETTING=setting)
- @app.context_processor
- def inject_mode():
- setting = app.config.get('OFFLINE_MODE', False)
- return dict(OFFLINE_MODE=setting)
-
return app
diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py
index 16b8161..d2aad5f 100644
--- a/powerdnsadmin/default_config.py
+++ b/powerdnsadmin/default_config.py
@@ -8,7 +8,6 @@ SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
BIND_ADDRESS = '0.0.0.0'
PORT = 9191
HSTS_ENABLED = False
-OFFLINE_MODE = False
FILESYSTEM_SESSIONS_ENABLED = False
### DATABASE CONFIG
From 9890ddfa6414af78985b26b61ffbe2726cf54e59 Mon Sep 17 00:00:00 2001
From: corubba
Date: Sun, 19 Jun 2022 12:16:40 +0200
Subject: [PATCH 083/203] Fix rrset changelog for names with hyphen
When clicking the changelog button for a record with the name
`foo-bar.example.org`, the url you get redirected to is
`/domain/example.org/changelog/foo-bar.example.org.-A`. Because of the
non-greedy behaviour of the path converter, the last part gets split at
the *first* hyphen, so the example above gets wrongly dissected into
`record_name=foo` and `record_type=bar.example.org.-A`. This results
for obvious reasons in an empty changelog.
As described in rfc5395 [0], types have to be alphanumerical, so its
converter is changed from path to string.
The hyphen is one of the few characters recommended by rfc1035 [1],
so it is a bad choice as separator. The separator is instead changed to
a slash.
Granted, this does not entirely solve the issue but at least makes it a
lot less likely to happen. Plus, a lot more and other things break in
pda with slashes in names.
[0] https://datatracker.ietf.org/doc/html/rfc5395#section-3.1
[1] https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.1
---
powerdnsadmin/routes/domain.py | 2 +-
powerdnsadmin/templates/domain.html | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py
index 3261264..556bc19 100644
--- a/powerdnsadmin/routes/domain.py
+++ b/powerdnsadmin/routes/domain.py
@@ -277,7 +277,7 @@ def changelog(domain_name):
"""
Returns a changelog for a specific pair of (record_name, record_type)
"""
-@domain_bp.route('//changelog/-', methods=['GET'])
+@domain_bp.route('//changelog//', methods=['GET'])
@login_required
@can_access_domain
@history_access_required
diff --git a/powerdnsadmin/templates/domain.html b/powerdnsadmin/templates/domain.html
index d821e6a..f616967 100755
--- a/powerdnsadmin/templates/domain.html
+++ b/powerdnsadmin/templates/domain.html
@@ -190,7 +190,7 @@
function show_record_changelog(record_name, record_type, e) {
e.stopPropagation();
- window.location.href = "/domain/{{domain.name}}/changelog/" + record_name + ".-" + record_type;
+ window.location.href = "/domain/{{domain.name}}/changelog/" + record_name + "./" + record_type;
}
// handle changelog button
$(document.body).on("click", ".button_changelog", function(e) {
From 5036619a6765a4487c5bf674fc610c507543d79e Mon Sep 17 00:00:00 2001
From: corubba
Date: Thu, 23 Jun 2022 22:31:00 +0200
Subject: [PATCH 084/203] Allow new domains to be absolute
Allow the new domain name to be input absolute (with a dot at the end).
To keep the rest of the logic working as-is, remove it fairly early in
the function.
Would have loved to use `str.removesuffix()` but that's python v3.9+.
---
powerdnsadmin/routes/domain.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py
index 3261264..105a18d 100644
--- a/powerdnsadmin/routes/domain.py
+++ b/powerdnsadmin/routes/domain.py
@@ -363,6 +363,9 @@ def add():
'errors/400.html',
msg="Please enter a valid domain name"), 400
+ if domain_name.endswith('.'):
+ domain_name = domain_name[:-1]
+
# If User creates the domain, check some additional stuff
if current_user.role.name not in ['Administrator', 'Operator']:
# Get all the account_ids of the user
From a88f4a66c63378802ab5e4eb091d786418fb8715 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 10 Aug 2021 22:13:25 +0000
Subject: [PATCH 085/203] Bump path-parse from 1.0.5 to 1.0.7
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.5 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)
---
updated-dependencies:
- dependency-name: path-parse
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
yarn.lock | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index e3b95c5..f5e1d98 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -877,8 +877,9 @@ path-is-absolute@^1.0.0, path-is-absolute@^1.0.1:
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
path-parse@^1.0.5:
- version "1.0.5"
- resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1"
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+ integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-platform@~0.11.15:
version "0.11.15"
From 289faa501984d9fb30e0e862ddf1a77e36e2c8ed Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 26 Oct 2021 14:58:46 +0000
Subject: [PATCH 086/203] Bump jquery-ui from 1.12.1 to 1.13.0
Bumps [jquery-ui](https://github.com/jquery/jquery-ui) from 1.12.1 to 1.13.0.
- [Release notes](https://github.com/jquery/jquery-ui/releases)
- [Commits](https://github.com/jquery/jquery-ui/compare/1.12.1...1.13.0)
---
updated-dependencies:
- dependency-name: jquery-ui
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
yarn.lock | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index f5e1d98..2f21653 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -690,8 +690,11 @@ jquery-ui-dist@^1.12.1:
resolved "https://registry.yarnpkg.com/jquery-ui-dist/-/jquery-ui-dist-1.12.1.tgz#5c0815d3cc6f90ff5faaf5b268a6e23b4ca904fa"
jquery-ui@^1.12.1:
- version "1.12.1"
- resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.12.1.tgz#bcb4045c8dd0539c134bc1488cdd3e768a7a9e51"
+ version "1.13.0"
+ resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.13.0.tgz#ab5ac65f37ca093c51b3478c4097f55bbc008f36"
+ integrity sha512-Osf7ECXNTYHtKBkn9xzbIf9kifNrBhfywFEKxOeB/OVctVmLlouV9mfc2qXCp6uyO4Pn72PXKOnj09qXetopCw==
+ dependencies:
+ jquery ">=1.8.0 <4.0.0"
jquery.quicksearch@^2.4.0:
version "2.4.0"
@@ -700,9 +703,10 @@ jquery.quicksearch@^2.4.0:
dependencies:
jquery ">=1.8"
-"jquery@>= 1.7", "jquery@>= 1.7.1", jquery@>=1.10, jquery@>=1.5, jquery@>=1.7, "jquery@>=1.7.1 <4.0.0", jquery@>=1.8, jquery@^3.2.1:
- version "3.3.1"
- resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
+"jquery@>= 1.7", "jquery@>= 1.7.1", jquery@>=1.10, jquery@>=1.5, jquery@>=1.7, "jquery@>=1.7.1 <4.0.0", jquery@>=1.8, "jquery@>=1.8.0 <4.0.0", jquery@^3.2.1:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
+ integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
json-stable-stringify@~0.0.0:
version "0.0.1"
From 34be2273812e33bbad9d2d253893837fd1f7b9d4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 27 Jan 2022 14:45:07 +0000
Subject: [PATCH 087/203] Bump cached-path-relative from 1.0.2 to 1.1.0
Bumps [cached-path-relative](https://github.com/ashaffer/cached-path-relative) from 1.0.2 to 1.1.0.
- [Release notes](https://github.com/ashaffer/cached-path-relative/releases)
- [Commits](https://github.com/ashaffer/cached-path-relative/commits)
---
updated-dependencies:
- dependency-name: cached-path-relative
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
yarn.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 2f21653..8b07250 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -299,9 +299,9 @@ builtin-status-codes@^3.0.0:
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
cached-path-relative@^1.0.0:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db"
- integrity sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.1.0.tgz#865576dfef39c0d6a7defde794d078f5308e3ef3"
+ integrity sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==
charm@~0.1.0:
version "0.1.2"
From 18150eea34043c0e732f5f536caff7c18348de21 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sat, 9 Apr 2022 01:07:03 +0000
Subject: [PATCH 088/203] Bump moment from 2.22.2 to 2.29.2
Bumps [moment](https://github.com/moment/moment) from 2.22.2 to 2.29.2.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.22.2...2.29.2)
---
updated-dependencies:
- dependency-name: moment
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
yarn.lock | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 8b07250..3aa8404 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -817,14 +817,10 @@ module-deps@^6.0.0:
through2 "^2.0.0"
xtend "^4.0.0"
-moment@^2.24.0:
- version "2.24.0"
- resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
- integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
-
-moment@^2.9.0:
- version "2.22.2"
- resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
+moment@^2.24.0, moment@^2.9.0:
+ version "2.29.2"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4"
+ integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==
morris.js@^0.5.0:
version "0.5.0"
From 41642fcea40c38342c03bcc6a6244157345917a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20BECOT?=
Date: Sat, 28 May 2022 11:17:37 +0200
Subject: [PATCH 089/203] fix: Update JS minifier library
---
powerdnsadmin/assets.py | 4 ++--
requirements.txt | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py
index e7c6354..233e23a 100644
--- a/powerdnsadmin/assets.py
+++ b/powerdnsadmin/assets.py
@@ -24,7 +24,7 @@ js_login = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/bootstrap/dist/js/bootstrap.js',
'node_modules/icheck/icheck.js',
'custom/js/custom.js',
- filters=(ConcatFilter, 'jsmin'),
+ filters=(ConcatFilter, 'rjsmin'),
output='generated/login.js')
js_validation = Bundle('node_modules/bootstrap-validator/dist/validator.js',
@@ -61,7 +61,7 @@ js_main = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/jquery.quicksearch/src/jquery.quicksearch.js',
'custom/js/custom.js',
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
- filters=(ConcatFilter, 'jsmin'),
+ filters=(ConcatFilter, 'rjsmin'),
output='generated/main.js')
assets = Environment()
diff --git a/requirements.txt b/requirements.txt
index ec2ecbb..cef0a76 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,7 +16,7 @@ gunicorn==20.0.4
python3-saml
pytz==2020.1
cssmin==0.2.0
-jsmin==3.0.0
+rjsmin==1.2.0
Authlib==0.15
Flask-SeaSurf==1.1.1
bravado-core==5.17.0
@@ -30,4 +30,4 @@ flask-session==0.3.2
Jinja2==3.0.3
itsdangerous==2.0.1
werkzeug==2.0.3
-cryptography==36.0.2
\ No newline at end of file
+cryptography==36.0.2
From e7fbc7af376abe0e2015daf5c48ee7370dbad1e1 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 22 Jun 2022 05:38:32 +0000
Subject: [PATCH 090/203] Bump shell-quote from 1.6.1 to 1.7.3
Bumps [shell-quote](https://github.com/substack/node-shell-quote) from 1.6.1 to 1.7.3.
- [Release notes](https://github.com/substack/node-shell-quote/releases)
- [Changelog](https://github.com/substack/node-shell-quote/blob/master/CHANGELOG.md)
- [Commits](https://github.com/substack/node-shell-quote/compare/1.6.1...1.7.3)
---
updated-dependencies:
- dependency-name: shell-quote
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
yarn.lock | 22 +++-------------------
1 file changed, 3 insertions(+), 19 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 3aa8404..a288bdb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -62,18 +62,6 @@ almond@~0.3.1:
version "0.3.3"
resolved "https://registry.yarnpkg.com/almond/-/almond-0.3.3.tgz#a0e7c95ac7624d6417b4494b1e68bff693168a20"
-array-filter@~0.0.0:
- version "0.0.1"
- resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
-
-array-map@~0.0.0:
- version "0.0.0"
- resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
-
-array-reduce@~0.0.0:
- version "0.0.0"
- resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
-
asn1.js@^4.0.0:
version "4.10.1"
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0"
@@ -1009,13 +997,9 @@ shasum@^1.0.0:
sha.js "~2.4.4"
shell-quote@^1.6.1:
- version "1.6.1"
- resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
- dependencies:
- array-filter "~0.0.0"
- array-map "~0.0.0"
- array-reduce "~0.0.0"
- jsonify "~0.0.0"
+ version "1.7.3"
+ resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123"
+ integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==
slimscroll@^0.9.1:
version "0.9.1"
From 5f304ee29a37c78cae0990e91fe2e004ff490bf8 Mon Sep 17 00:00:00 2001
From: Phil Jaenke
Date: Mon, 22 Aug 2022 20:40:17 -0400
Subject: [PATCH 091/203] Update to python-ldap 3.4.2
Minor version bump. This is necessary to resolve build issues on Alpine 3.16+ without impacts for any other distributions.
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index cef0a76..1fc2864 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,7 +8,7 @@ mysqlclient==2.0.1
configobj==5.0.6
bcrypt>=3.1.7
requests==2.24.0
-python-ldap==3.4.0
+python-ldap==3.4.2
pyotp==2.4.0
qrcode==6.1
dnspython>=1.16.0
From 9bf74a6baf1cb6d375fd1cb08a68a472243e5892 Mon Sep 17 00:00:00 2001
From: Pascal de Bruijn
Date: Tue, 6 Sep 2022 15:25:28 +0200
Subject: [PATCH 092/203] admin_edit_key: default to User role for new api keys
hopefully this will prevent accidental administator api keys from being created
---
powerdnsadmin/templates/admin_edit_key.html | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/powerdnsadmin/templates/admin_edit_key.html b/powerdnsadmin/templates/admin_edit_key.html
index 7c8ca17..6a94340 100644
--- a/powerdnsadmin/templates/admin_edit_key.html
+++ b/powerdnsadmin/templates/admin_edit_key.html
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "admin_keys" %}
-{% if create or (key is not none and key.role.name != "User") %}{% set hide_opts = True %}{%else %}{% set hide_opts = False %}{% endif %}
+{% if (key is not none and key.role.name != "User") %}{% set hide_opts = True %}{%else %}{% set hide_opts = False %}{% endif %}
{% block title %}
Edit Key - {{ SITE_NAME }}
{% endblock %}
@@ -39,7 +39,9 @@
From 4fd1b1001847eaef73b3da9a222c011068a3b2b7 Mon Sep 17 00:00:00 2001
From: Pascal de Bruijn
Date: Tue, 6 Sep 2022 15:31:43 +0200
Subject: [PATCH 093/203] models/user.py: properly guard plain_text_password
property
Resolves the following issue, which occurs with force_otp enabled
and OAuth authentication sources:
File "/srv/powerdnsadmin/powerdnsadmin/models/user.py", line 481, in update_profile
"utf-8") if self.plain_text_password else user.password
AttributeError: 'User' object has no attribute 'plain_text_password'
---
powerdnsadmin/models/user.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py
index 2f8b87c..1ac7b60 100644
--- a/powerdnsadmin/models/user.py
+++ b/powerdnsadmin/models/user.py
@@ -107,7 +107,7 @@ class User(db.Model):
def check_password(self, hashed_password):
# Check hashed password. Using bcrypt, the salt is saved into the hash itself
- if (self.plain_text_password):
+ if hasattr(self, "plain_text_password"):
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
hashed_password.encode('utf-8'))
return False
@@ -423,7 +423,7 @@ class User(db.Model):
name='Administrator').first().id
self.password = self.get_hashed_password(
- self.plain_text_password) if self.plain_text_password else '*'
+ self.plain_text_password) if hasattr(self, "plain_text_password") else '*'
if self.password and self.password != '*':
self.password = self.password.decode("utf-8")
@@ -459,7 +459,7 @@ class User(db.Model):
user.email = self.email
# store new password hash (only if changed)
- if self.plain_text_password:
+ if hasattr(self, "plain_text_password"):
user.password = self.get_hashed_password(
self.plain_text_password).decode("utf-8")
@@ -478,7 +478,7 @@ class User(db.Model):
user.lastname = self.lastname if self.lastname else user.lastname
user.password = self.get_hashed_password(
self.plain_text_password).decode(
- "utf-8") if self.plain_text_password else user.password
+ "utf-8") if hasattr(self, "plain_text_password") else user.password
if self.email:
# Can not update to a new email that
From 846c03f154ff9953641628e33632f4ba951b8bf9 Mon Sep 17 00:00:00 2001
From: Pascal de Bruijn
Date: Wed, 7 Sep 2022 14:23:34 +0200
Subject: [PATCH 094/203] models/user.py: add non-zero valid_window to
totp.verify
PyOTP's totp.verify defaults to the valid_window of zero, which means
it will reject valid codes, if submitted just past the 30 sec window.
It also means, users will run into authentication issues very quickly
if their phones time-sync isn't perfect.
Therefore valid_window should at the very least be 1 or more, settting
it higher trades security for robustness, especially with regard to
time desync issues.
---
powerdnsadmin/models/user.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py
index 2f8b87c..228f8f3 100644
--- a/powerdnsadmin/models/user.py
+++ b/powerdnsadmin/models/user.py
@@ -94,7 +94,7 @@ class User(db.Model):
def verify_totp(self, token):
totp = pyotp.TOTP(self.otp_secret)
- return totp.verify(token)
+ return totp.verify(token, valid_window = 5)
def get_hashed_password(self, plain_text_password=None):
# Hash a password for the first time
From cb835978dfef4f390738db81239a9d9d171c76c1 Mon Sep 17 00:00:00 2001
From: corubba
Date: Fri, 23 Sep 2022 00:19:22 +0200
Subject: [PATCH 095/203] Fix order of operations in api payload
PDNS checks that when a `CNAME` rrset is created that no other rrset of
the same name but a different rtype exists. When changing a record type
to `CNAME`, PDA will send two operations in one api call to PDNS: A
deletion of the old rrset, and the addition of the new rrset. For the
check in PDNS to pass, the deletion needs to happen before the addition.
Before PR #1201 that was the case, the first api call did deletions and
the second handled additions and changes. Currently the api payload
contains additions first and deletions last. PDNS applies these in the
order they are passed in the payload to the api, so to restore the
original/correct/working behaviour the order of operations in the api
payload has to be reversed.
fixes #1251
---
powerdnsadmin/models/record.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py
index b5e3050..b6c3f54 100644
--- a/powerdnsadmin/models/record.py
+++ b/powerdnsadmin/models/record.py
@@ -337,7 +337,8 @@ class Record(object):
replaces = [replace_for_api(r) for r in new_rrsets]
deletes = [delete_for_api(r) for r in del_rrsets if not rrset_in(r, replaces)]
return {
- 'rrsets': replaces + deletes
+ # order matters: first deletions, then additions+changes
+ 'rrsets': deletes + replaces
}
def apply(self, domain_name, submitted_records):
From d25a22272eb1ee589ca9425b77ebc49f4b177478 Mon Sep 17 00:00:00 2001
From: WhatshallIbreaktoday
<75358410+WhatshallIbreaktoday@users.noreply.github.com>
Date: Wed, 12 Oct 2022 08:10:35 +0200
Subject: [PATCH 096/203] allow null/None JSON data
This change permits to proxy pdns zone notify api requests (which are expected to be with empty body)
---
powerdnsadmin/lib/helper.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/powerdnsadmin/lib/helper.py b/powerdnsadmin/lib/helper.py
index a5925ef..1b5a082 100644
--- a/powerdnsadmin/lib/helper.py
+++ b/powerdnsadmin/lib/helper.py
@@ -14,9 +14,9 @@ def forward_request():
msg_str = "Sending request to powerdns API {0}"
if request.method != 'GET' and request.method != 'DELETE':
- msg = msg_str.format(request.get_json(force=True))
+ msg = msg_str.format(request.get_json(force=True, silent=True))
current_app.logger.debug(msg)
- data = request.get_json(force=True)
+ data = request.get_json(force=True, silent=True)
verify = False
From d88da0fde3d4691420c376b48b228246a51db69a Mon Sep 17 00:00:00 2001
From: jbe-dw <50663045+jbe-dw@users.noreply.github.com>
Date: Fri, 14 Oct 2022 15:33:33 +0200
Subject: [PATCH 097/203] Update API.md
---
docs/API.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/API.md b/docs/API.md
index d7e3732..7514d5c 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -11,6 +11,8 @@
#### Accessing the API
+PDA has its own API, that should not be confused with the PowerDNS API. Keep in mind that you have to enable PowerDNS API with a key that will be used by PDA to manage it. Therefore, you should use PDA created keys to browse PDA's API, on PDA's adress and port. They don't grant access to PowerDNS' API.
+
The PDA API consists of two distinct parts:
- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
From 25ebbf132c16f0068b7b44759ab8a83e0aa38635 Mon Sep 17 00:00:00 2001
From: Will Rouesnel
Date: Fri, 4 Nov 2022 11:59:59 +1100
Subject: [PATCH 098/203] Fix handling of passwords with % in the
SQLALCHEMY_DATABASE_URI
Fix Flask-Migrate ValueError from occurring when a password has '%'
characters in it when specified via SQLALCHEMY_DATABASE_URI.
---
migrations/env.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/migrations/env.py b/migrations/env.py
index 6a10e6d..4742e14 100755
--- a/migrations/env.py
+++ b/migrations/env.py
@@ -19,7 +19,7 @@ logger = logging.getLogger('alembic.env')
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
- current_app.config.get('SQLALCHEMY_DATABASE_URI'))
+ current_app.config.get('SQLALCHEMY_DATABASE_URI').replace("%","%%"))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
From 3cdf2b6b7c058b74861c2338e45f3cbf48d62744 Mon Sep 17 00:00:00 2001
From: Matt Scott
Date: Thu, 8 Dec 2022 10:52:02 -0500
Subject: [PATCH 099/203] Added current wiki content to project files for
ongoing maintenance. Existing wiki will be updated with a link reference to
the wiki files.
---
...ory-Authentication-using-Group-Security.md | 34 ++++
docs/wiki/DynDNS2.md | 15 ++
docs/wiki/Home.md | 22 +++
.../Install-PowerDNS-Admin-in-Fedora-23.md | 1 +
...-or-MariaDB-Database-for-PowerDNS-Admin.md | 22 +++
...g-PowerDNS-Admin-as-a-service-(Systemd).md | 72 +++++++
.../Running-PowerDNS-Admin-on-Centos-7.md | 100 ++++++++++
.../Running-PowerDNS-Admin-on-Fedora-30.md | 83 ++++++++
...ning-PowerDNS-Admin-on-Ubuntu-or-Debian.md | 94 +++++++++
...dmin-with-Systemd,-Gunicorn--and--Nginx.md | 181 ++++++++++++++++++
...Admin-with-Systemd,-Gunicorn-and-Apache.md | 97 ++++++++++
docs/wiki/Running-on-FreeBSD.md | 102 ++++++++++
docs/wiki/Supervisord-example.md | 18 ++
docs/wiki/Systemd-example.md | 50 +++++
.../Using-PowerDNS-Admin-with-PostgreSQL.md | 38 ++++
docs/wiki/WSGI-Apache-example.md | 100 ++++++++++
.../fullscreen-dashboard.png | Bin 0 -> 70522 bytes
.../fullscreen-domaincreate.png | Bin 0 -> 115626 bytes
.../fullscreen-domainmanage.png | Bin 0 -> 61219 bytes
.../readme_screenshots/fullscreen-login.png | Bin 0 -> 15454 bytes
docs/wiki/images/webui/create.jpg | Bin 0 -> 32990 bytes
docs/wiki/images/webui/index.jpg | Bin 0 -> 27513 bytes
docs/wiki/images/webui/login.jpg | Bin 0 -> 27845 bytes
docs/wiki/uWSGI-example.md | 49 +++++
24 files changed, 1078 insertions(+)
create mode 100644 docs/wiki/Configure-Active-Directory-Authentication-using-Group-Security.md
create mode 100644 docs/wiki/DynDNS2.md
create mode 100644 docs/wiki/Home.md
create mode 100644 docs/wiki/Install-PowerDNS-Admin-in-Fedora-23.md
create mode 100644 docs/wiki/Prepare-MySQL-or-MariaDB-Database-for-PowerDNS-Admin.md
create mode 100644 docs/wiki/Running-PowerDNS-Admin-as-a-service-(Systemd).md
create mode 100644 docs/wiki/Running-PowerDNS-Admin-on-Centos-7.md
create mode 100644 docs/wiki/Running-PowerDNS-Admin-on-Fedora-30.md
create mode 100644 docs/wiki/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md
create mode 100644 docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx.md
create mode 100644 docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn-and-Apache.md
create mode 100644 docs/wiki/Running-on-FreeBSD.md
create mode 100644 docs/wiki/Supervisord-example.md
create mode 100644 docs/wiki/Systemd-example.md
create mode 100644 docs/wiki/Using-PowerDNS-Admin-with-PostgreSQL.md
create mode 100644 docs/wiki/WSGI-Apache-example.md
create mode 100644 docs/wiki/images/readme_screenshots/fullscreen-dashboard.png
create mode 100644 docs/wiki/images/readme_screenshots/fullscreen-domaincreate.png
create mode 100644 docs/wiki/images/readme_screenshots/fullscreen-domainmanage.png
create mode 100644 docs/wiki/images/readme_screenshots/fullscreen-login.png
create mode 100644 docs/wiki/images/webui/create.jpg
create mode 100644 docs/wiki/images/webui/index.jpg
create mode 100644 docs/wiki/images/webui/login.jpg
create mode 100644 docs/wiki/uWSGI-example.md
diff --git a/docs/wiki/Configure-Active-Directory-Authentication-using-Group-Security.md b/docs/wiki/Configure-Active-Directory-Authentication-using-Group-Security.md
new file mode 100644
index 0000000..417bdc3
--- /dev/null
+++ b/docs/wiki/Configure-Active-Directory-Authentication-using-Group-Security.md
@@ -0,0 +1,34 @@
+Active Directory Setup - Tested with Windows Server 2012
+
+1) Login as an admin to PowerDNS Admin
+
+2) Go to Settings --> Authentication
+
+3) Under Authentication, select LDAP
+
+4) Click the Radio Button for Active Directory
+
+5) Fill in the required info -
+
+* LDAP URI - ldap://ip.of.your.domain.controller:389
+* LDAP Base DN - dc=youdomain,dc=com
+* Active Directory domain - yourdomain.com
+* Basic filter - (objectCategory=person)
+ * the brackets here are **very important**
+* Username field - sAMAccountName
+* GROUP SECURITY - Status - On
+* Admin group - CN=Your_AD_Admin_Group,OU=Your_AD_OU,DC=yourdomain,DC=com
+* Operator group - CN=Your_AD_Operator_Group,OU=Your_AD_OU,DC=yourdomain,DC=com
+* User group - CN=Your_AD_User_Group,OU=Your_AD_OU,DC=yourdomain,DC=com
+
+6) Click Save
+
+7) Logout and re-login as an LDAP user from each of the above groups.
+
+If you're having problems getting the correct information for your groups, the following tool can be useful -
+
+https://docs.microsoft.com/en-us/sysinternals/downloads/adexplorer
+
+In our testing, groups with spaces in the name did not work, we had to create groups with underscores to get everything operational.
+
+YMMV
diff --git a/docs/wiki/DynDNS2.md b/docs/wiki/DynDNS2.md
new file mode 100644
index 0000000..2fbd9ae
--- /dev/null
+++ b/docs/wiki/DynDNS2.md
@@ -0,0 +1,15 @@
+Usage:
+IPv4: http://user:pass@yournameserver.yoursite.tld/nic/update?hostname=record.domain.tld&myip=127.0.0.1
+IPv6: http://user:pass@yournameserver.yoursite.tld/nic/update?hostname=record.domain.tld&myip=::1
+Multiple IPs: http://user:pass@yournameserver.yoursite.tld/nic/update?hostname=record.domain.tld&myip=127.0.0.1,127.0.0.2,::1,::2
+
+- user needs to be a LOCAL user, not LDAP etc
+- user must have already logged-in
+- user needs to be added to Domain Access Control list of domain.tld - admin status (manage all) does not suffice
+- record has to exist already - unless on-demand creation is allowed
+- ipv4 address in myip field will change A record
+- ipv6 address in myip field will change AAAA record
+- use commas to separate multiple IP addresses in the myip field, mixing v4 & v6 is allowed
+
+DynDNS also works without authentication header (user:pass@) when already authenticated via session cookie from /login, even with external auth like LDAP.
+However Domain Access Control restriction still applies.
\ No newline at end of file
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
new file mode 100644
index 0000000..7902a97
--- /dev/null
+++ b/docs/wiki/Home.md
@@ -0,0 +1,22 @@
+Welcome to the PowerDNS-Admin wiki!
+
+## Preparation guides
+- [Prepare MySQL or MariaDB Database for PowerDNS-Admin](Prepare-MySQL-or-MariaDB-Database-for-PowerDNS-Admin)
+- [Using PowerDNS-Admin with PostgreSQL](Using-PowerDNS-Admin-with-PostgreSQL)
+
+## Installation guides
+- [Running PowerDNS Admin on Ubuntu or Debian](Running-PowerDNS-Admin-on-Ubuntu-or-Debian)
+- [Running PowerDNS-Admin in Centos 7](Running-PowerDNS-Admin-on-Centos-7)
+- [Running PowerDNS-Admin in Fedora 30](Running-PowerDNS-Admin-on-Fedora-30)
+- [Running PowerDNS-Admin on FreeBSD 12.1-RELEASE](Running-on-FreeBSD)
+
+## Web Server configuration
+- [Supervisord](Supervisord-example)
+- [Systemd](Systemd-example)
+- [Systemd + Gunicorn + Nginx](Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx)
+- [Systemd + Gunicorn + Apache](Running-PowerDNS-Admin-with-Systemd,-Gunicorn-and-Apache)
+- [uWSGI](uWSGI-example)
+- [WSGI-Apache](WSGI-Apache-example)
+
+## Feature usage
+- [DynDNS2](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/DynDNS2)
\ No newline at end of file
diff --git a/docs/wiki/Install-PowerDNS-Admin-in-Fedora-23.md b/docs/wiki/Install-PowerDNS-Admin-in-Fedora-23.md
new file mode 100644
index 0000000..7f876ff
--- /dev/null
+++ b/docs/wiki/Install-PowerDNS-Admin-in-Fedora-23.md
@@ -0,0 +1 @@
+Please refer to CentOS guide: https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/Running-PowerDNS-Admin-on-Centos-7
\ No newline at end of file
diff --git a/docs/wiki/Prepare-MySQL-or-MariaDB-Database-for-PowerDNS-Admin.md b/docs/wiki/Prepare-MySQL-or-MariaDB-Database-for-PowerDNS-Admin.md
new file mode 100644
index 0000000..774d3e3
--- /dev/null
+++ b/docs/wiki/Prepare-MySQL-or-MariaDB-Database-for-PowerDNS-Admin.md
@@ -0,0 +1,22 @@
+This guide will show you how to prepare a MySQL or MariaDB database for PowerDNS-Admin.
+
+### Step-by-step instructions
+1. ivan@ubuntu:~$ `mysql -u root -p` (then enter your MySQL/MariaDB root users password)
+2. mysql> `CREATE DATABASE powerdnsadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`
+3. mysql> `GRANT ALL PRIVILEGES ON powerdnsadmin.* TO 'pdnsadminuser'@'%' IDENTIFIED BY 'p4ssw0rd';`
+4. mysql> `FLUSH PRIVILEGES;`
+5. mysql> `quit`
+
+**NOTE:**
+
+If you plan to manage large zones, you may encounter some issues while applying changes.
+This is due to PowerDNS-Admin trying to insert the entire modified zone into the column history.detail.
+
+Using MySQL/MariaDB, this column is created by default as TEXT and thus limited to 65,535 characters.
+
+_Solution_:
+
+Convert the column to MEDIUMTEXT:
+
+* `USE powerdnsadmin;`
+* `ALTER TABLE history MODIFY detail MEDIUMTEXT;`
\ No newline at end of file
diff --git a/docs/wiki/Running-PowerDNS-Admin-as-a-service-(Systemd).md b/docs/wiki/Running-PowerDNS-Admin-as-a-service-(Systemd).md
new file mode 100644
index 0000000..df01179
--- /dev/null
+++ b/docs/wiki/Running-PowerDNS-Admin-as-a-service-(Systemd).md
@@ -0,0 +1,72 @@
+***
+**WARNING**
+This just uses the development server for testing purposes. For production environments you should probably go with a more robust solution, like [gunicorn](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx) or a WSGI server.
+***
+
+### Following example shows a systemd unit file that can run PowerDNS-Admin
+
+You shouldn't run PowerDNS-Admin as _root_, so let's start of with the user/group creation that will later run PowerDNS-Admin:
+
+Create a new group for PowerDNS-Admin:
+
+> sudo groupadd powerdnsadmin
+
+Create a user for PowerDNS-Admin:
+
+> sudo useradd --system -g powerdnsadmin powerdnsadmin
+
+_`--system` creates a user without login-shell and password, suitable for running system services._
+
+Create new systemd service file:
+
+> sudo vim /etc/systemd/system/powerdns-admin.service
+
+General example:
+```
+[Unit]
+Description=PowerDNS-Admin
+After=network.target
+
+[Service]
+Type=simple
+User=powerdnsadmin
+Group=powerdnsadmin
+ExecStart=/opt/web/powerdns-admin/flask/bin/python ./run.py
+WorkingDirectory=/opt/web/powerdns-admin
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Debian example:
+```
+[Unit]
+Description=PowerDNS-Admin
+After=network.target
+
+[Service]
+Type=simple
+User=powerdnsadmin
+Group=powerdnsadmin
+Environment=PATH=/opt/web/powerdns-admin/flask/bin
+ExecStart=/opt/web/powerdns-admin/flask/bin/python /opt/web/powerdns-admin/run.py
+WorkingDirectory=/opt/web/powerdns-admin
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+Before starting the service, we need to make sure that the new user can work on the files in the PowerDNS-Admin folder:
+> chown -R powerdnsadmin:powerdnsadmin /opt/web/powerdns-admin
+
+After saving the file, we need to reload the systemd daemon:
+> sudo systemctl daemon-reload
+
+We can now try to start the service:
+> sudo systemctl start powerdns-admin
+
+If you would like to start PowerDNS-Admin automagically at startup enable the service:
+> systemctl enable powerdns-admin
+
+Should the service not be up by now, consult your syslog. Generally this will be a file permission issue, or python not finding it's modules. See the Debian unit example to see how you can use systemd in a python `virtualenv`
\ No newline at end of file
diff --git a/docs/wiki/Running-PowerDNS-Admin-on-Centos-7.md b/docs/wiki/Running-PowerDNS-Admin-on-Centos-7.md
new file mode 100644
index 0000000..e61485a
--- /dev/null
+++ b/docs/wiki/Running-PowerDNS-Admin-on-Centos-7.md
@@ -0,0 +1,100 @@
+```
+NOTE: If you are logged in as User and not root, add "sudo", or get root by sudo -i.
+```
+
+
+**Remove old Python 3.4**
+If you had it installed because of older instructions
+```
+yum remove python34*
+yum autoremove
+```
+
+
+## Install required packages
+**Install needed repositories:**
+
+```
+yum install epel-release
+yum install https://repo.ius.io/ius-release-el7.rpm https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
+```
+
+**Install Python 3.6 and tools**
+```
+yum install python3 python3-devel python3-pip
+pip3.6 install -U pip
+pip install -U virtualenv
+```
+
+**Install required packages for building python libraries from requirements.txt file**
+```
+--> NOTE: I am using MySQL Community server as the database backend.
+ So `mysql-community-devel` is required. For MariaDB,
+ and PostgreSQL the required package will be different.
+```
+
+If you use MariaDB ( from [MariaDB repositories](https://mariadb.com/resources/blog/installing-mariadb-10-on-centos-7-rhel-7/) )
+
+```
+yum install gcc MariaDB-devel MariaDB-shared openldap-devel xmlsec1-devel xmlsec1-openssl libtool-ltdl-devel
+```
+
+If you use default Centos mariadb (5.5)
+```
+yum install gcc mariadb-devel openldap-devel xmlsec1-devel xmlsec1-openssl libtool-ltdl-devel
+```
+
+
+**Install yarn to build asset files + Nodejs 14**
+```
+curl -sL https://rpm.nodesource.com/setup_14.x | bash -
+curl -sL https://dl.yarnpkg.com/rpm/yarn.repo -o /etc/yum.repos.d/yarn.repo
+yum install yarn
+```
+
+
+## Checkout source code and create virtualenv
+NOTE: Please adjust `/opt/web/powerdns-admin` to your local web application directory
+
+```
+git clone https://github.com/ngoduykhanh/PowerDNS-Admin.git /opt/web/powerdns-admin
+cd /opt/web/powerdns-admin
+virtualenv -p python3 flask
+```
+
+Activate your python3 environment and install libraries:
+```
+. ./flask/bin/activate
+pip install python-dotenv
+pip install -r requirements.txt
+```
+
+
+## Running PowerDNS-Admin
+NOTE: The default config file is located at `./powerdnsadmin/default_config.py`. If you want to load another one, please set the `FLASK_CONF` environment variable. E.g.
+```bash
+export FLASK_CONF=../configs/development.py
+```
+
+**Then create the database schema by running:**
+```
+export FLASK_APP=powerdnsadmin/__init__.py
+flask db upgrade
+```
+
+**Also, we should generate asset files:**
+```
+yarn install --pure-lockfile
+flask assets build
+```
+
+**Now you can run PowerDNS-Admin by command:**
+```
+./run.py
+```
+
+Open your web browser and access to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register an user. The first user will be in Administrator role.
+
+At the first time you login into the PDA UI, you will be redirected to setting page to configure the PDNS API information.
+
+_**Note:**_ For production environment, i would recommend you to run PowerDNS-Admin with gunicorn or uwsgi instead of flask's built-in web server, take a look at WIKI page to see how to configure them.
\ No newline at end of file
diff --git a/docs/wiki/Running-PowerDNS-Admin-on-Fedora-30.md b/docs/wiki/Running-PowerDNS-Admin-on-Fedora-30.md
new file mode 100644
index 0000000..e3b23ea
--- /dev/null
+++ b/docs/wiki/Running-PowerDNS-Admin-on-Fedora-30.md
@@ -0,0 +1,83 @@
+```
+NOTE: If you are logged in as User and not root, add "sudo", or get root by sudo -i.
+ Normally under centos you are anyway mostly root.
+```
+
+
+## Install required packages
+
+**Install Python and requirements**
+```bash
+dnf install python37 python3-devel python3-pip
+```
+**Install Backend and Environment prerequisites**
+```bash
+dnf install mariadb-devel mariadb-common openldap-devel xmlsec1-devel xmlsec1-openssl libtool-ltdl-devel
+```
+**Install Development tools**
+```bash
+dnf install gcc gc make
+```
+**Install PIP**
+```bash
+pip3.7 install -U pip
+```
+**Install Virtual Environment**
+```bash
+pip install -U virtualenv
+```
+
+**Install Yarn for building NodeJS asset files:**
+```bash
+dnf install npm
+npm install yarn -g
+```
+
+## Clone the PowerDNS-Admin repository to the installation path:
+```bash
+cd /opt/web/
+git clone https://github.com/ngoduykhanh/PowerDNS-Admin.git powerdns-admin
+```
+
+**Prepare the Virtual Environment:**
+```bash
+cd /opt/web/powerdns-admin
+virtualenv -p python3 flask
+```
+**Activate the Python Environment and install libraries**
+```bash
+. ./flask/bin/activate
+pip install python-dotenv
+pip install -r requirements.txt
+```
+
+## Running PowerDNS-Admin
+
+NOTE: The default config file is located at `./powerdnsadmin/default_config.py`. If you want to load another one, please set the `FLASK_CONF` environment variable. E.g.
+```bash
+export FLASK_CONF=../configs/development.py
+```
+
+**Then create the database schema by running:**
+```
+(flask) [khanh@localhost powerdns-admin] export FLASK_APP=powerdnsadmin/__init__.py
+(flask) [khanh@localhost powerdns-admin] flask db upgrade
+```
+
+**Also, we should generate asset files:**
+```
+(flask) [khanh@localhost powerdns-admin] yarn install --pure-lockfile
+(flask) [khanh@localhost powerdns-admin] flask assets build
+```
+
+**Now you can run PowerDNS-Admin by command:**
+```
+(flask) [khanh@localhost powerdns-admin] ./run.py
+```
+
+Open your web browser and access to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register an user. The first user will be in Administrator role.
+
+At the first time you login into the PDA UI, you will be redirected to setting page to configure the PDNS API information.
+
+_**Note:**_ For production environment, i recommend to run PowerDNS-Admin with WSGI over Apache instead of flask's built-in web server...
+ Take a look at [WSGI Apache Example](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/WSGI-Apache-example#fedora) WIKI page to see how to configure it.
\ No newline at end of file
diff --git a/docs/wiki/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md b/docs/wiki/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md
new file mode 100644
index 0000000..1cdda78
--- /dev/null
+++ b/docs/wiki/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md
@@ -0,0 +1,94 @@
+## Install required packages
+
+**Install Python 3 development package**
+
+```bash
+sudo apt install python3-dev
+```
+
+**Install required packages for building python libraries from requirements.txt file**
+
+```bash
+sudo apt install -y git libmysqlclient-dev libsasl2-dev libldap2-dev libssl-dev libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev pkg-config apt-transport-https virtualenv build-essential curl
+```
+
+_**Note:**_ I am using MySQL Community server as the database backend. So `libmysqlclient-dev` is required. For MariaDB, and PostgreSQL the required package will be difference.
+
+** Install Maria or MySQL (ONLY if not ALREADY installed)**
+```bash
+sudo apt install mariadb-server mariadb-client
+```
+Create database and user using mysql command and entering
+```bash
+>create database pda;
+>grant all privileges on pda.* TO 'pda'@'localhost' identified by 'YOUR_PASSWORD_HERE';
+>flush privileges;
+```
+**Install NodeJs**
+
+```bash
+curl -sL https://deb.nodesource.com/setup_14.x | bash -
+apt install -y nodejs
+```
+
+**Install yarn to build asset files**
+
+```bash
+sudo curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
+echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
+sudo apt update -y
+sudo apt install -y yarn
+```
+
+## Checkout source code and create virtualenv
+_**Note:**_ Please adjust `/opt/web/powerdns-admin` to your local web application directory
+
+```bash
+git clone https://github.com/ngoduykhanh/PowerDNS-Admin.git /opt/web/powerdns-admin
+cd /opt/web/powerdns-admin
+python3 -mvenv ./venv
+```
+
+Activate your python3 environment and install libraries:
+
+```bash
+source ./venv/bin/activate
+pip install --upgrade pip
+pip install -r requirements.txt
+```
+
+
+
+## Running PowerDNS-Admin
+
+Create PowerDNS-Admin config file and make the changes necessary for your use case. Make sure to change `SECRET_KEY` to a long random string that you generated yourself ([see Flask docs](https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)), do not use the pre-defined one. E.g.:
+
+```bash
+cp /opt/web/powerdns-admin/configs/development.py /opt/web/powerdns-admin/configs/production.py
+vim /opt/web/powerdns-admin/configs/production.py
+export FLASK_CONF=../configs/production.py
+```
+
+Do the DB migration
+
+```bash
+export FLASK_APP=powerdnsadmin/__init__.py
+flask db upgrade
+```
+
+Then generate asset files
+
+```bash
+yarn install --pure-lockfile
+flask assets build
+```
+
+Now you can run PowerDNS-Admin by command
+
+```bash
+./run.py
+```
+
+Open your web browser and go to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register a user. The first user will be in the Administrator role.
+
+This is good for testing, but for production usage, you should use gunicorn or uwsgi. See [Running PowerDNS Admin with Systemd, Gunicorn and Nginx](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx) for instructions.
\ No newline at end of file
diff --git a/docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx.md b/docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx.md
new file mode 100644
index 0000000..57725bc
--- /dev/null
+++ b/docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx.md
@@ -0,0 +1,181 @@
+Following is an example showing how to run PowerDNS-Admin with systemd, gunicorn and nginx:
+
+## Configure PowerDNS-Admin
+
+Create PowerDNS-Admin config file and make the changes necessary for your use case. Make sure to change `SECRET_KEY` to a long random string that you generated yourself ([see Flask docs](https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)), do not use the pre-defined one.
+```
+$ cp /opt/web/powerdns-admin/configs/development.py /opt/web/powerdns-admin/configs/production.py
+$ vim /opt/web/powerdns-admin/configs/production.py
+```
+
+## Configure systemd service
+
+`$ sudo vim /etc/systemd/system/powerdns-admin.service`
+
+```
+[Unit]
+Description=PowerDNS-Admin
+Requires=powerdns-admin.socket
+After=network.target
+
+[Service]
+PIDFile=/run/powerdns-admin/pid
+User=pdns
+Group=pdns
+WorkingDirectory=/opt/web/powerdns-admin
+ExecStartPre=+mkdir -p /run/powerdns-admin/
+ExecStartPre=+chown pdns:pdns -R /run/powerdns-admin/
+ExecStart=/usr/local/bin/gunicorn --pid /run/powerdns-admin/pid --bind unix:/run/powerdns-admin/socket 'powerdnsadmin:create_app()'
+ExecReload=/bin/kill -s HUP $MAINPID
+ExecStop=/bin/kill -s TERM $MAINPID
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`$ sudo systemctl edit powerdns-admin.service`
+
+```
+[Service]
+Environment="FLASK_CONF=../configs/production.py"
+```
+
+`$ sudo vim /etc/systemd/system/powerdns-admin.socket`
+
+```
+[Unit]
+Description=PowerDNS-Admin socket
+
+[Socket]
+ListenStream=/run/powerdns-admin/socket
+
+[Install]
+WantedBy=sockets.target
+```
+
+`$ sudo vim /etc/tmpfiles.d/powerdns-admin.conf`
+
+```
+d /run/powerdns-admin 0755 pdns pdns -
+```
+
+Then `sudo systemctl daemon-reload; sudo systemctl start powerdns-admin.socket; sudo systemctl enable powerdns-admin.socket` to start the Powerdns-Admin service and make it run on boot.
+
+## Sample nginx configuration
+```
+server {
+ listen *:80;
+ server_name powerdns-admin.local www.powerdns-admin.local;
+
+ index index.html index.htm index.php;
+ root /opt/web/powerdns-admin;
+ access_log /var/log/nginx/powerdns-admin.local.access.log combined;
+ error_log /var/log/nginx/powerdns-admin.local.error.log;
+
+ client_max_body_size 10m;
+ client_body_buffer_size 128k;
+ proxy_redirect off;
+ proxy_connect_timeout 90;
+ proxy_send_timeout 90;
+ proxy_read_timeout 90;
+ proxy_buffers 32 4k;
+ proxy_buffer_size 8k;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_headers_hash_bucket_size 64;
+
+ location ~ ^/static/ {
+ include /etc/nginx/mime.types;
+ root /opt/web/powerdns-admin/powerdnsadmin;
+
+ location ~* \.(jpg|jpeg|png|gif)$ {
+ expires 365d;
+ }
+
+ location ~* ^.+.(css|js)$ {
+ expires 7d;
+ }
+ }
+
+ location / {
+ proxy_pass http://unix:/run/powerdns-admin/socket;
+ proxy_read_timeout 120;
+ proxy_connect_timeout 120;
+ proxy_redirect off;
+ }
+
+}
+```
+
+
+Sample Nginx-Configuration for SSL
+
+* Im binding this config to every dns-name with default_server...
+* but you can remove it and set your server_name.
+
+```
+server {
+ listen 80 default_server;
+ server_name "";
+ return 301 https://$http_host$request_uri;
+}
+
+server {
+ listen 443 ssl http2 default_server;
+ server_name _;
+ index index.html index.htm;
+ error_log /var/log/nginx/error_powerdnsadmin.log error;
+ access_log off;
+
+ ssl_certificate path_to_your_fullchain_or_cert;
+ ssl_certificate_key path_to_your_key;
+ ssl_dhparam path_to_your_dhparam.pem;
+ ssl_prefer_server_ciphers on;
+ ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
+ ssl_session_cache shared:SSL:10m;
+
+ client_max_body_size 10m;
+ client_body_buffer_size 128k;
+ proxy_redirect off;
+ proxy_connect_timeout 90;
+ proxy_send_timeout 90;
+ proxy_read_timeout 90;
+ proxy_buffers 32 4k;
+ proxy_buffer_size 8k;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Scheme $scheme;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_headers_hash_bucket_size 64;
+
+ location ~ ^/static/ {
+ include mime.types;
+ root /opt/web/powerdns-admin/powerdnsadmin;
+ location ~* \.(jpg|jpeg|png|gif)$ { expires 365d; }
+ location ~* ^.+.(css|js)$ { expires 7d; }
+ }
+
+ location ~ ^/upload/ {
+ include mime.types;
+ root /opt/web/powerdns-admin;
+ location ~* \.(jpg|jpeg|png|gif)$ { expires 365d; }
+ location ~* ^.+.(css|js)$ { expires 7d; }
+ }
+
+ location / {
+ proxy_pass http://unix:/run/powerdns-admin/socket;
+ proxy_read_timeout 120;
+ proxy_connect_timeout 120;
+ proxy_redirect http:// $scheme://;
+ }
+}
+```
+
+
+## Note
+* `/opt/web/powerdns-admin` is the path to your powerdns-admin web directory
+* Make sure you have installed gunicorn in flask virtualenv already.
+* `powerdns-admin.local` just an example of your web domain name.
\ No newline at end of file
diff --git a/docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn-and-Apache.md b/docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn-and-Apache.md
new file mode 100644
index 0000000..a2d4fa2
--- /dev/null
+++ b/docs/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn-and-Apache.md
@@ -0,0 +1,97 @@
+Following is an example showing how to run PowerDNS-Admin with systemd, gunicorn and Apache:
+
+The systemd and gunicorn setup are the same as for with nginx. This set of configurations assumes you have installed your PowerDNS-Admin under /opt/powerdns-admin and are running with a package-installed gunicorn.
+
+## Configure systemd service
+
+`$ sudo vim /etc/systemd/system/powerdns-admin.service`
+
+```
+[Unit]
+Description=PowerDNS web administration service
+Requires=powerdns-admin.socket
+Wants=network.target
+After=network.target mysqld.service postgresql.service slapd.service mariadb.service
+
+[Service]
+PIDFile=/run/powerdns-admin/pid
+User=pdnsa
+Group=pdnsa
+WorkingDirectory=/opt/powerdns-admin
+ExecStart=/usr/bin/gunicorn-3.6 --workers 4 --log-level info --pid /run/powerdns-admin/pid --bind unix:/run/powerdns-admin/socket "powerdnsadmin:create_app(config='config.py')"
+ExecReload=/bin/kill -s HUP $MAINPID
+ExecStop=/bin/kill -s TERM $MAINPID
+PrivateTmp=true
+Restart=on-failure
+RestartSec=10
+StartLimitInterval=0
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`$ sudo vim /etc/systemd/system/powerdns-admin.socket`
+
+```
+[Unit]
+Description=PowerDNS-Admin socket
+
+[Socket]
+ListenStream=/run/powerdns-admin/socket
+
+[Install]
+WantedBy=sockets.target
+```
+
+`$ sudo vim /etc/tmpfiles.d/powerdns-admin.conf`
+
+```
+d /run/powerdns-admin 0755 pdnsa pdnsa -
+```
+
+Then `sudo systemctl daemon-reload; sudo systemctl start powerdns-admin.socket; sudo systemctl enable powerdns-admin.socket` to start the Powerdns-Admin service and make it run on boot.
+
+## Sample Apache configuration
+
+This includes SSL redirect.
+
+```
+
+ ServerName dnsadmin.company.com
+ DocumentRoot "/opt/powerdns-admin"
+
+ Options Indexes FollowSymLinks MultiViews
+ AllowOverride None
+ Require all granted
+
+ Redirect permanent / https://dnsadmin.company.com/
+
+
+ ServerName dnsadmin.company.com
+ DocumentRoot "/opt/powerdns-admin/powerdnsadmin"
+ ## Alias declarations for resources outside the DocumentRoot
+ Alias /static/ "/opt/powerdns-admin/powerdnsadmin/static/"
+ Alias /favicon.ico "/opt/powerdns-admin/powerdnsadmin/static/favicon.ico"
+
+ AllowOverride None
+ Require all granted
+
+ ## Proxy rules
+ ProxyRequests Off
+ ProxyPreserveHost On
+ ProxyPass /static/ !
+ ProxyPass /favicon.ico !
+ ProxyPass / unix:/var/run/powerdns-admin/socket|http://%{HTTP_HOST}/
+ ProxyPassReverse / unix:/var/run/powerdns-admin/socket|http://%{HTTP_HOST}/
+ ## SSL directives
+ SSLEngine on
+ SSLCertificateFile "/etc/pki/tls/certs/dnsadmin.company.com.crt"
+ SSLCertificateKeyFile "/etc/pki/tls/private/dnsadmin.company.com.key"
+
+```
+
+## Notes
+* The above assumes your installation is under /opt/powerdns-admin
+* The hostname is assumed as dnsadmin.company.com
+* gunicorn is installed in /usr/bin via a package (as in the case with CentOS/Redhat 7) and you have Python 3.6 installed. If you prefer to use flask then see the systemd configuration for nginx.
+* On Ubuntu / Debian systems, you may need to enable the "proxy_http" module with `a2enmod proxy_http`
diff --git a/docs/wiki/Running-on-FreeBSD.md b/docs/wiki/Running-on-FreeBSD.md
new file mode 100644
index 0000000..76a8f5b
--- /dev/null
+++ b/docs/wiki/Running-on-FreeBSD.md
@@ -0,0 +1,102 @@
+On [FreeBSD](https://www.freebsd.org/), most software is installed using `pkg`. You can always build from source with the Ports system. This method uses as many binary ports as possible, and builds some python packages from source. It installs all the required runtimes in the global system (e.g., python, node, yarn) and then builds a virtual python environment in `/opt/python`. Likewise, it installs powerdns-admin in `/opt/powerdns-admin`.
+
+### Build an area to host files
+
+```bash
+mkdir -p /opt/python
+```
+
+### Install prerequisite runtimes: python, node, yarn
+
+```bash
+sudo pkg install git python3 curl node12 yarn-node12
+sudo pkg install libxml2 libxslt pkgconf py37-xmlsec py37-cffi py37-ldap
+```
+
+## Check Out Source Code
+_**Note:**_ Please adjust `/opt/powerdns-admin` to your local web application directory
+
+```bash
+git clone https://github.com/ngoduykhanh/PowerDNS-Admin.git /opt/powerdns-admin
+cd /opt/powerdns-admin
+```
+
+## Make Virtual Python Environment
+
+Make a virtual environment for python. Activate your python3 environment and install libraries. It's easier to install some python libraries as system packages, so we add the `--system-site-packages` option to pull those in.
+
+> Note: I couldn't get `python-ldap` to install correctly, and I don't need it. I commented out the `python-ldap` line in `requirements.txt` and it all built and installed correctly. If you don't intend to use LDAP authentication, you'll be fine. If you need LDAP authentication, it probably won't work.
+
+```bash
+python3 -m venv /web/python --system-site-packages
+source /web/python/bin/activate
+/web/python/bin/python3 -m pip install --upgrade pip wheel
+# this command comments out python-ldap
+perl -pi -e 's,^python-ldap,\# python-ldap,' requirements.txt
+pip3 install -r requirements.txt
+```
+
+## Configuring PowerDNS-Admin
+
+NOTE: The default config file is located at `./powerdnsadmin/default_config.py`. If you want to load another one, please set the `FLASK_CONF` environment variable. E.g.
+```bash
+cp configs/development.py /opt/powerdns-admin/production.py
+export FLASK_CONF=/opt/powerdns-admin/production.py
+```
+
+### Update the Flask config
+
+Edit your flask python configuration. Insert values for the database server, user name, password, etc.
+
+```bash
+vim $FLASK_CONF
+```
+
+Edit the values below to something sensible
+```python
+### BASIC APP CONFIG
+SALT = '[something]'
+SECRET_KEY = '[something]'
+BIND_ADDRESS = '0.0.0.0'
+PORT = 9191
+OFFLINE_MODE = False
+
+### DATABASE CONFIG
+SQLA_DB_USER = 'pda'
+SQLA_DB_PASSWORD = 'changeme'
+SQLA_DB_HOST = '127.0.0.1'
+SQLA_DB_NAME = 'pda'
+SQLALCHEMY_TRACK_MODIFICATIONS = True
+```
+
+Be sure to uncomment one of the lines like `SQLALCHEMY_DATABASE_URI`.
+
+### Initialise the database
+
+```bash
+export FLASK_APP=powerdnsadmin/__init__.py
+flask db upgrade
+```
+
+### Build web assets
+
+```bash
+yarn install --pure-lockfile
+flask assets build
+```
+
+## Running PowerDNS-Admin
+
+Now you can run PowerDNS-Admin by command
+
+```bash
+./run.py
+```
+
+Open your web browser and go to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register a user. The first user will be in the Administrator role.
+
+### Running at startup
+
+This is good for testing, but for production usage, you should use gunicorn or uwsgi. See [Running PowerDNS Admin with Systemd, Gunicorn and Nginx](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx) for instructions.
+
+The right approach long-term is to create a startup script in `/usr/local/etc/rc.d` and enable it through `/etc/rc.conf`.
\ No newline at end of file
diff --git a/docs/wiki/Supervisord-example.md b/docs/wiki/Supervisord-example.md
new file mode 100644
index 0000000..11cebc8
--- /dev/null
+++ b/docs/wiki/Supervisord-example.md
@@ -0,0 +1,18 @@
+Following is an example showing how to run PowerDNS-Admin with supervisord
+
+Create supervisord program config file
+```
+$ sudo vim /etc/supervisor.d/powerdnsadmin.conf
+```
+
+```
+[program:powerdnsadmin]
+command=/opt/web/powerdns-admin/flask/bin/python ./run.py
+stdout_logfile=/var/log/supervisor/program_powerdnsadmin.log
+stderr_logfile=/var/log/supervisor/program_powerdnsadmin.error
+autostart=true
+autorestart=true
+directory=/opt/web/powerdns-admin
+```
+
+Then `sudo supervisorctl start powerdnsadmin` to start the Powerdns-Admin service.
\ No newline at end of file
diff --git a/docs/wiki/Systemd-example.md b/docs/wiki/Systemd-example.md
new file mode 100644
index 0000000..d7f738b
--- /dev/null
+++ b/docs/wiki/Systemd-example.md
@@ -0,0 +1,50 @@
+## Configure systemd service
+
+This example uses package-installed gunicorn (instead of flask-installed) and PowerDNS-Admin installed under /opt/powerdns-admin
+
+`$ sudo vim /etc/systemd/system/powerdns-admin.service`
+
+```
+[Unit]
+Description=PowerDNS web administration service
+Requires=powerdns-admin.socket
+Wants=network.target
+After=network.target mysqld.service postgresql.service slapd.service mariadb.service
+
+[Service]
+PIDFile=/run/powerdns-admin/pid
+User=pdnsa
+Group=pdnsa
+WorkingDirectory=/opt/powerdns-admin
+ExecStart=/usr/bin/gunicorn-3.6 --workers 4 --log-level info --pid /run/powerdns-admin/pid --bind unix:/run/powerdns-admin/socket "powerdnsadmin:create_app(config='config.py')"
+ExecReload=/bin/kill -s HUP $MAINPID
+ExecStop=/bin/kill -s TERM $MAINPID
+PrivateTmp=true
+Restart=on-failure
+RestartSec=10
+StartLimitInterval=0
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`$ sudo vim /etc/systemd/system/powerdns-admin.socket`
+
+```
+[Unit]
+Description=PowerDNS-Admin socket
+
+[Socket]
+ListenStream=/run/powerdns-admin/socket
+
+[Install]
+WantedBy=sockets.target
+```
+
+`$ sudo vim /etc/tmpfiles.d/powerdns-admin.conf`
+
+```
+d /run/powerdns-admin 0755 pdns pdns -
+```
+
+Then `sudo systemctl daemon-reload; sudo systemctl start powerdns-admin.socket; sudo systemctl enable powerdns-admin.socket` to start the Powerdns-Admin service and make it run on boot.
diff --git a/docs/wiki/Using-PowerDNS-Admin-with-PostgreSQL.md b/docs/wiki/Using-PowerDNS-Admin-with-PostgreSQL.md
new file mode 100644
index 0000000..04155e9
--- /dev/null
+++ b/docs/wiki/Using-PowerDNS-Admin-with-PostgreSQL.md
@@ -0,0 +1,38 @@
+If you would like to use PostgreSQL instead of MySQL or MariaDB, you have to install difference dependencies. Check the following instructions.
+
+### Install dependencies
+```
+$ sudo yum install postgresql-libs
+$ pip install psycopg2
+```
+
+### Create database
+```
+$ sudo su - postgres
+$ createuser powerdnsadmin
+$ createdb powerdnsadmindb
+$ psql
+postgres=# alter user powerdnsadmin with encrypted password 'powerdnsadmin';
+postgres=# grant all privileges on database powerdnsadmindb to powerdnsadmin;
+```
+
+In your `config.py` file, make sure you have
+```
+SQLALCHEMY_DATABASE_URI = 'postgresql://powerdnsadmin:powerdnsadmin@127.0.0.1/powerdnsadmindb'
+```
+
+Note:
+- Please change the information above (db, user, password) to fit your setup.
+- You might need to adjust your PostgreSQL's `pg_hba.conf` config file to allow password authentication for networks.
+
+### Use Docker
+```
+docker run --name pdnsadmin-test -e BIND_ADDRESS=0.0.0.0
+-e SECRET_KEY='a-very-secret-key'
+-e PORT='9191'
+-e SQLA_DB_USER='powerdns_admin_user'
+-e SQLA_DB_PASSWORD='exceptionallysecure'
+-e SQLA_DB_HOST='192.168.0.100'
+-e SQLA_DB_NAME='powerdns_admin_test'
+-v /data/node_modules:/var/www/powerdns-admin/node_modules -d -p 9191:9191 ixpict/powerdns-admin-pgsql:latest
+```
\ No newline at end of file
diff --git a/docs/wiki/WSGI-Apache-example.md b/docs/wiki/WSGI-Apache-example.md
new file mode 100644
index 0000000..d31e4f7
--- /dev/null
+++ b/docs/wiki/WSGI-Apache-example.md
@@ -0,0 +1,100 @@
+How to run PowerDNS-Admin via WSGI and Apache2.4 using mod_wsgi.
+
+**Note**: You must install mod_wsgi by using pip3 instead of system default mod_wsgi!!!
+
+### Ubuntu/Debian
+```shell
+# apt install apache2-dev
+# virtualenv -p python3 flask
+# source ./flask/bin/activate
+(flask) # pip3 install mod-wsgi
+(flask) # mod_wsgi-express install-module > /etc/apache2/mods-available/wsgi.load
+(flask) # a2enmod wsgi
+(flask) # systemctl restart apache2
+```
+### CentOS
+```shell
+# yum install httpd-devel
+# virtualenv -p python3 flask
+# source ./flask/bin/activate
+(flask) # pip3 install mod-wsgi
+(flask) # mod_wsgi-express install-module > /etc/httpd/conf.modules.d/02-wsgi.conf
+(flask) # systemctl restart httpd
+```
+### Fedora
+```bash
+# Install Apache's Development interfaces and package requirements
+dnf install httpd-devel gcc gc make
+virtualenv -p python3 flask
+source ./flask/bin/activate
+# Install WSGI for HTTPD
+pip install mod_wsgi-httpd
+# Install WSGI
+pip install mod-wsgi
+# Enable the module in Apache:
+mod_wsgi-express install-module > /etc/httpd/conf.modules.d/02-wsgi.conf
+systemctl restart httpd
+```
+
+Apache vhost configuration;
+```apache
+
+ ServerName superawesomedns.foo.bar
+ ServerAlias [fe80::1]
+ ServerAdmin webmaster@foo.bar
+
+ SSLEngine On
+ SSLCertificateFile /some/path/ssl/certs/cert.pem
+ SSLCertificateKeyFile /some/path/ssl/private/cert.key
+
+ ErrorLog /var/log/apache2/error-superawesomedns.foo.bar.log
+ CustomLog /var/log/apache2/access-superawesomedns.foo.bar.log combined
+
+ DocumentRoot /srv/vhosts/superawesomedns.foo.bar/
+
+ WSGIDaemonProcess pdnsadmin user=pdnsadmin group=pdnsadmin threads=5
+ WSGIScriptAlias / /srv/vhosts/superawesomedns.foo.bar/powerdnsadmin.wsgi
+
+ # pass BasicAuth on to the WSGI process
+ WSGIPassAuthorization On
+
+
+ WSGIProcessGroup pdnsadmin
+ WSGIApplicationGroup %{GLOBAL}
+
+ AllowOverride None
+ Options +ExecCGI +FollowSymLinks
+ SSLRequireSSL
+ AllowOverride None
+ Require all granted
+
+
+```
+**In Fedora, you might want to change the following line:**
+```apache
+WSGIDaemonProcess pdnsadmin socket-user=apache user=pdnsadmin group=pdnsadmin threads=5
+```
+**And you should add the following line to `/etc/httpd/conf/httpd.conf`:**
+```apache
+WSGISocketPrefix /var/run/wsgi
+```
+
+Content of `/srv/vhosts/superawesomedns.foo.bar/powerdnsadmin.wsgi`;
+```python
+#!/usr/bin/env python3
+import sys
+sys.path.insert(0, '/srv/vhosts/superawesomedns.foo.bar')
+
+from app import app as application
+```
+Starting from 0.2 version, the `powerdnsadmin.wsgi` file is slighty different :
+```python
+#!/usr/bin/env python3
+import sys
+sys.path.insert(0, '/srv/vhosts/superawesomedns.foo.bar')
+
+from powerdnsadmin import create_app
+application = create_app()
+```
+
+(this implies that the pdnsadmin user/group exists, and that you have mod_wsgi loaded)
\ No newline at end of file
diff --git a/docs/wiki/images/readme_screenshots/fullscreen-dashboard.png b/docs/wiki/images/readme_screenshots/fullscreen-dashboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..828fc48e2bd5732a085ef10a7307776304d87d6c
GIT binary patch
literal 70522
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