mirror of
https://github.com/cwinfo/powerdns-admin.git
synced 2025-08-01 18:23:45 +00:00
Merge branch 'PowerDNS-Admin:master' into shine/config_table_key_uniqueness
This commit is contained in:
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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'
|
||||
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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'}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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": [{
|
||||
|
@@ -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() +
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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'))
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
||||
|
||||
|
@@ -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.'}))
|
||||
|
||||
|
||||
|
@@ -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():
|
||||
|
@@ -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')
|
||||
|
@@ -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']
|
||||
|
@@ -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%;
|
||||
}
|
@@ -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 |
BIN
powerdnsadmin/static/img/user_image.png
Normal file
BIN
powerdnsadmin/static/img/user_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
@@ -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:
|
||||
|
@@ -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.
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
{
|
||||
|
@@ -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 <i class="fa fa-info"></i>
|
||||
{% endif %} value="{{ loop.index0 }}">Info <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>
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 <i class="fa fa-info"></i>
|
||||
{% if history.detailed_msg == "" and history.change_set is none %}
|
||||
style="visibility: hidden;"
|
||||
{% endif %} value="{{ loop.index0 }}">Info <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
21
powerdnsadmin/templates/domain.html
Normal file → Executable file
@@ -33,6 +33,11 @@
|
||||
Update from Master <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 <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 <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"> <i class="fa fa-exclamation-circle"></i> </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">
|
||||
@@ -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
|
||||
|
@@ -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">×</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 %}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
90
powerdnsadmin/templates/register_otp.html
Executable file
90
powerdnsadmin/templates/register_otp.html
Executable 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">×</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>
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user