Merge branch 'PowerDNS-Admin:master' into shine/config_table_key_uniqueness

This commit is contained in:
Kateřina Churanová
2023-02-09 12:29:14 +00:00
committed by GitHub
102 changed files with 2954 additions and 708 deletions

View File

@@ -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)
@@ -100,8 +74,6 @@ def create_app(config=None):
app.jinja_env.filters['display_record_name'] = utils.display_record_name
app.jinja_env.filters['display_master_name'] = utils.display_master_name
app.jinja_env.filters['display_second_to_time'] = utils.display_time
app.jinja_env.filters[
'email_to_gravatar_url'] = utils.email_to_gravatar_url
app.jinja_env.filters[
'display_setting_state'] = utils.display_setting_state
app.jinja_env.filters['pretty_domain_name'] = utils.pretty_domain_name
@@ -119,9 +91,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

View File

@@ -23,7 +23,8 @@ css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css',
js_login = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/bootstrap/dist/js/bootstrap.js',
'node_modules/icheck/icheck.js',
filters=(ConcatFilter, 'jsmin'),
'custom/js/custom.js',
filters=(ConcatFilter, 'rjsmin'),
output='generated/login.js')
js_validation = Bundle('node_modules/bootstrap-validator/dist/validator.js',
@@ -60,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()

View File

@@ -1,12 +1,13 @@
import base64
import binascii
from functools import wraps
from flask import g, request, abort, current_app, render_template
from flask import g, request, abort, current_app, Response
from flask_login import current_user
from .models import User, ApiKey, Setting, Domain, Setting
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges
from .lib.errors import DomainAccessForbidden
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges, RecordTTLNotAllowed, RecordTypeNotAllowed
from .lib.errors import DomainAccessForbidden, DomainOverrideForbidden
def admin_role_required(f):
"""
@@ -259,6 +260,13 @@ def api_can_create_domain(f):
msg = "User {0} does not have enough privileges to create domain"
current_app.logger.error(msg.format(current_user.username))
raise NotEnoughPrivileges()
if Setting().get('deny_domain_override'):
req = request.get_json(force=True)
domain = Domain()
if req['name'] and domain.is_overriding(req['name']):
raise DomainOverrideForbidden()
return f(*args, **kwargs)
return decorated_function
@@ -269,6 +277,9 @@ def apikey_can_create_domain(f):
Grant access if:
- user is in Operator role or higher, or
- allow_user_create_domain is on
and
- deny_domain_override is off or
- override_domain is true (from request)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -278,6 +289,13 @@ def apikey_can_create_domain(f):
msg = "ApiKey #{0} does not have enough privileges to create domain"
current_app.logger.error(msg.format(g.apikey.id))
raise NotEnoughPrivileges()
if Setting().get('deny_domain_override'):
req = request.get_json(force=True)
domain = Domain()
if req['name'] and domain.is_overriding(req['name']):
raise DomainOverrideForbidden()
return f(*args, **kwargs)
return decorated_function
@@ -367,6 +385,60 @@ def apikey_can_configure_dnssec(http_methods=[]):
return decorated_function
return decorator
def allowed_record_types(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if request.method == 'GET':
return f(*args, **kwargs)
if g.apikey.role.name in ['Administrator', 'Operator']:
return f(*args, **kwargs)
records_allowed_to_edit = Setting().get_records_allow_to_edit()
content = request.get_json()
try:
for record in content['rrsets']:
if 'type' not in record:
raise RecordTypeNotAllowed()
if record['type'] not in records_allowed_to_edit:
current_app.logger.error(f"Error: Record type not allowed: {record['type']}")
raise RecordTypeNotAllowed(message=f"Record type not allowed: {record['type']}")
except (TypeError, KeyError) as e:
raise e
return f(*args, **kwargs)
return decorated_function
def allowed_record_ttl(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not Setting().get('enforce_api_ttl'):
return f(*args, **kwargs)
if request.method == 'GET':
return f(*args, **kwargs)
if g.apikey.role.name in ['Administrator', 'Operator']:
return f(*args, **kwargs)
allowed_ttls = Setting().get_ttl_options()
allowed_numeric_ttls = [ ttl[0] for ttl in allowed_ttls ]
content = request.get_json()
try:
for record in content['rrsets']:
if 'ttl' not in record:
raise RecordTTLNotAllowed()
if record['ttl'] not in allowed_numeric_ttls:
current_app.logger.error(f"Error: Record TTL not allowed: {record['ttl']}")
raise RecordTTLNotAllowed(message=f"Record TTL not allowed: {record['ttl']}")
except (TypeError, KeyError) as e:
raise e
return f(*args, **kwargs)
return decorated_function
def apikey_auth(f):
@wraps(f)
@@ -409,7 +481,7 @@ def dyndns_login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.is_authenticated is False:
return render_template('dyndns.html', response='badauth'), 200
return Response(headers={'WWW-Authenticate': 'Basic'}, status=401)
return f(*args, **kwargs)
return decorated_function

View File

@@ -8,8 +8,9 @@ SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
BIND_ADDRESS = '0.0.0.0'
PORT = 9191
HSTS_ENABLED = False
OFFLINE_MODE = False
FILESYSTEM_SESSIONS_ENABLED = False
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_HTTPONLY = True
### DATABASE CONFIG
SQLA_DB_USER = 'pda'

View File

@@ -1,48 +1,58 @@
from OpenSSL import crypto
from datetime import datetime
import pytz
import datetime
import os
CRYPT_PATH = os.path.abspath(os.path.dirname(os.path.realpath(__file__)) + "/../../")
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import NameOID
CRYPT_PATH = os.path.abspath(os.path.dirname(os.path.realpath(__file__)) + "/../../")
CERT_FILE = CRYPT_PATH + "/saml_cert.crt"
KEY_FILE = CRYPT_PATH + "/saml_cert.key"
def check_certificate():
if not os.path.isfile(CERT_FILE):
return False
st_cert = open(CERT_FILE, 'rt').read()
cert = crypto.load_certificate(crypto.FILETYPE_PEM, st_cert)
now = datetime.now(pytz.utc)
begin = datetime.strptime(cert.get_notBefore(), "%Y%m%d%H%M%SZ").replace(tzinfo=pytz.UTC)
begin_ok = begin < now
end = datetime.strptime(cert.get_notAfter(), "%Y%m%d%H%M%SZ").replace(tzinfo=pytz.UTC)
end_ok = end > now
if begin_ok and end_ok:
return True
return False
def create_self_signed_cert():
""" Generate a new self-signed RSA-2048-SHA256 x509 certificate. """
# Generate our key
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# create a key pair
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, 2048)
# Write our key to disk for safe keeping
with open(KEY_FILE, "wb") as key_file:
key_file.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
# create a self-signed cert
cert = crypto.X509()
cert.get_subject().C = "DE"
cert.get_subject().ST = "NRW"
cert.get_subject().L = "Dortmund"
cert.get_subject().O = "Dummy Company Ltd"
cert.get_subject().OU = "Dummy Company Ltd"
cert.get_subject().CN = "PowerDNS-Admin"
cert.set_serial_number(1000)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(10*365*24*60*60)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(k)
cert.sign(k, 'sha256')
# Various details about who we are. For a self-signed certificate the
# subject and issuer are always the same.
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "DE"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "NRW"),
x509.NameAttribute(NameOID.LOCALITY_NAME, "Dortmund"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Dummy Company Ltd"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Dummy Company Ltd"),
x509.NameAttribute(NameOID.COMMON_NAME, "PowerDNS-Admin"),
])
open(CERT_FILE, "bw").write(
crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
open(KEY_FILE, "bw").write(
crypto.dump_privatekey(crypto.FILETYPE_PEM, k))
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.utcnow()
).not_valid_after(
datetime.datetime.utcnow() + datetime.timedelta(days=10*365)
).sign(key, hashes.SHA256())
# Write our certificate out to disk.
with open(CERT_FILE, "wb") as cert_file:
cert_file.write(cert.public_bytes(serialization.Encoding.PEM))

View File

@@ -44,6 +44,13 @@ class DomainAccessForbidden(StructuredException):
self.message = message
self.name = name
class DomainOverrideForbidden(StructuredException):
status_code = 409
def __init__(self, name=None, message="Domain override of record not allowed"):
StructuredException.__init__(self)
self.message = message
self.name = name
class ApiKeyCreateFail(StructuredException):
status_code = 500
@@ -129,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
@@ -138,7 +152,6 @@ class UserCreateFail(StructuredException):
self.message = message
self.name = name
class UserCreateDuplicate(StructuredException):
status_code = 409
@@ -163,7 +176,6 @@ class UserUpdateFailEmail(StructuredException):
self.message = message
self.name = name
class UserDeleteFail(StructuredException):
status_code = 500
@@ -171,3 +183,19 @@ class UserDeleteFail(StructuredException):
StructuredException.__init__(self)
self.message = message
self.name = name
class RecordTypeNotAllowed(StructuredException):
status_code = 400
def __init__(self, name=None, message="Record type not allowed or does not present"):
StructuredException.__init__(self)
self.message = message
self.name = name
class RecordTTLNotAllowed(StructuredException):
status_code = 400
def __init__(self, name=None, message="Record TTL not allowed or does not present"):
StructuredException.__init__(self)
self.message = message
self.name = name

View File

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

View File

@@ -2,13 +2,12 @@ import logging
import re
import json
import requests
import hashlib
import ipaddress
import idna
from collections.abc import Iterable
from distutils.version import StrictVersion
from urllib.parse import urlparse
from datetime import datetime, timedelta
def auth_from_url(url):
@@ -185,17 +184,6 @@ def pdns_api_extended_uri(version):
return ""
def email_to_gravatar_url(email="", size=100):
"""
AD doesn't necessarily have email
"""
if email is None:
email = ""
hash_string = hashlib.md5(email.encode('utf-8')).hexdigest()
return "https://s.gravatar.com/avatar/{0}?s={1}".format(hash_string, size)
def display_setting_state(value):
if value == 1:
return "ON"
@@ -237,21 +225,42 @@ class customBoxes:
}
order = ["reverse", "ip6arpa", "inaddrarpa"]
def pretty_domain_name(value):
"""
Display domain name in original format.
If it is IDN domain (Punycode starts with xn--), do the
idna decoding.
Note that any part of the domain name can be individually punycoded
"""
if isinstance(value, str):
if value.startswith('xn--') \
or value.find('.xn--') != -1:
def pretty_domain_name(domain_name):
# Add a debugging statement to print out the domain name
print("Received domain name:", domain_name)
# Check if the domain name is encoded using Punycode
if domain_name.endswith('.xn--'):
try:
# Decode the domain name using the idna library
domain_name = idna.decode(domain_name)
except Exception as e:
# If the decoding fails, raise an exception with more information
raise Exception('Cannot decode IDN domain: {}'.format(e))
# Return the "pretty" version of the domain name
return domain_name
def to_idna(value, action):
splits = value.split('.')
result = []
if action == 'encode':
for split in splits:
try:
return value.encode().decode('idna')
except:
raise Exception("Cannot decode IDN domain")
else:
return value
# Try encoding to idna
if not split.startswith('_') and not split.startswith('-'):
result.append(idna.encode(split).decode())
else:
result.append(split)
except idna.IDNAError:
result.append(split)
elif action == 'decode':
for split in splits:
if not split.startswith('_') and not split.startswith('--'):
result.append(idna.decode(split))
else:
result.append(split)
else:
raise Exception("Require the Punycode in string format")
raise Exception('No valid action received')
return '.'.join(result)

View File

@@ -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,9 +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:
self.name = ''.join(c for c in self.name.lower()
if c in "abcdefghijklmnopqrstuvwxyz0123456789")
@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 '<Account {0}r>'.format(self.name)
@@ -68,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'}

View File

@@ -60,31 +60,31 @@ class ApiKey(db.Model):
def update(self, role_name=None, description=None, domains=None, accounts=None):
try:
if role_name:
role = Role.query.filter(Role.name == role_name).first()
self.role_id = role.id
if role_name:
role = Role.query.filter(Role.name == role_name).first()
self.role_id = role.id
if description:
self.description = description
if description:
self.description = description
if domains is not None:
domain_object_list = Domain.query \
.filter(Domain.name.in_(domains)) \
.all()
self.domains[:] = domain_object_list
if domains is not None:
domain_object_list = Domain.query \
.filter(Domain.name.in_(domains)) \
.all()
self.domains[:] = domain_object_list
if accounts is not None:
account_object_list = Account.query \
.filter(Account.name.in_(accounts)) \
.all()
self.accounts[:] = account_object_list
if accounts is not None:
account_object_list = Account.query \
.filter(Account.name.in_(accounts)) \
.all()
self.accounts[:] = account_object_list
db.session.commit()
db.session.commit()
except Exception as e:
msg_str = 'Update of apikey failed. Error: {0}'
current_app.logger.error(msg_str.format(e))
db.session.rollback
raise e
msg_str = 'Update of apikey failed. Error: {0}'
current_app.logger.error(msg_str.format(e))
db.session.rollback() # fixed line
raise e
def get_hashed_password(self, plain_text_password=None):
# Hash a password for the first time

View File

@@ -1,3 +1,4 @@
import json
import re
import traceback
from flask import current_app
@@ -19,7 +20,7 @@ class Domain(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), index=True, unique=True)
master = db.Column(db.String(128))
type = db.Column(db.String(6), nullable=False)
type = db.Column(db.String(8), nullable=False)
serial = db.Column(db.BigInteger)
notified_serial = db.Column(db.BigInteger)
last_check = db.Column(db.Integer)
@@ -109,6 +110,22 @@ class Domain(db.Model):
'Domain does not exist. ERROR: {0}'.format(e))
return None
def search_idn_domains(self, search_string):
"""
Search for IDN domains using the provided search string.
"""
# Compile the regular expression pattern for matching IDN domain names
idn_pattern = re.compile(r'^xn--')
# Search for domain names that match the IDN pattern
idn_domains = [
domain for domain in self.get_domains() if idn_pattern.match(domain)
]
# Filter the search results based on the provided search string
return [domain for domain in idn_domains if search_string in domain]
def update(self):
"""
Fetch zones (domains) from PowerDNS and update into DB
@@ -142,9 +159,20 @@ class Domain(db.Model):
current_app.logger.debug(traceback.format_exc())
# update/add new domain
account_cache = {}
for data in jdata:
if 'account' in data:
account_id = Account().get_id_by_name(data['account'])
# if no account is set don't try to query db
if data['account'] == '':
find_account_id = None
else:
find_account_id = account_cache.get(data['account'])
# if account was not queried in the past and hence not in cache
if find_account_id is None:
find_account_id = Account().get_id_by_name(data['account'])
# add to cache
account_cache[data['account']] = find_account_id
account_id = find_account_id
else:
current_app.logger.debug(
"No 'account' data found in API result - Unsupported PowerDNS version?"
@@ -208,7 +236,7 @@ class Domain(db.Model):
Add a domain to power dns
"""
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
domain_name = domain_name + '.'
domain_ns = [ns + '.' for ns in domain_ns]
@@ -311,7 +339,7 @@ class Domain(db.Model):
if not domain:
return {'status': 'error', 'msg': 'Domain does not exist.'}
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
if soa_edit_api not in ["DEFAULT", "INCREASE", "EPOCH", "OFF"]:
soa_edit_api = 'DEFAULT'
@@ -361,7 +389,7 @@ class Domain(db.Model):
if not domain:
return {'status': 'error', 'msg': 'Domain does not exist.'}
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
post_data = {"kind": kind, "masters": masters}
@@ -421,7 +449,7 @@ class Domain(db.Model):
if result['status'] == 'ok':
history = History(msg='Add reverse lookup domain {0}'.format(
domain_reverse_name),
detail=str({
detail=json.dumps({
'domain_type': 'Master',
'domain_master_ips': ''
}),
@@ -681,7 +709,7 @@ class Domain(db.Model):
"""
domain = Domain.query.filter(Domain.name == domain_name).first()
if domain:
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
try:
# Enable API-RECTIFY for domain, BEFORE activating DNSSEC
post_data = {"api_rectify": True}
@@ -747,7 +775,7 @@ class Domain(db.Model):
"""
domain = Domain.query.filter(Domain.name == domain_name).first()
if domain:
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
try:
# Deactivate DNSSEC
jdata = utils.fetch_json(
@@ -806,7 +834,7 @@ class Domain(db.Model):
else:
return {'status': 'error', 'msg': 'This domain does not exist'}
def assoc_account(self, account_id):
def assoc_account(self, account_id, update=True):
"""
Associate domain with a domain, specified by account id
"""
@@ -821,7 +849,7 @@ class Domain(db.Model):
if not domain:
return {'status': False, 'msg': 'Domain does not exist'}
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
account_name = Account().get_name_by_id(account_id)
@@ -842,7 +870,8 @@ class Domain(db.Model):
current_app.logger.error(jdata['error'])
return {'status': 'error', 'msg': jdata['error']}
else:
self.update()
if update:
self.update()
msg_str = 'Account changed for domain {0} successfully'
current_app.logger.info(msg_str.format(domain_name))
return {'status': 'ok', 'msg': 'account changed successfully'}
@@ -879,3 +908,18 @@ class Domain(db.Model):
DomainUser.user_id == user_id,
AccountUser.user_id == user_id
)).filter(Domain.id == self.id).first()
# Return None if this domain does not exist as record,
# Return the parent domain that hold the record if exist
def is_overriding(self, domain_name):
upper_domain_name = '.'.join(domain_name.split('.')[1:])
while upper_domain_name != '':
if self.get_id_by_name(upper_domain_name.rstrip('.')) != None:
upper_domain = self.get_domain_info(upper_domain_name)
if 'rrsets' in upper_domain:
for r in upper_domain['rrsets']:
if domain_name.rstrip('.') in r['name'].rstrip('.'):
current_app.logger.error('Domain already exists as a record: {} under domain: {}'.format(r['name'].rstrip('.'), upper_domain_name))
return upper_domain_name
upper_domain_name = '.'.join(upper_domain_name.split('.')[1:])
return None

View File

@@ -99,7 +99,7 @@ class Record(object):
}
# Continue if the record is ready to be added
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
try:
jdata = utils.fetch_json(urljoin(
@@ -169,12 +169,12 @@ class Record(object):
record['record_data'] = record['record_data'].replace('[ZONE]', domain_name)
# Translate record name into punycode (IDN) as that's the only way
# to convey non-ascii records to the dns server
record['record_name'] = record['record_name'].encode('idna').decode()
record['record_name'] = utils.to_idna(record["record_name"], "encode")
#TODO: error handling
# If the record is an alias (CNAME), we will also make sure that
# the target domain is properly converted to punycode (IDN)
if record["record_type"] == 'CNAME':
record['record_data'] = record['record_data'].encode('idna').decode()
if record['record_type'] == 'CNAME' or record['record_type'] == 'SOA':
record['record_data'] = utils.to_idna(record['record_data'], 'encode')
#TODO: error handling
# If it is ipv6 reverse zone and PRETTY_IPV6_PTR is enabled,
# We convert ipv6 address back to reverse record format
@@ -293,7 +293,7 @@ class Record(object):
return new_rrsets, del_rrsets
def apply_rrsets(self, domain_name, rrsets):
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
jdata = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain_name)),
@@ -303,10 +303,48 @@ 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 {
# order matters: first deletions, then additions+changes
'rrsets': deletes + replaces
}
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 +353,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_rrests = del_rrsets
for r in del_rrsets["rrsets"]:
r['changetype'] = 'REPLACE'
rollback = self.apply_rrsets(domain_name, rollback_rrests)
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.')
@@ -500,7 +494,7 @@ class Record(object):
"""
Delete a record from domain
"""
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
data = {
"rrsets": [{
"name": self.name.rstrip('.') + '.',
@@ -562,7 +556,7 @@ class Record(object):
"""
Update single record
"""
headers = {'X-API-Key': self.PDNS_API_KEY}
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
data = {
"rrsets": [{

View File

@@ -28,7 +28,7 @@ class Setting(db.Model):
'allow_user_create_domain': False,
'allow_user_remove_domain': False,
'allow_user_view_history': False,
'delete_sso_accounts': False,
'delete_sso_accounts': False,
'bg_domain_updates': False,
'enable_api_rr_history': True,
'site_name': 'PowerDNS-Admin',
@@ -110,6 +110,7 @@ class Setting(db.Model):
'oidc_oauth_email': 'email',
'oidc_oauth_account_name_property': '',
'oidc_oauth_account_description_property': '',
'enforce_api_ttl': False,
'forward_records_allow_edit': {
'A': True,
'AAAA': True,
@@ -189,7 +190,11 @@ class Setting(db.Model):
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
'otp_field_enabled': True,
'custom_css': '',
'max_history_records': 1000
'otp_force': False,
'max_history_records': 1000,
'deny_domain_override': False,
'account_name_extra_chars': False,
'gravatar_enabled': False,
}
def __init__(self, id=None, name=None, value=None):
@@ -270,15 +275,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
@@ -286,7 +291,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() +

View File

@@ -8,6 +8,9 @@ import ldap.filter
from flask import current_app
from flask_login import AnonymousUserMixin
from sqlalchemy import orm
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from .base import db
from .role import Role
@@ -80,10 +83,7 @@ class User(db.Model):
return False
def get_id(self):
try:
return unicode(self.id) # python 2
except NameError:
return str(self.id) # python 3
return str(self.id)
def __repr__(self):
return '<User {0}>'.format(self.username)
@@ -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
@@ -107,9 +107,10 @@ 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):
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
hashed_password.encode('utf-8'))
if hasattr(self, "plain_text_password"):
if self.plain_text_password != None:
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
hashed_password.encode('utf-8'))
return False
def get_user_info_by_id(self):
@@ -125,7 +126,6 @@ class User(db.Model):
conn = ldap.initialize(Setting().get('ldap_uri'))
conn.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
conn.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
conn.set_option(ldap.OPT_X_TLS_DEMAND, True)
conn.set_option(ldap.OPT_DEBUG_LEVEL, 255)
conn.protocol_version = ldap.VERSION3
@@ -171,28 +171,6 @@ class User(db.Model):
current_app.logger.error(e)
return False
def ad_recursive_groups(self, groupDN):
"""
Recursively list groups belonging to a group. It will allow checking deep in the Active Directory
whether a user is allowed to enter or not
"""
LDAP_BASE_DN = Setting().get('ldap_base_dn')
groupSearchFilter = "(&(objectcategory=group)(member=%s))" % ldap.filter.escape_filter_chars(
groupDN)
result = [groupDN]
try:
groups = self.ldap_search(groupSearchFilter, LDAP_BASE_DN)
for group in groups:
result += [group[0][0]]
if 'memberOf' in group[0][1]:
for member in group[0][1]['memberOf']:
result += self.ad_recursive_groups(
member.decode("utf-8"))
return result
except ldap.LDAPError as e:
current_app.logger.exception("Recursive AD Group search error")
return result
def is_validate(self, method, src_ip='', trust_user=False):
"""
Validate user credential
@@ -304,7 +282,17 @@ class User(db.Model):
LDAP_USER_GROUP))
return False
elif LDAP_TYPE == 'ad':
user_ldap_groups = []
ldap_admin_group_filter, ldap_operator_group, ldap_user_group = "", "", ""
if LDAP_ADMIN_GROUP:
ldap_admin_group_filter = "(memberOf:1.2.840.113556.1.4.1941:={0})".format(LDAP_ADMIN_GROUP)
if LDAP_OPERATOR_GROUP:
ldap_operator_group = "(memberOf:1.2.840.113556.1.4.1941:={0})".format(LDAP_OPERATOR_GROUP)
if LDAP_USER_GROUP:
ldap_user_group = "(memberOf:1.2.840.113556.1.4.1941:={0})".format(LDAP_USER_GROUP)
searchFilter = "(&({0}={1})(|{2}{3}{4}))".format(LDAP_FILTER_USERNAME, self.username,
ldap_admin_group_filter,
ldap_operator_group, ldap_user_group)
ldap_result = self.ldap_search(searchFilter, LDAP_BASE_DN)
user_ad_member_of = ldap_result[0][0][1].get(
'memberOf')
@@ -314,26 +302,21 @@ class User(db.Model):
.format(self.username))
return False
for group in [
g.decode("utf-8")
for g in user_ad_member_of
]:
user_ldap_groups += self.ad_recursive_groups(
group)
user_ad_member_of = [g.decode("utf-8") for g in user_ad_member_of]
if (LDAP_ADMIN_GROUP in user_ldap_groups):
if (LDAP_ADMIN_GROUP in user_ad_member_of):
role_name = 'Administrator'
current_app.logger.info(
'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin'
.format(self.username,
LDAP_ADMIN_GROUP))
elif (LDAP_OPERATOR_GROUP in user_ldap_groups):
elif (LDAP_OPERATOR_GROUP in user_ad_member_of):
role_name = 'Operator'
current_app.logger.info(
'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin'
.format(self.username,
LDAP_OPERATOR_GROUP))
elif (LDAP_USER_GROUP in user_ldap_groups):
elif (LDAP_USER_GROUP in user_ad_member_of):
current_app.logger.info(
'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin'
.format(self.username,
@@ -439,8 +422,12 @@ class User(db.Model):
self.role_id = Role.query.filter_by(
name='Administrator').first().id
self.password = self.get_hashed_password(
self.plain_text_password) if self.plain_text_password else '*'
if hasattr(self, "plain_text_password"):
if self.plain_text_password != None:
self.password = self.get_hashed_password(
self.plain_text_password)
else:
self.password = '*'
if self.password and self.password != '*':
self.password = self.password.decode("utf-8")
@@ -476,9 +463,10 @@ class User(db.Model):
user.email = self.email
# store new password hash (only if changed)
if self.plain_text_password:
user.password = self.get_hashed_password(
self.plain_text_password).decode("utf-8")
if hasattr(self, "plain_text_password"):
if self.plain_text_password != None:
user.password = self.get_hashed_password(
self.plain_text_password).decode("utf-8")
db.session.commit()
return {'status': True, 'msg': 'User updated successfully'}
@@ -493,9 +481,11 @@ class User(db.Model):
user.firstname = self.firstname if self.firstname else user.firstname
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
if hasattr(self, "plain_text_password"):
if self.plain_text_password != None:
user.password = self.get_hashed_password(
self.plain_text_password).decode("utf-8")
if self.email:
# Can not update to a new email that
@@ -634,6 +624,13 @@ class User(db.Model):
accounts.append(q[1])
return accounts
def get_qrcode_value(self):
img = qrc.make(self.get_totp_uri(),
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue()
def read_entitlements(self, key):
"""
@@ -787,14 +784,11 @@ def get_role_names(roles):
"""
roles_list=[]
for role in roles:
roles_list.append(role.name)
roles_list.append(role.name)
return roles_list
def getUserInfo(DomainsOrAccounts):
current=[]
for DomainOrAccount in DomainsOrAccounts:
current.append(DomainOrAccount.name)
return current

View File

@@ -1,15 +1,19 @@
from .base import login_manager, handle_bad_request, handle_unauthorized_access, handle_access_forbidden, handle_page_not_found, handle_internal_server_error
from .base import (
csrf, login_manager, handle_bad_request, handle_unauthorized_access,
handle_access_forbidden, handle_page_not_found, handle_internal_server_error
)
from .index import index_bp
from .user import user_bp
from .dashboard import dashboard_bp
from .domain import domain_bp
from .admin import admin_bp
from .api import api_bp
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)
@@ -17,6 +21,7 @@ def init_app(app):
app.register_blueprint(domain_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(api_bp)
app.register_blueprint(apilist_bp)
app.register_error_handler(400, handle_bad_request)
app.register_error_handler(401, handle_unauthorized_access)

View File

@@ -23,6 +23,7 @@ from ..models.domain_template_record import DomainTemplateRecord
from ..models.api_key import ApiKey
from ..models.base import db
from ..lib.errors import ApiKeyCreateFail
from ..lib.schema import ApiPlainKeySchema
apikey_plain_schema = ApiPlainKeySchema(many=True)
@@ -43,10 +44,10 @@ change_type: "addition" or "deletion" or "status" for status change or "unchange
Note: A change in "content", is considered a deletion and recreation of the same record,
holding the new content value.
"""
def get_record_changes(del_rrest, add_rrest):
def get_record_changes(del_rrset, add_rrset):
changeSet = []
delSet = del_rrest['records'] if 'records' in del_rrest else []
addSet = add_rrest['records'] if 'records' in add_rrest else []
delSet = del_rrset['records'] if 'records' in del_rrset else []
addSet = add_rrset['records'] if 'records' in add_rrset else []
for d in delSet: # get the deletions and status changes
exists = False
for a in addSet:
@@ -86,44 +87,44 @@ def get_record_changes(del_rrest, add_rrest):
return changeSet
# out_changes is a list of HistoryRecordEntry objects in which we will append the new changes
# a HistoryRecordEntry represents a pair of add_rrest and del_rrest
# a HistoryRecordEntry represents a pair of add_rrset and del_rrset
def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_num, record_name=None, record_type=None):
if history_entry.detail is None:
return
if "add_rrests" in history_entry.detail:
detail_dict = json.loads(history_entry.detail.replace("\'", ''))
if "add_rrsets" in history_entry.detail:
detail_dict = json.loads(history_entry.detail)
else: # not a record entry
return
add_rrests = detail_dict['add_rrests']
del_rrests = detail_dict['del_rrests']
add_rrsets = detail_dict['add_rrsets']
del_rrsets = detail_dict['del_rrsets']
for add_rrest in add_rrests:
for add_rrset in add_rrsets:
exists = False
for del_rrest in del_rrests:
if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
for del_rrset in del_rrsets:
if del_rrset['name'] == add_rrset['name'] and del_rrset['type'] == add_rrset['type']:
exists = True
if change_num not in out_changes:
out_changes[change_num] = []
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, add_rrest, "*"))
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrset, add_rrset, "*"))
break
if not exists: # this is a new record
if change_num not in out_changes:
out_changes[change_num] = []
out_changes[change_num].append(HistoryRecordEntry(history_entry, [], add_rrest, "+")) # (add_rrest, del_rrest, change_type)
for del_rrest in del_rrests:
out_changes[change_num].append(HistoryRecordEntry(history_entry, [], add_rrset, "+")) # (add_rrset, del_rrset, change_type)
for del_rrset in del_rrsets:
exists = False
for add_rrest in add_rrests:
if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
for add_rrset in add_rrsets:
if del_rrset['name'] == add_rrset['name'] and del_rrset['type'] == add_rrset['type']:
exists = True # no need to add in the out_changes set
break
if not exists: # this is a deletion
if change_num not in out_changes:
out_changes[change_num] = []
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, [], "-"))
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrset, [], "-"))
# only used for changelog per record
@@ -133,9 +134,9 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n
else:
return
for hre in changes_i: # for each history record entry in changes_i
if 'type' in hre.add_rrest and hre.add_rrest['name'] == record_name and hre.add_rrest['type'] == record_type:
if 'type' in hre.add_rrset and hre.add_rrset['name'] == record_name and hre.add_rrset['type'] == record_type:
continue
elif 'type' in hre.del_rrest and hre.del_rrest['name'] == record_name and hre.del_rrest['type'] == record_type:
elif 'type' in hre.del_rrset and hre.del_rrset['name'] == record_name and hre.del_rrset['type'] == record_type:
continue
else:
out_changes[change_num].remove(hre)
@@ -144,42 +145,42 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n
# records with same (name,type) are considered as a single HistoryRecordEntry
# history_entry is of type History - used to extract created_by and created_on
# add_rrest is a dictionary of replace
# del_rrest is a dictionary of remove
# add_rrset is a dictionary of replace
# del_rrset is a dictionary of remove
class HistoryRecordEntry:
def __init__(self, history_entry, del_rrest, add_rrest, change_type):
# search the add_rrest index into the add_rrest set for the key (name, type)
def __init__(self, history_entry, del_rrset, add_rrset, change_type):
# search the add_rrset index into the add_rrset set for the key (name, type)
self.history_entry = history_entry
self.add_rrest = add_rrest
self.del_rrest = del_rrest
self.add_rrset = add_rrset
self.del_rrset = del_rrset
self.change_type = change_type # "*": edit or unchanged, "+" new tuple(name,type), "-" deleted (name,type) tuple
self.changed_fields = [] # contains a subset of : [ttl, name, type]
self.changeSet = [] # all changes for the records of this add_rrest-del_rrest pair
self.changeSet = [] # all changes for the records of this add_rrset-del_rrset pair
if change_type == "+": # addition
self.changed_fields.append("name")
self.changed_fields.append("type")
self.changed_fields.append("ttl")
self.changeSet = get_record_changes(del_rrest, add_rrest)
self.changeSet = get_record_changes(del_rrset, add_rrset)
elif change_type == "-": # removal
self.changed_fields.append("name")
self.changed_fields.append("type")
self.changed_fields.append("ttl")
self.changeSet = get_record_changes(del_rrest, add_rrest)
self.changeSet = get_record_changes(del_rrset, add_rrset)
elif change_type == "*": # edit of unchanged
if add_rrest['ttl'] != del_rrest['ttl']:
if add_rrset['ttl'] != del_rrset['ttl']:
self.changed_fields.append("ttl")
self.changeSet = get_record_changes(del_rrest, add_rrest)
self.changeSet = get_record_changes(del_rrset, add_rrset)
def toDict(self):
return {
"add_rrest" : self.add_rrest,
"del_rrest" : self.del_rrest,
"add_rrset" : self.add_rrset,
"del_rrset" : self.del_rrset,
"changed_fields" : self.changed_fields,
"created_on" : self.history_entry.created_on,
"created_by" : self.history_entry.created_by,
@@ -362,13 +363,13 @@ def edit_key(key_id=None):
current_app.logger.error('Error: {0}'.format(e))
history = History(msg=history_message,
detail=str({
'key': apikey.id,
'role': apikey.role.name,
'description': apikey.description,
'domains': [domain.name for domain in apikey.domains],
'accounts': [a.name for a in apikey.accounts]
}),
detail = json.dumps({
'key': apikey.id,
'role': apikey.role.name,
'description': apikey.description,
'domains': [domain.name for domain in apikey.domains],
'accounts': [a.name for a in apikey.accounts]
}),
created_by=current_user.username)
history.add()
@@ -411,12 +412,12 @@ def manage_keys():
current_app.logger.info('Delete API key {0}'.format(apikey.id))
history = History(msg='Delete API key {0}'.format(apikey.id),
detail=str({
'key': history_apikey_id,
'role': history_apikey_role,
'description': history_apikey_description,
'domains': history_apikey_domains
}),
detail = json.dumps({
'key': history_apikey_id,
'role': history_apikey_role,
'description': history_apikey_description,
'domains': history_apikey_domains
}),
created_by=current_user.username)
history.add()
@@ -763,10 +764,7 @@ class DetailedHistory():
self.detailed_msg = ""
return
if 'add_rrest' in history.detail:
detail_dict = json.loads(history.detail.replace("\'", ''))
else:
detail_dict = json.loads(history.detail.replace("'", '"'))
detail_dict = json.loads(history.detail)
if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
self.detailed_msg = render_template_string("""
@@ -806,7 +804,7 @@ class DetailedHistory():
authenticator=detail_dict['authenticator'],
ip_address=detail_dict['ip_address'])
elif 'add_rrests' in detail_dict: # this is a domain record change
elif 'add_rrsets' in detail_dict: # this is a domain record change
# changes_set = []
self.detailed_msg = ""
# extract_changelogs_from_a_history_entry(changes_set, history, 0)
@@ -883,6 +881,16 @@ class DetailedHistory():
domain_type=DetailedHistory.get_key_val(detail_dict, "domain_type"),
domain_master_ips=DetailedHistory.get_key_val(detail_dict, "domain_master_ips"))
elif DetailedHistory.get_key_val(detail_dict, 'msg') and DetailedHistory.get_key_val(detail_dict, 'status'):
self.detailed_msg = render_template_string('''
<table class="table table-bordered table-striped">
<tr><td>Status: </td><td>{{ history_status }}</td></tr>
<tr><td>Message:</td><td>{{ history_msg }}</td></tr>
</table>
''',
history_status=DetailedHistory.get_key_val(detail_dict, 'status'),
history_msg=DetailedHistory.get_key_val(detail_dict, 'msg'))
# check for lower key as well for old databases
@staticmethod
def get_key_val(_dict, key):
@@ -895,7 +903,7 @@ def convert_histories(histories):
detailedHistories = []
j = 0
for i in range(len(histories)):
if histories[i].detail and ('add_rrests' in histories[i].detail or 'del_rrests' in histories[i].detail):
if histories[i].detail and ('add_rrsets' in histories[i].detail or 'del_rrsets' in histories[i].detail):
extract_changelogs_from_a_history_entry(changes_set, histories[i], j)
if j in changes_set:
detailedHistories.append(DetailedHistory(histories[i], changes_set[j]))
@@ -1251,20 +1259,41 @@ def history_table(): # ajax call data
@login_required
@operator_role_required
def setting_basic():
if request.method == 'GET':
settings = [
'maintenance', 'fullscreen_layout', 'record_helper',
'login_ldap_first', 'default_record_table_size',
'default_domain_table_size', 'auto_ptr', 'record_quick_edit',
'pretty_ipv6_ptr', 'dnssec_admins_only',
'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'
settings = [
'account_name_extra_chars',
'allow_user_create_domain',
'allow_user_remove_domain',
'allow_user_view_history',
'auto_ptr',
'bg_domain_updates',
'custom_css',
'default_domain_table_size',
'default_record_table_size',
'delete_sso_accounts',
'deny_domain_override',
'dnssec_admins_only',
'enable_api_rr_history',
'enforce_api_ttl',
'fullscreen_layout',
'gravatar_enabled',
'login_ldap_first',
'maintenance',
'max_history_records',
'otp_field_enabled',
'otp_force',
'pdns_api_timeout',
'pretty_ipv6_ptr',
'record_helper',
'record_quick_edit',
'session_timeout',
'site_name',
'ttl_options',
'verify_ssl_connections',
'verify_user_email',
'warn_session_timeout',
]
]
return render_template('admin_setting_basic.html', settings=settings)
return render_template('admin_setting_basic.html', settings=settings)
@admin_bp.route('/setting/basic/<path:setting>/edit', methods=['POST'])
@@ -1664,10 +1693,10 @@ def create_template():
result = t.create()
if result['status'] == 'ok':
history = History(msg='Add domain template {0}'.format(name),
detail=str({
'name': name,
'description': description
}),
detail = json.dumps({
'name': name,
'description': description
}),
created_by=current_user.username)
history.add()
return redirect(url_for('admin.templates'))
@@ -1711,10 +1740,10 @@ def create_template_from_zone():
result = t.create()
if result['status'] == 'ok':
history = History(msg='Add domain template {0}'.format(name),
detail=str({
'name': name,
'description': description
}),
detail = json.dumps({
'name': name,
'description': description
}),
created_by=current_user.username)
history.add()
@@ -1844,7 +1873,7 @@ def apply_records(template):
history = History(
msg='Apply domain template record changes to domain template {0}'
.format(template),
detail=str(json.dumps(jdata)),
detail = json.dumps(jdata),
created_by=current_user.username)
history.add()
return make_response(jsonify(result), 200)
@@ -1874,7 +1903,7 @@ def delete_template(template):
if result['status'] == 'ok':
history = History(
msg='Deleted domain template {0}'.format(template),
detail=str({'name': template}),
detail = json.dumps({'name': template}),
created_by=current_user.username)
history.add()
return redirect(url_for('admin.templates'))

View File

@@ -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,
@@ -23,19 +24,20 @@ 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,
apikey_can_create_domain, apikey_can_remove_domain,
apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
api_role_can, apikey_or_basic_auth,
callback_if_request_body_contains_key,
callback_if_request_body_contains_key, allowed_record_types, allowed_record_ttl
)
import secrets
import string
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
apilist_bp = Blueprint('apilist', __name__, url_prefix='/')
apikey_schema = ApiKeySchema(many=True)
apikey_single_schema = ApiKeySchema()
@@ -47,6 +49,7 @@ user_detailed_schema = UserDetailedSchema()
account_schema = AccountSchema(many=True)
account_single_schema = AccountSchema()
def get_user_domains():
domains = db.session.query(Domain) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
@@ -177,9 +180,15 @@ def before_request():
}))
@apilist_bp.route('/api', methods=['GET'])
def index():
return '[{"url": "/api/v1", "version": 1}]', 200
@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')
@@ -188,6 +197,7 @@ def api_login_create_zone():
api_full_uri = api_uri_with_prefix + '/servers/localhost/zones'
headers = {}
headers['X-API-Key'] = pdns_api_key
headers['Content-Type'] = 'application/json'
msg_str = "Sending request to powerdns API {0}"
msg = msg_str.format(request.get_json(force=True))
@@ -247,6 +257,7 @@ def api_login_list_zones():
@api_bp.route('/pdnsadmin/zones/<string:domain_name>', 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')
@@ -287,13 +298,12 @@ def api_login_delete_zone(domain_name):
domain.update()
history = History(msg='Delete domain {0}'.format(
pretty_domain_name(domain_name)),
utils.pretty_domain_name(domain_name)),
detail='',
created_by=current_user.username,
domain_id=domain_id)
history.add()
except Exception as e:
current_app.logger.error('Error: {0}'.format(e))
abort(500)
@@ -303,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
@@ -459,6 +470,7 @@ def api_get_apikey(apikey_id):
@api_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', methods=['DELETE'])
@api_basic_auth
@csrf.exempt
def api_delete_apikey(apikey_id):
apikey = ApiKey.query.get(apikey_id)
@@ -496,6 +508,7 @@ def api_delete_apikey(apikey_id):
@api_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', 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
@@ -657,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
@@ -730,6 +744,7 @@ def api_create_user():
@api_bp.route('/pdnsadmin/users/<int:user_id>', methods=['PUT'])
@api_basic_auth
@api_role_can('update users', allow_self=True)
@csrf.exempt
def api_update_user(user_id):
"""
Update existing user
@@ -802,6 +817,7 @@ def api_update_user(user_id):
@api_bp.route('/pdnsadmin/users/<int:user_id>', 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:
@@ -853,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)
@@ -863,12 +880,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)
@@ -894,6 +914,7 @@ def api_create_account():
@api_bp.route('/pdnsadmin/accounts/<int:account_id>', 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
@@ -906,8 +927,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"
@@ -934,12 +956,25 @@ def api_update_account(account_id):
@api_bp.route('/pdnsadmin/accounts/<int:account_id>', 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:
account = account_list[0]
else:
abort(404)
current_app.logger.debug(
f'Deleting Account {account.name}'
)
# Remove account association from domains first
if len(account.domains) > 0:
for domain in account.domains:
current_app.logger.info(f"Disassociating domain {domain.name} with {account.name}")
Domain(name=domain.name).assoc_account(None, update=False)
current_app.logger.info("Syncing all domains")
Domain().update()
current_app.logger.debug(
"Deleting account {} ({})".format(account_id, account.name))
result = account.delete_account()
@@ -973,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:
@@ -1000,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:
@@ -1031,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()
@@ -1042,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()
@@ -1052,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()
@@ -1060,11 +1100,14 @@ def api_zone_subpath_forward(server_id, zone_id, subpath):
@api_bp.route('/servers/<string:server_id>/zones/<string:zone_id>',
methods=['GET', 'PUT', 'PATCH', 'DELETE'])
@apikey_auth
@allowed_record_types
@allowed_record_ttl
@apikey_can_access_domain
@apikey_can_remove_domain(http_methods=['DELETE'])
@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'):
@@ -1074,7 +1117,7 @@ def api_zone_forward(server_id, zone_id):
if 200 <= status < 300:
current_app.logger.debug("Request to powerdns API successful")
if Setting().get('enable_api_rr_history'):
if request.method in ['POST', 'PATCH'] :
if request.method in ['POST', 'PATCH']:
data = request.get_json(force=True)
for rrset_data in data['rrsets']:
history = History(msg='{0} zone {1} record of {2}'.format(
@@ -1102,6 +1145,7 @@ def api_zone_forward(server_id, zone_id):
@api_bp.route('/servers/<path:subpath>', 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()
@@ -1110,6 +1154,7 @@ def api_server_sub_forward(subpath):
@api_bp.route('/servers/<string:server_id>/zones', methods=['POST'])
@apikey_auth
@apikey_can_create_domain
@csrf.exempt
def api_create_zone(server_id):
resp = helper.forward_request()
@@ -1147,8 +1192,10 @@ def api_get_zones(server_id):
return jsonify(domain_schema.dump(domain_obj_list)), 200
else:
resp = helper.forward_request()
if (g.apikey.role.name not in ['Administrator', 'Operator']
and resp.status_code == 200):
if (
g.apikey.role.name not in ['Administrator', 'Operator']
and resp.status_code == 200
):
domain_list = [d['name']
for d in domain_schema.dump(g.apikey.domains)]
@@ -1169,16 +1216,35 @@ def api_server_forward():
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@api_bp.route('/servers/<string:server_id>', methods=['GET'])
@apikey_auth
def api_server_config_forward(server_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
# The endpoint to snychronize Domains in background
# The endpoint to synchronize Domains in background
@api_bp.route('/sync_domains', methods=['GET'])
@apikey_or_basic_auth
def sync_domains():
domain = Domain()
domain.update()
return 'Finished synchronization in background', 200
@api_bp.route('/health', methods=['GET'])
@apikey_auth
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)
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)
return make_response("Up", 200)

View File

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

View File

@@ -10,6 +10,7 @@ from flask_login import login_required, current_user, login_manager
from ..lib.utils import pretty_domain_name
from ..lib.utils import pretty_json
from ..lib.utils import to_idna
from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec, can_remove_domain
from ..models.user import User, Anonymous
from ..models.account import Account
@@ -31,7 +32,6 @@ domain_bp = Blueprint('domain',
template_folder='templates',
url_prefix='/domain')
@domain_bp.before_request
def before_request():
# Check if user is anonymous
@@ -63,7 +63,7 @@ def domain(domain_name):
# Query domain's rrsets from PowerDNS API
rrsets = Record().get_rrsets(domain.name)
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets)))
# API server might be down, misconfigured
if not rrsets and domain.type != 'Slave':
@@ -202,7 +202,7 @@ def changelog(domain_name):
# Query domain's rrsets from PowerDNS API
rrsets = Record().get_rrsets(domain.name)
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets)))
# API server might be down, misconfigured
if not rrsets and domain.type != 'Slave':
@@ -277,7 +277,7 @@ def changelog(domain_name):
"""
Returns a changelog for a specific pair of (record_name, record_type)
"""
@domain_bp.route('/<path:domain_name>/changelog/<path:record_name>-<path:record_type>', methods=['GET'])
@domain_bp.route('/<path:domain_name>/changelog/<path:record_name>/<string:record_type>', methods=['GET'])
@login_required
@can_access_domain
@history_access_required
@@ -290,7 +290,7 @@ def record_changelog(domain_name, record_name, record_type):
abort(404)
# Query domain's rrsets from PowerDNS API
rrsets = Record().get_rrsets(domain.name)
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets)))
# API server might be down, misconfigured
if not rrsets and domain.type != 'Slave':
@@ -328,9 +328,9 @@ def record_changelog(domain_name, record_name, record_type):
for change_num in changes_set_of_record:
changes_i = changes_set_of_record[change_num]
for hre in changes_i: # for each history record entry in changes_i
if 'type' in hre.add_rrest and hre.add_rrest['name'] == record_name and hre.add_rrest['type'] == record_type:
if 'type' in hre.add_rrset and hre.add_rrset['name'] == record_name and hre.add_rrset['type'] == record_type:
continue
elif 'type' in hre.del_rrest and hre.del_rrest['name'] == record_name and hre.del_rrest['type'] == record_type:
elif 'type' in hre.del_rrset and hre.del_rrset['name'] == record_name and hre.del_rrset['type'] == record_type:
continue
else:
changes_set_of_record[change_num].remove(hre)
@@ -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
@@ -379,7 +382,7 @@ def add():
# Encode domain name into punycode (IDN)
try:
domain_name = domain_name.encode('idna').decode()
domain_name = to_idna(domain_name, 'encode')
except:
current_app.logger.error("Cannot encode the domain name {}".format(domain_name))
current_app.logger.debug(traceback.format_exc())
@@ -400,6 +403,38 @@ def add():
account_name = Account().get_name_by_id(account_id)
d = Domain()
### Test if a record same as the domain already exists in an upper level domain
if Setting().get('deny_domain_override'):
upper_domain = None
domain_override = False
domain_override_toggle = False
if current_user.role.name in ['Administrator', 'Operator']:
domain_override = request.form.get('domain_override')
domain_override_toggle = True
# If overriding box is not selected.
# False = Do not allow ovrriding, perform checks
# True = Allow overriding, do not perform checks
if not domain_override:
upper_domain = d.is_overriding(domain_name)
if upper_domain:
if current_user.role.name in ['Administrator', 'Operator']:
accounts = Account.query.order_by(Account.name).all()
else:
accounts = current_user.get_accounts()
msg = 'Domain already exists as a record under domain: {}'.format(upper_domain)
return render_template('domain_add.html',
domain_override_message=msg,
accounts=accounts,
domain_override_toggle=domain_override_toggle)
result = d.add(domain_name=domain_name,
domain_type=domain_type,
soa_edit_api=soa_edit_api,
@@ -409,7 +444,7 @@ def add():
domain_id = Domain().get_id_by_name(domain_name)
history = History(msg='Add domain {0}'.format(
pretty_domain_name(domain_name)),
detail=str({
detail = json.dumps({
'domain_type': domain_type,
'domain_master_ips': domain_master_ips,
'account_id': account_id
@@ -445,17 +480,16 @@ def add():
history = History(
msg='Applying template {0} to {1} successfully.'.
format(template.name, domain_name),
detail=str(
json.dumps({
"domain":
detail = json.dumps({
'domain':
domain_name,
"template":
'template':
template.name,
"add_rrests":
'add_rrsets':
result['data'][0]['rrsets'],
"del_rrests":
'del_rrsets':
result['data'][1]['rrsets']
})),
}),
created_by=current_user.username,
domain_id=domain_id)
history.add()
@@ -464,7 +498,7 @@ def add():
msg=
'Failed to apply template {0} to {1}.'
.format(template.name, domain_name),
detail=str(result),
detail = json.dumps(result),
created_by=current_user.username)
history.add()
return redirect(url_for('dashboard.dashboard'))
@@ -478,14 +512,17 @@ def add():
# Get
else:
domain_override_toggle = False
# Admins and Operators can set to any account
if current_user.role.name in ['Administrator', 'Operator']:
accounts = Account.query.order_by(Account.name).all()
domain_override_toggle = True
else:
accounts = current_user.get_accounts()
return render_template('domain_add.html',
templates=templates,
accounts=accounts)
accounts=accounts,
domain_override_toggle=domain_override_toggle)
@@ -545,7 +582,7 @@ def setting(domain_name):
history = History(
msg='Change domain {0} access control'.format(
pretty_domain_name(domain_name)),
detail=str({'user_has_access': new_user_list}),
detail=json.dumps({'user_has_access': new_user_list}),
created_by=current_user.username,
domain_id=d.id)
history.add()
@@ -583,7 +620,7 @@ def change_type(domain_name):
if status['status'] == 'ok':
history = History(msg='Update type for domain {0}'.format(
pretty_domain_name(domain_name)),
detail=str({
detail=json.dumps({
"domain": domain_name,
"type": domain_type,
"masters": domain_master_ips
@@ -617,9 +654,9 @@ def change_soa_edit_api(domain_name):
history = History(
msg='Update soa_edit_api for domain {0}'.format(
pretty_domain_name(domain_name)),
detail=str({
"domain": domain_name,
"soa_edit_api": new_setting
detail = json.dumps({
'domain': domain_name,
'soa_edit_api': new_setting
}),
created_by=current_user.username,
domain_id=d.get_id_by_name(domain_name))
@@ -684,12 +721,11 @@ def record_apply(domain_name):
if result['status'] == 'ok':
history = History(
msg='Apply record changes to domain {0}'.format(pretty_domain_name(domain_name)),
detail=str(
json.dumps({
"domain": domain_name,
"add_rrests": result['data'][0]['rrsets'],
"del_rrests": result['data'][1]['rrsets']
})),
detail = json.dumps({
'domain': domain_name,
'add_rrsets': result['data'][0]['rrsets'],
'del_rrsets': result['data'][1]['rrsets']
}),
created_by=current_user.username,
domain_id=domain.id)
history.add()
@@ -698,11 +734,10 @@ def record_apply(domain_name):
history = History(
msg='Failed to apply record changes to domain {0}'.format(
pretty_domain_name(domain_name)),
detail=str(
json.dumps({
"domain": domain_name,
"msg": result['msg'],
})),
detail = json.dumps({
'domain': domain_name,
'msg': result['msg'],
}),
created_by=current_user.username)
history.add()
return make_response(jsonify(result), 400)
@@ -780,6 +815,12 @@ def dnssec(domain_name):
def dnssec_enable(domain_name):
domain = Domain()
dnssec = domain.enable_domain_dnssec(domain_name)
domain_object = Domain.query.filter(domain_name == Domain.name).first()
history = History(
msg='DNSSEC was enabled for domain ' + domain_name ,
created_by=current_user.username,
domain_id=domain_object.id)
history.add()
return make_response(jsonify(dnssec), 200)
@@ -793,7 +834,12 @@ def dnssec_disable(domain_name):
for key in dnssec['dnssec']:
domain.delete_dnssec_key(domain_name, key['id'])
domain_object = Domain.query.filter(domain_name == Domain.name).first()
history = History(
msg='DNSSEC was disabled for domain ' + domain_name ,
created_by=current_user.username,
domain_id=domain_object.id)
history.add()
return make_response(jsonify({'status': 'ok', 'msg': 'DNSSEC removed.'}))

View File

@@ -4,13 +4,13 @@ import json
import traceback
import datetime
import ipaddress
import base64
from distutils.util import strtobool
from yaml import Loader, load
from onelogin.saml2.utils import OneLogin_Saml2_Utils
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
@@ -84,7 +84,6 @@ def index():
@index_bp.route('/ping', methods=['GET'])
@login_required
def ping():
return make_response('ok')
@@ -167,10 +166,8 @@ def login():
return redirect(url_for('index.login'))
session['user_id'] = user.id
login_user(user, remember=False)
session['authentication_type'] = 'OAuth'
signin_history(user.username, 'Google OAuth', True)
return redirect(url_for('index.index'))
return authenticate_user(user, 'Google OAuth')
if 'github_token' in session:
me = json.loads(github.get('user').text)
@@ -195,9 +192,7 @@ def login():
session['user_id'] = user.id
session['authentication_type'] = 'OAuth'
login_user(user, remember=False)
signin_history(user.username, 'Github OAuth', True)
return redirect(url_for('index.index'))
return authenticate_user(user, 'Github OAuth')
if 'azure_token' in session:
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
@@ -329,14 +324,15 @@ def login():
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)
# check if user has permissions
account_users = account.get_user()
current_app.logger.info('Group: {} Users: {}'.format(
group_name,
group_name,
account_users))
if user.id in account_users:
current_app.logger.info('User id {} is already in account {}'.format(
@@ -350,13 +346,15 @@ 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),
account.name),
created_by='System')
history.add()
@@ -366,10 +364,7 @@ def login():
history.add()
current_app.logger.warning('group info: {} '.format(account_id))
login_user(user, remember=False)
signin_history(user.username, 'Azure OAuth', True)
return redirect(url_for('index.index'))
return authenticate_user(user, 'Azure OAuth')
if 'oidc_token' in session:
me = json.loads(oidc.get('userinfo').text)
@@ -409,7 +404,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 = ''
@@ -419,7 +414,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:
@@ -433,9 +428,7 @@ def login():
session['user_id'] = user.id
session['authentication_type'] = 'OAuth'
login_user(user, remember=False)
signin_history(user.username, 'OIDC OAuth', True)
return redirect(url_for('index.index'))
return authenticate_user(user, 'OIDC OAuth')
if request.method == 'GET':
return render_template('login.html', saml_enabled=SAML_ENABLED)
@@ -469,7 +462,7 @@ def login():
auth = user.is_validate(method=auth_method,
src_ip=request.remote_addr)
if auth == False:
signin_history(user.username, 'LOCAL', False)
signin_history(user.username, auth_method, False)
return render_template('login.html',
saml_enabled=SAML_ENABLED,
error='Invalid credentials')
@@ -486,7 +479,7 @@ def login():
if otp_token and otp_token.isdigit():
good_token = user.verify_totp(otp_token)
if not good_token:
signin_history(user.username, 'LOCAL', False)
signin_history(user.username, auth_method, False)
return render_template('login.html',
saml_enabled=SAML_ENABLED,
error='Invalid credentials')
@@ -495,13 +488,13 @@ def login():
saml_enabled=SAML_ENABLED,
error='Token required')
if Setting().get('autoprovisioning') and auth_method!='LOCAL':
if Setting().get('autoprovisioning') and auth_method!='LOCAL':
urn_value=Setting().get('urn_value')
Entitlements=user.read_entitlements(Setting().get('autoprovisioning_attribute'))
if len(Entitlements)==0 and Setting().get('purge'):
user.set_role("User")
user.revoke_privilege(True)
elif len(Entitlements)!=0:
if checkForPDAEntries(Entitlements, urn_value):
user.updateUser(Entitlements)
@@ -512,9 +505,7 @@ def login():
user.revoke_privilege(True)
current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' )
login_user(user, remember=remember_me)
signin_history(user.username, 'LOCAL', True)
return redirect(session.get('next', url_for('index.index')))
return authenticate_user(user, auth_method, remember_me)
def checkForPDAEntries(Entitlements, urn_value):
"""
@@ -561,12 +552,12 @@ def signin_history(username, authenticator, success):
# Write history
History(msg='User {} authentication {}'.format(username, str_success),
detail=str({
"username": username,
"authenticator": authenticator,
"ip_address": request_ip,
"success": 1 if success else 0
}),
detail = json.dumps({
'username': username,
'authenticator': authenticator,
'ip_address': request_ip,
'success': 1 if success else 0
}),
created_by='System').add()
# Get a list of Azure security groups the user is a member of
@@ -584,6 +575,23 @@ def get_azure_groups(uri):
mygroups = []
return mygroups
# Handle user login, write history and, if set, handle showing the register_otp QR code.
# if Setting for OTP on first login is enabled, and OTP field is also enabled,
# but user isn't using it yet, enable OTP, get QR code and display it, logging the user out.
def authenticate_user(user, authenticator, remember=False):
login_user(user, remember=remember)
signin_history(user.username, authenticator, True)
if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret:
user.update_profile(enable_otp=True)
user_id = current_user.id
prepare_welcome_user(user_id)
return redirect(url_for('index.welcome'))
return redirect(url_for('index.login'))
# Prepare user to enter /welcome screen, otherwise they won't have permission to do so
def prepare_welcome_user(user_id):
logout_user()
session['welcome_user_id'] = user_id
@index_bp.route('/logout')
def logout():
@@ -674,7 +682,12 @@ def register():
if result and result['status']:
if Setting().get('verify_user_email'):
send_account_verification(email)
return redirect(url_for('index.login'))
if Setting().get('otp_force') and Setting().get('otp_field_enabled'):
user.update_profile(enable_otp=True)
prepare_welcome_user(user.id)
return redirect(url_for('index.welcome'))
else:
return redirect(url_for('index.login'))
else:
return render_template('register.html',
error=result['msg'])
@@ -684,6 +697,28 @@ def register():
return render_template('errors/404.html'), 404
# Show welcome page on first login if otp_force is enabled
@index_bp.route('/welcome', methods=['GET', 'POST'])
def welcome():
if 'welcome_user_id' not in session:
return redirect(url_for('index.index'))
user = User(id=session['welcome_user_id'])
encoded_img_data = base64.b64encode(user.get_qrcode_value())
if request.method == 'GET':
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user)
elif request.method == 'POST':
otp_token = request.form.get('otptoken', '')
if otp_token and otp_token.isdigit():
good_token = user.verify_totp(otp_token)
if not good_token:
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Invalid token")
else:
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Token required")
session.pop('welcome_user_id')
return redirect(url_for('index.index'))
@index_bp.route('/confirm/<token>', methods=['GET'])
def confirm_email(token):
email = confirm_token(token)
@@ -729,6 +764,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',
@@ -737,6 +773,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:
@@ -804,7 +841,7 @@ def dyndns_update():
remote_addr = utils.validate_ipaddress(
request.headers.get('X-Forwarded-For',
request.remote_addr).split(', ')[:1])
request.remote_addr).split(', ')[0])
response = 'nochg'
for ip in myip_addr or remote_addr:
@@ -831,13 +868,13 @@ def dyndns_update():
if result['status'] == 'ok':
history = History(
msg='DynDNS update: updated {} successfully'.format(hostname),
detail=str({
"domain": domain.name,
"record": hostname,
"type": rtype,
"old_value": oldip,
"new_value": str(ip)
}),
detail = json.dumps({
'domain': domain.name,
'record': hostname,
'type': rtype,
'old_value': oldip,
'new_value': str(ip)
}),
created_by=current_user.username,
domain_id=domain.id)
history.add()
@@ -873,11 +910,11 @@ def dyndns_update():
msg=
'DynDNS update: created record {0} in zone {1} successfully'
.format(hostname, domain.name, str(ip)),
detail=str({
"domain": domain.name,
"record": hostname,
"value": str(ip)
}),
detail = json.dumps({
'domain': domain.name,
'record': hostname,
'value': str(ip)
}),
created_by=current_user.username,
domain_id=domain.id)
history.add()
@@ -898,6 +935,7 @@ def dyndns_update():
def saml_login():
if not current_app.config.get('SAML_ENABLED'):
abort(400)
from onelogin.saml2.utils import OneLogin_Saml2_Utils
req = saml.prepare_flask_request(request)
auth = saml.init_saml_auth(req)
redirect_url = OneLogin_Saml2_Utils.get_self_url(req) + url_for(
@@ -910,7 +948,7 @@ def saml_metadata():
if not current_app.config.get('SAML_ENABLED'):
current_app.logger.error("SAML authentication is disabled.")
abort(400)
from onelogin.saml2.utils import OneLogin_Saml2_Utils
req = saml.prepare_flask_request(request)
auth = saml.init_saml_auth(req)
settings = auth.get_settings()
@@ -926,11 +964,13 @@ 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'):
current_app.logger.error("SAML authentication is disabled.")
abort(400)
from onelogin.saml2.utils import OneLogin_Saml2_Utils
req = saml.prepare_flask_request(request)
auth = saml.init_saml_auth(req)
auth.process_response()
@@ -974,6 +1014,8 @@ def saml_authorized():
None)
admin_group_name = current_app.config.get('SAML_GROUP_ADMIN_NAME',
None)
operator_group_name = current_app.config.get('SAML_GROUP_OPERATOR_NAME',
None)
group_to_account_mapping = create_group_to_account_mapping()
if email_attribute_name in session['samlUserdata']:
@@ -1027,6 +1069,8 @@ def saml_authorized():
uplift_to_admin(user)
elif admin_group_name in user_groups:
uplift_to_admin(user)
elif operator_group_name in user_groups:
uplift_to_operator(user)
elif admin_attribute_name or group_attribute_name:
if user.role.name != 'User':
user.role_id = Role.query.filter_by(name='User').first().id
@@ -1037,9 +1081,7 @@ def saml_authorized():
user.plain_text_password = None
user.update_profile()
session['authentication_type'] = 'SAML'
login_user(user, remember=False)
signin_history(user.username, 'SAML', True)
return redirect(url_for('index.login'))
return authenticate_user(user, 'SAML')
else:
return render_template('errors/SAML.html', errors=errors)
@@ -1056,14 +1098,10 @@ def create_group_to_account_mapping():
def handle_account(account_name, account_description=""):
clean_name = ''.join(c for c in account_name.lower()
if c in "abcdefghijklmnopqrstuvwxyz0123456789")
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='')
@@ -1085,6 +1123,14 @@ def uplift_to_admin(user):
created_by='SAML Assertion')
history.add()
def uplift_to_operator(user):
if user.role.name != 'Operator':
user.role_id = Role.query.filter_by(name='Operator').first().id
history = History(msg='Promoting {0} to operator'.format(
user.username),
created_by='SAML Assertion')
history.add()
@index_bp.route('/saml/sls')
def saml_logout():

View File

@@ -1,8 +1,10 @@
import datetime
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
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
from flask_login import current_user, login_required, login_manager
from ..models.user import User, Anonymous
@@ -94,13 +96,60 @@ def qrcode():
if not current_user:
return redirect(url_for('index'))
img = qrc.make(current_user.get_totp_uri(),
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue(), 200, {
return current_user.get_qrcode_value(), 200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
@user_bp.route('/image', methods=['GET'])
@login_required
def image():
"""Returns the user profile image or avatar."""
@after_this_request
def add_cache_headers(response_):
"""When the response is ok, add cache headers."""
if 200 <= response_.status_code <= 399:
response_.cache_control.private = True
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()
url = f'https://s.gravatar.com/avatar/{hash_}?s=100'
current_app.logger.debug('Redirect user image request to gravatar')
return redirect(url, 307)
# Fallback to the local default image
return current_app.send_static_file('img/user_image.png')

View File

@@ -72,8 +72,9 @@ class SAML(object):
def prepare_flask_request(self, request):
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
url_data = urlparse(request.url)
proto = request.headers.get('HTTP_X_FORWARDED_PROTO', request.scheme)
return {
'https': 'on' if request.scheme == 'https' else 'off',
'https': 'on' if proto == 'https' else 'off',
'http_host': request.host,
'server_port': url_data.port,
'script_name': request.path,
@@ -163,7 +164,8 @@ class SAML(object):
'signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
settings['security']['wantAssertionsEncrypted'] = current_app.config.get(
'SAML_ASSERTION_ENCRYPTED', True)
settings['security']['wantAttributeStatement'] = True
settings['security']['wantAttributeStatement'] = current_app.config.get(
'SAML_WANT_ATTRIBUTE_STATEMENT', True)
settings['security']['wantNameId'] = True
settings['security']['authnRequestsSigned'] = current_app.config[
'SAML_SIGN_REQUEST']

View File

@@ -42,15 +42,6 @@ table td {
background-position: center;
}
.navbar-nav>.user-menu>.dropdown-menu>li.user-header>img.img-circle.offline {
filter: brightness(0);
border-color: black;
}
.navbar-nav>.user-menu .user-image.offline {
filter: brightness(0);
}
.search-input {
width: 100%;
}

View File

@@ -12,13 +12,7 @@ function applyChanges(data, url, showResult, refreshPage) {
console.log("Applied changes successfully.");
console.log(data);
if (showResult) {
var modal = $("#modal_success");
if (data['msg']) {
modal.find('.modal-body p').text(data['msg']);
} else {
modal.find('.modal-body p').text("Applied changes successfully");
}
modal.modal('show');
showSuccessModal(data['msg'] || "Applied changes successfully");
}
if (refreshPage) {
location.reload(true);
@@ -27,10 +21,8 @@ function applyChanges(data, url, showResult, refreshPage) {
error : function(jqXHR, status) {
console.log(jqXHR);
var modal = $("#modal_error");
var responseJson = jQuery.parseJSON(jqXHR.responseText);
modal.find('.modal-body p').text(responseJson['msg']);
modal.modal('show');
showErrorModal(responseJson['msg']);
}
});
}
@@ -50,18 +42,14 @@ function applyRecordChanges(data, domain) {
});
console.log("Applied changes successfully.")
var modal = $("#modal_success");
modal.find('.modal-body p').text("Applied changes successfully");
modal.modal('show');
showSuccessModal("Applied changes successfully");
setTimeout(() => {window.location.reload()}, 2000);
},
error : function(jqXHR, status) {
console.log(jqXHR);
var modal = $("#modal_error");
var responseJson = jQuery.parseJSON(jqXHR.responseText);
modal.find('.modal-body p').text(responseJson['msg']);
modal.modal('show');
showErrorModal(responseJson['msg']);
}
});
}
@@ -76,13 +64,17 @@ function getTableData(table) {
record["record_type"] = r[1].trim();
record["record_status"] = r[2].trim();
record["record_ttl"] = r[3].trim();
record["record_data"] = r[4].trim();
record["record_comment"] = r[5].trim();
record["record_data"] = convertHTMLEntityToText(r[4].trim());
record["record_comment"] = convertHTMLEntityToText(r[5].trim());
records.push(record);
});
return records
}
function convertHTMLEntityToText(htmlEntity) {
return $('<textarea />').html(htmlEntity).text();
}
function saveRow(oTable, nRow) {
var status = 'Disabled';
@@ -285,4 +277,14 @@ function timer(elToUpdate, maxTime) {
}, 1000);
return interval;
}
}
// copy otp secret code to clipboard
function copy_otp_secret_to_clipboard() {
var copyBox = document.getElementById("otp_secret");
copyBox.select();
copyBox.setSelectionRange(0, 99999); /* For mobile devices */
navigator.clipboard.writeText(copyBox.value);
$("#copy_tooltip").css("visibility", "visible");
setTimeout(function(){ $("#copy_tooltip").css("visibility", "collapse"); }, 2000);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -782,6 +782,32 @@ paths:
'422':
description: 'Returned when something is wrong with the content of the request. Contains an error message'
'/servers/{server_id}/health':
get:
security:
- APIKeyHeader: []
summary: Perfoms health check
operationId: health_check
tags:
- Monitoring
parameters:
- name: server_id
in: path
required: true
description: The id of the server to retrieve
type: string
responses:
'200':
description: Healthcheck succeeded
schema:
type: string
example: "up"
'503':
description: Healthcheck failed
schema:
type: string
example: Down/Unknown
'/pdnsadmin/zones':
get:
security:

View File

@@ -49,7 +49,7 @@
<span class="fa fa-cog form-control-feedback"></span>
{% if invalid_accountname %}
<span class="help-block">Cannot be blank and must only contain alphanumeric
characters.</span>
characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens or underscores{% endif %}.</span>
{% elif duplicate_accountname %}
<span class="help-block">Account name already in use.</span>
{% endif %}
@@ -112,8 +112,9 @@
</p>
<p>Fill in all the fields to the in the form to the left.</p>
<p>
<strong>Name</strong> is an account identifier. It will be stored as all lowercase letters (no
spaces, special characters etc).<br />
<strong>Name</strong> is an account identifier. It will be lowercased and can contain alphanumeric
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 %}.<br />
<strong>Description</strong> is a user friendly name for this account.<br />
<strong>Contact person</strong> is the name of a contact person at the account.<br />
<strong>Mail Address</strong> is an e-mail address for the contact person.

View File

@@ -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 %}
<title>Edit Key - {{ SITE_NAME }}</title>
{% endblock %}
@@ -39,7 +39,9 @@
<select class="key_role form-control" id="key_role" name="key_role">
{% for role in roles %}
<option value="{{ role.name }}"
{% if (key is not none) and (role.id==key.role.id) %}selected{% endif %}>{{ role.name }}</option>
{% if (key is not none) and (role.id==key.role.id) %}selected{% endif %}
{% if (key is none) and (role.name=="User") %}selected{% endif %}
>{{ role.name }}</option>
{% endfor %}
</select>
</div>

View File

@@ -446,9 +446,7 @@
if(!canSearch)
{
var modal = $("#modal_error");
modal.find('.modal-body p').text("Please fill out the " + main_field + " field.");
modal.modal('show');
showErrorModal("Please fill out the " + main_field + " field.");
}
else
{

View File

@@ -24,19 +24,20 @@
<td>{{ history.history.created_on }}</td>
<td width="6%">
<div id="history-info-div-{{ loop.index0 }}" style="display: none;">
{{ history.detailed_msg | safe }}
{% if history.change_set %}
<div class="content">
<div id="change_index_definition"></div>
{% call applied_change_macro.applied_change_template(history.change_set) %}
{% endcall %}
</div>
{% endif %}
</div>
<button type="button" class="btn btn-flat btn-primary history-info-button"
{% if history.detailed_msg == "" and history.change_set == None %}
{% if history.detailed_msg == "" and history.change_set is none %}
style="visibility: hidden;"
{% endif%}
value='{{ history.detailed_msg }}
{% if history.change_set != None %}
<div class="content">
<div id="change_index_definition"></div>
{% call applied_change_macro.applied_change_template(history.change_set) %}
{% endcall %}
</div>
{% endif %}
'>Info&nbsp;<i class="fa fa-info"></i>
{% endif %} value="{{ loop.index0 }}">Info&nbsp;<i class="fa fa-info"></i>
</button>
</td>
</tr>
@@ -45,17 +46,10 @@
</table>
</div>
<script>
var table;
$(document).ready(function () {
table = $('#tbl_history').DataTable({
"order": [
[2, "desc"]
@@ -74,22 +68,14 @@ $(document).ready(function () {
fixedHeader: true
});
$(document.body).on('click', '.history-info-button', function () {
var modal = $("#modal_history_info");
var info = $(this).val();
var history_id = $(this).val();
var info = $("#history-info-div-" + history_id).html();
$('#modal-info-content').html(info);
modal.modal('show');
});
$(document.body).on("click", ".button-filter", function (e) {
e.stopPropagation();
var nextRow = $("#filter-table")
@@ -101,4 +87,4 @@ $(document).ready(function () {
});
</script>
</script>

View File

@@ -35,7 +35,7 @@
<tbody>
{% for statistic in statistics %}
<tr class="odd gradeX">
<td><a href="https://google.com/search?q=site:doc.powerdns.com+{{ statistic['name'] }}"
<td><a href="https://doc.powerdns.com/authoritative/search.html?q={{ statistic['name'] }}"
target="_blank" class="btn btn-flat btn-xs blue"><i
class="fa fa-search"></i></a></td>
<td>{{ statistic['name'] }}</td>
@@ -70,7 +70,7 @@
<tbody>
{% for config in configs %}
<tr class="odd gradeX">
<td><a href="https://google.com/search?q=site:doc.powerdns.com+{{ config['name'] }}"
<td><a href="https://doc.powerdns.com/authoritative/search.html?q={{ config['name'] }}"
target="_blank" class="btn btn-flat btn-xs blue"><i
class="fa fa-search"></i></a></td>
<td>{{ config['name'] }}</td>

View File

@@ -735,15 +735,17 @@
$('#ldap_admin_username').prop('required', true);
$('#ldap_admin_password').prop('required', true);
$('#ldap_domain').prop('required', false);
$('#ldap_filter_group').prop('required', true);
$('#ldap_filter_groupname').prop('required', true);
} else {
$('#ldap_admin_username').prop('required', false);
$('#ldap_admin_password').prop('required', false);
$('#ldap_domain').prop('required', true);
$('#ldap_filter_group').prop('required', false);
$('#ldap_filter_groupname').prop('required', false);
}
$('#ldap_filter_basic').prop('required', true);
$('#ldap_filter_group').prop('required', true);
$('#ldap_filter_username').prop('required', true);
$('#ldap_filter_groupname').prop('required', true);
if ($('#ldap_sg_on').is(":checked")) {
$('#ldap_admin_group').prop('required', true);

View File

@@ -7,28 +7,28 @@
<th colspan="3">
{% if hist_rec_entry.change_type == "+" %}
<span
style="background-color: lightgreen">{{hist_rec_entry.add_rrest['name']}}
{{hist_rec_entry.add_rrest['type']}}</span>
style="background-color: lightgreen">{{hist_rec_entry.add_rrset['name']}}
{{hist_rec_entry.add_rrset['type']}}</span>
{% elif hist_rec_entry.change_type == "-" %}
<s
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
{{hist_rec_entry.del_rrest['name']}}
{{hist_rec_entry.del_rrest['type']}}
{{hist_rec_entry.del_rrset['name']}}
{{hist_rec_entry.del_rrset['type']}}
</s>
{% else %}
{{hist_rec_entry.add_rrest['name']}}
{{hist_rec_entry.add_rrest['type']}}
{{hist_rec_entry.add_rrset['name']}}
{{hist_rec_entry.add_rrset['type']}}
{% endif %}
, TTL:
{% if "ttl" in hist_rec_entry.changed_fields %}
<s
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
{{hist_rec_entry.del_rrest['ttl']}}</s>
{{hist_rec_entry.del_rrset['ttl']}}</s>
<span
style="background-color: lightgreen">{{hist_rec_entry.add_rrest['ttl']}}</span>
style="background-color: lightgreen">{{hist_rec_entry.add_rrset['ttl']}}</span>
{% else %}
{{hist_rec_entry.add_rrest['ttl']}}
{{hist_rec_entry.add_rrset['ttl']}}
{% endif %}
</th>
@@ -92,24 +92,24 @@
{% for changes in hist_rec_entry.changeSet %}
<tr>
{% if changes[2] == "unchanged" %}
<td>
<td style="word-break: break-all">
{{changes[0]['content']}}
</td>
{% elif changes[2] == "addition" %}
<td>
<td style="word-break: break-all">
<span style="background-color: lightgreen">
{{changes[1]['content']}}
</span>
</td>
{% elif changes[2] == "deletion" %}
<td>
<td style="word-break: break-all">
<s
style="text-decoration-color: rgba(194, 10, 10, 0.6); text-decoration-thickness: 2px;">
{{changes[0]['content']}}
</s>
</td>
{% elif changes[2] == "status" %}
<td>
<td style="word-break: break-all">
{{changes[0]['content']}}
</td>
{% endif %}
@@ -119,8 +119,8 @@
</table>
</td>
<td>
{% for comments in hist_rec_entry.add_rrest['comments'] %}
<td style="word-break: break-all">
{% for comments in hist_rec_entry.add_rrset['comments'] %}
{{comments['content'] }}
<br/>
{% endfor %}
@@ -130,4 +130,4 @@
</tbody>
</table>
{% endfor %}
{%- endmacro %}
{%- endmacro %}

View File

@@ -8,13 +8,8 @@
{% block title %}<title>{{ SITE_NAME }}</title>{% endblock %}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/style.css') }}">
<!-- Get Google Fonts we like -->
{% if OFFLINE_MODE %}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/source_sans_pro.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/roboto_mono.css') }}">
{% else %}
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700,300italic,400italic,600italic">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto+Mono:400,300,700">
{% endif %}
<!-- Tell the browser to be responsive to screen width -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<!-- Tell Safari to not recognise telephone numbers -->
@@ -25,20 +20,10 @@
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
{% endblock %}
</head>
<body class="hold-transition skin-blue sidebar-mini {% if not SETTING.get('fullscreen_layout') %}layout-boxed{% endif %}">
{% if OFFLINE_MODE %}
{% set gravatar_url = url_for('static', filename='img/gravatar.png') %}
{% elif current_user.email is defined %}
{% set gravatar_url = current_user.email|email_to_gravatar_url(size=80) %}
{% endif %}
{% set user_image_url = url_for('user.image', username=current_user.username) %}
<div class="wrapper">
{% block pageheader %}
<header class="main-header">
@@ -47,7 +32,13 @@
<!-- mini logo for sidebar mini 50x50 pixels -->
<span class="logo-mini"><b>PD</b>A</span>
<!-- logo for regular state and mobile devices -->
<span class="logo-lg"><b>PowerDNS</b>-Admin</span>
<span class="logo-lg">
{% if SETTING.get('site_name') %}
<b>{{ SITE_NAME }}</b>
{% else %}
<b>PowerDNS</b>-Admin
{% endif %}
</span>
</a>
<!-- Header Navbar: style can be found in header.less -->
<nav class="navbar navbar-static-top">
@@ -62,14 +53,14 @@
<!-- User Account: style can be found in dropdown.less -->
<li class="dropdown user user-menu">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<img src="{{ gravatar_url }}" class="user-image {{ 'offline' if OFFLINE_MODE }}" alt="User Image"/>
<img src="{{ user_image_url }}" class="user-image" alt="User Image"/>
<span class="hidden-xs">
{{ current_user.firstname }}
</span>
</a>
<ul class="dropdown-menu">
<li class="user-header">
<img src="{{ gravatar_url }}" class="img-circle {{ 'offline' if OFFLINE_MODE }}" alt="User Image"/>
<img src="{{ user_image_url }}" class="img-circle" alt="User Image"/>
<p>
{{ current_user.firstname }} {{ current_user.lastname }}
<small>{{ current_user.role.name }}</small>
@@ -100,7 +91,7 @@
{% if current_user.id is defined %}
<div class="user-panel">
<div class="pull-left image">
<img src="{{ gravatar_url }}" class="img-circle" alt="User Image"/>
<img src="{{ user_image_url }}" class="img-circle" alt="User Image"/>
</div>
<div class="pull-left info">
<p>{{ current_user.firstname }} {{ current_user.lastname }}</p>
@@ -194,7 +185,7 @@
</div>
<!-- /.content-wrapper -->
<footer class="main-footer">
<strong><a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></strong> - A PowerDNS web interface with advanced features.
<strong><a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin">PowerDNS-Admin</a></strong> - A PowerDNS web interface with advanced features.
</footer>
</div>
<!-- ./wrapper -->
@@ -298,6 +289,18 @@
}
});
});
function showErrorModal(message) {
var modal = $('#modal_error');
modal.find('.modal-body p').text(message);
modal.modal('show');
}
function showSuccessModal(message) {
var modal = $("#modal_success");
modal.find('.modal-body p').text(message);
modal.modal('show');
}
</script>
{% endif %}
{%- endassets %}

View File

@@ -114,20 +114,20 @@
<td>{{ history.history.msg }}</td>
<td>{{ history.history.created_on }}</td>
<td width="6%">
<div id="history-info-div-{{ loop.index0 }}" style="display: none;">
{{ history.detailed_msg | safe }}
{% if history.change_set %}
<div class="content">
<div id="change_index_definition"></div>
{% call applied_change_macro.applied_change_template(history.change_set) %}
{% endcall %}
</div>
{% endif %}
</div>
<button type="button" class="btn btn-flat btn-primary history-info-button"
{% if history.detailed_msg == "" and history.change_set == None %}
style="visibility: hidden;"
{% endif%}
value='{{ history.detailed_msg }}
{% if history.change_set != None %}
<div class="content">
<div id="change_index_definition"></div>
{% call applied_change_macro.applied_change_template(history.change_set) %}
{% endcall %}
</div>
{% endif %}
'>
Info&nbsp;<i class="fa fa-info"></i>
{% if history.detailed_msg == "" and history.change_set is none %}
style="visibility: hidden;"
{% endif %} value="{{ loop.index0 }}">Info&nbsp;<i class="fa fa-info"></i>
</button>
</td>
</tr>
@@ -243,7 +243,8 @@
$(document.body).on('click', '.history-info-button', function () {
var modal = $("#modal_history_info");
var info = $(this).val();
var history_id = $(this).val();
var info = $("#history-info-div-" + history_id).html();
$('#modal-info-content').html(info);
modal.modal('show');
});

21
powerdnsadmin/templates/domain.html Normal file → Executable file
View File

@@ -33,6 +33,11 @@
Update from Master&nbsp;<i class="fa fa-download"></i>
</button>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] %}
<button type="button" style="position: relative; margin-left: 20px" class="btn btn-flat btn-primary pull-left btn-danger" onclick="window.location.href='{{ url_for('domain.setting', domain_name=domain.name) }}'">
Admin&nbsp;<i class="fa fa-cog"></i>
</button>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<button type="button" style="position: relative; margin-left: 20px" class="btn btn-flat btn-primary button_changelog" id="{{ domain.name }}">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
@@ -55,6 +60,7 @@
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<th >Changelog</th>
{% endif %}
<th>Invisible Sorting Column</th>
</tr>
</thead>
<tbody>
@@ -99,7 +105,6 @@
<button type="button" class="btn btn-flat btn-warning">&nbsp;&nbsp;<i class="fa fa-exclamation-circle"></i>&nbsp;&nbsp;</button>
</td>
{% endif %}
</td>
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<td width="6%">
<button type="button" onclick="show_record_changelog('{{record.name}}','{{record.type}}',event)" class="btn btn-flat btn-primary">&nbsp;&nbsp;
@@ -156,6 +161,10 @@
type: 'natural',
targets: [0, 4]
},
{
targets: [0, 1, 2, 3, 4, 5],
render: $.fn.dataTable.render.text()
},
{
// hidden column so that we can add new records on top
// regardless of whatever sorting is done. See orderFixed
@@ -181,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) {
@@ -242,9 +251,7 @@
// handle apply changes button
$(document.body).on("click",".button_apply_changes", function() {
if (nNew || nEditing) {
var modal = $("#modal_error");
modal.find('.modal-body p').text("Previous record not saved. Please save it before applying the changes.");
modal.modal('show');
showErrorModal("Previous record not saved. Please save it before applying the changes.");
return;
}
@@ -268,9 +275,7 @@
// handle add record button
$(document.body).on("click", ".button_add_record", function (e) {
if (nNew || nEditing) {
var modal = $("#modal_error");
modal.find('.modal-body p').text("Previous record not saved. Please save it before adding more record.");
modal.modal('show');
showErrorModal("Previous record not saved. Please save it before adding more record.");
return;
}
// clear search first

View File

@@ -34,6 +34,13 @@
<input type="text" class="form-control" name="domain_name" id="domain_name"
placeholder="Enter a valid domain name (required)">
</div>
{% if domain_override_toggle == True %}
<div class="form-group">
<label>Domain Override Record</label>
<input type="checkbox" id="domain_override" name="domain_override"
class="checkbox">
</div>
{% endif %}
<select name="accountid" class="form-control" style="width:15em;">
<option value="0">- No Account -</option>
{% for account in accounts %}
@@ -178,3 +185,37 @@
});
</script>
{% endblock %}
{% block modals %}
<script>
{% if domain_override_message %}
$(document.body).ready(function () {
var modal = $("#modal_warning");
var info = "{{ domain_override_message }}";
modal.find('.modal-body p').text(info);
modal.modal('show');
});
{% endif %}
</script>
</div>
<div class="modal fade" id="modal_warning">
<div class="modal-dialog">
<div class="modal-content modal-sm">
<div class="modal-header alert-danger">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" id="button_close_warn_modal">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">WARNING</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-primary center-block" data-dismiss="modal" id="button_confirm_warn_modal">
CLOSE</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %}

View File

@@ -73,9 +73,7 @@
$(document.body).on("click", ".button_delete", function(e) {
e.stopPropagation();
if ( $("#domainid").val() == 0 ){
var modal = $("#modal_error");
modal.find('.modal-body p').text("Please select domain to remove.");
modal.modal('show');
showErrorModal("Please select domain to remove.");
return;
}

View File

@@ -202,7 +202,7 @@
<td class="content-block powered-by"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin"
Powered by <a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin"
style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">PowerDNS-Admin</a>.
</td>
</tr>

View File

@@ -8,24 +8,25 @@
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Tell the browser to be responsive to screen width -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<META HTTP-EQUIV="REFRESH" CONTENT="{{ 60 * SETTING.get('session_timeout') }}">
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<a href="{{ url_for('index.index') }}"><b>PowerDNS</b>-Admin</a>
<a href="{{ url_for('index.index') }}">
{% if SETTING.get('site_name') %}
<b>{{ SITE_NAME }}</b>
{% else %}
<b>PowerDNS</b>-Admin
{% endif %}
</a>
</div>
<!-- /.login-logo -->
<div class="login-box-body">
@@ -50,7 +51,7 @@
</div>
{% if SETTING.get('otp_field_enabled') %}
<div class="form-group">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken" autocomplete="off">
</div>
{% endif %}
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
@@ -138,7 +139,7 @@
<!-- /.login-box-body -->
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
<p>Powered by <a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>

View File

@@ -11,13 +11,6 @@
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body class="hold-transition register-page">
@@ -85,7 +78,7 @@
<!-- /.form-box -->
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
<p>Powered by <a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Welcome - {{ SITE_NAME }}</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Tell the browser to be responsive to screen width -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
</head>
<body class="hold-transition register-page">
<div class="register-box">
<div class="register-logo">
<a><b>PowerDNS</b>-Admin</a>
</div>
<div class="register-box-body">
{% if error %}
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ error }}
</div>
{% endif %}
Welcome, {{user.firstname}}! <br />
You will need a Token on login. <br />
Your QR code is:
<div id="token_information">
{% if qrcode_image == None %}
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
{% else %}
<p><img id="qrcode" src="data:image/svg+xml;utf8;base64, {{qrcode_image}}"></p>
{% endif %}
<p>
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</p>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
href="https://apps.apple.com/us/app/google-authenticator/id388497605">iOS</a>)
<br />
or FreeOTP (<a target="_blank"
href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp&hl=en">Android</a>
- <a target="_blank"
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone <br /> to scan the QR code or type the secret key.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code <br />
and secret key, and nobody can capture them.</i></strong></font>
</div>
</br>
Please input your OTP token to continue, to ensure the seed has been scanned correctly.
<form action="" method="post" data-toggle="validator">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<input type="text" class="form-control" placeholder="OTP Token" name="otptoken"
data-error="Please input your OTP token" required>
</div>
<div class="row">
<div class="col-xs-4">
<button type="submit" class="btn btn-flat btn-primary btn-block">Continue</button>
</div>
</div>
</form>
</div>
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>
</body>
{% assets "js_login" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
{% assets "js_validation" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
</html>

View File

@@ -62,7 +62,7 @@
<!-- /.form-box -->
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
<p>Powered by <a href="https://github.com/PowerDNS-Admin/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>

View File

@@ -192,9 +192,7 @@
// handle apply changes button
$(document.body).on("click",".button_apply_changes", function() {
if (nNew || nEditing) {
var modal = $("#modal_error");
modal.find('.modal-body p').text("Previous record not saved. Please save it before applying the changes.");
modal.modal('show');
showErrorModal("Previous record not saved. Please save it before applying the changes.");
return;
}
@@ -217,9 +215,7 @@
// handle add record button
$(document.body).on("click", ".button_add_record", function (e) {
if (nNew || nEditing) {
var modal = $("#modal_error");
modal.find('.modal-body p').text("Previous record not saved. Please save it before adding more record.");
modal.modal('show');
showErrorModal("Previous record not saved. Please save it before adding more record.");
return;
}
// clear search first

View File

@@ -93,6 +93,14 @@
{% if current_user.otp_secret %}
<div id="token_information">
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
<div style="position: relative; left: 15px">
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{current_user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</div>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
@@ -103,8 +111,8 @@
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone to scan the QR code.
<br />
<font color="red"><strong><i>Make sure only you can see this QR Code and
nobody can capture it.</i></strong></font>
<font color="red"><strong><i>Make sure only you can see this QR Code and secret key and
nobody can capture them.</i></strong></font>
</div>
{% endif %}
</div>