Resolve the conflicts for #228

This commit is contained in:
Khanh Ngo 2018-04-02 13:38:53 +07:00
commit 17a892b18d
32 changed files with 1103 additions and 130 deletions

5
.gitignore vendored
View file

@ -23,9 +23,14 @@ nosetests.xml
flask
config.py
logfile.log
settings.json
advanced_settings.json
idp.crt
log.txt
db_repository/*
upload/avatar/*
tmp/*
.ropeproject
.sonarlint/*
pdns.db

12
.travis.yml Normal file
View file

@ -0,0 +1,12 @@
language: python
python:
- "3.5.2"
before_install:
- 'travis_retry sudo apt-get update'
- 'travis_retry sudo apt-get install python3-dev libxml2-dev libxmlsec1-dev'
install:
- pip install -r requirements.txt
before_script:
- mv config_template.py config.py
script:
- sh run_travis.sh

View file

@ -6,12 +6,21 @@ ENV ENVIRONMENT=${ENVIRONMENT}
WORKDIR /powerdns-admin
RUN apt-get update -y
RUN apt-get install -y python3-pip python3-dev libmysqlclient-dev supervisor
RUN apt-get install -y python3-pip python3-dev supervisor
# lib for building mysql db driver
RUN apt-get install -y libmysqlclient-dev
# lib for buiding ldap and ssl-based application
RUN apt-get install -y libsasl2-dev libldap2-dev libssl-dev
# lib for building python3-saml
RUN apt-get install -y libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev pkg-config
COPY ./requirements.txt /powerdns-admin/requirements.txt
RUN pip3 install -r requirements.txt
ADD ./supervisord.conf /etc/supervisord.conf
ADD . /powerdns-admin/
COPY ./configs/${ENVIRONMENT}.py /powerdns-admin/config.py

View file

@ -8,6 +8,9 @@ A PowerDNS web interface with advanced features.
- User access management based on domain
- User activity logging
- Local DB / LDAP / Active Directory user authentication
- Support SAML authentication
- Google oauth authentication
- Github oauth authentication
- Support Two-factor authentication (TOTP)
- Dashboard and pdns service statistics
- DynDNS 2 protocol support

View file

@ -11,7 +11,6 @@ login_manager = LoginManager()
login_manager.init_app(app)
db = SQLAlchemy(app)
def enable_github_oauth(GITHUB_ENABLE):
if not GITHUB_ENABLE:
return None, None
@ -89,5 +88,9 @@ def enable_google_oauth(GOOGLE_ENABLE):
google = enable_google_oauth(app.config.get('GOOGLE_OAUTH_ENABLE'))
from app import views, models
if app.config.get('SAML_ENABLED') and app.config.get('SAML_ENCRYPT'):
from app.lib import certutil
if not certutil.check_certificate():
certutil.create_self_signed_cert()

48
app/lib/certutil.py Normal file
View file

@ -0,0 +1,48 @@
from OpenSSL import crypto
from datetime import datetime
import pytz
import os
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():
# create a key pair
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, 2048)
# 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')
open(CERT_FILE, "wt").write(
crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
open(KEY_FILE, "wt").write(
crypto.dump_privatekey(crypto.FILETYPE_PEM, k))

View file

@ -7,6 +7,44 @@ import hashlib
from app import app
from distutils.version import StrictVersion
from urllib.parse import urlparse
from datetime import datetime, timedelta
from threading import Thread
from .certutil import *
if app.config['SAML_ENABLED']:
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from onelogin.saml2.settings import OneLogin_Saml2_Settings
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
idp_timestamp = datetime(1970, 1, 1)
idp_data = None
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'])
if idp_data is None:
print('SAML: IDP Metadata initial load failed')
exit(-1)
idp_timestamp = datetime.now()
def get_idp_data():
global idp_data, idp_timestamp
lifetime = timedelta(minutes=app.config['SAML_METADATA_CACHE_LIFETIME'])
if idp_timestamp+lifetime < datetime.now():
background_thread = Thread(target=retreive_idp_data)
background_thread.start()
return idp_data
def retreive_idp_data():
global idp_data, idp_timestamp
new_idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'])
if new_idp_data is not None:
idp_data = new_idp_data
idp_timestamp = datetime.now()
print("SAML: IDP Metadata successfully retreived from: " + app.config['SAML_METADATA_URL'])
else:
print("SAML: IDP Metadata could not be retreived")
if 'TIMEOUT' in app.config.keys():
TIMEOUT = app.config['TIMEOUT']
@ -62,7 +100,8 @@ def fetch_remote(remote_url, method='GET', data=None, accept=None, params=None,
def fetch_json(remote_url, method='GET', data=None, params=None, headers=None):
r = fetch_remote(remote_url, method=method, data=data, params=params, headers=headers, accept='application/json; q=1')
r = fetch_remote(remote_url, method=method, data=data, params=params, headers=headers,
accept='application/json; q=1')
if method == "DELETE":
return True
@ -159,3 +198,78 @@ def email_to_gravatar_url(email="", size=100):
"""
hash_string = hashlib.md5(email.encode('utf-8')).hexdigest()
return "https://s.gravatar.com/avatar/{0}?s={1}".format(hash_string, size)
def prepare_flask_request(request):
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
url_data = urlparse.urlparse(request.url)
return {
'https': 'on' if request.scheme == 'https' else 'off',
'http_host': request.host,
'server_port': url_data.port,
'script_name': request.path,
'get_data': request.args.copy(),
'post_data': request.form.copy(),
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
'lowercase_urlencoding': True,
'query_string': request.query_string
}
def init_saml_auth(req):
own_url = ''
if req['https'] is 'on':
own_url = 'https://'
else:
own_url = 'http://'
own_url += req['http_host']
metadata = get_idp_data()
settings = {}
settings['sp'] = {}
settings['sp']['NameIDFormat'] = idp_data['sp']['NameIDFormat']
settings['sp']['entityId'] = app.config['SAML_SP_ENTITY_ID']
cert = open(CERT_FILE, "r").readlines()
key = open(KEY_FILE, "r").readlines()
settings['sp']['privateKey'] = "".join(key)
settings['sp']['x509cert'] = "".join(cert)
settings['sp']['assertionConsumerService'] = {}
settings['sp']['assertionConsumerService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
settings['sp']['assertionConsumerService']['url'] = own_url+'/saml/authorized'
settings['sp']['attributeConsumingService'] = {}
settings['sp']['singleLogoutService'] = {}
settings['sp']['singleLogoutService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
settings['sp']['singleLogoutService']['url'] = own_url+'/saml/sls'
settings['idp'] = metadata['idp']
settings['strict'] = True
settings['debug'] = app.config['SAML_DEBUG']
settings['security'] = {}
settings['security']['digestAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
settings['security']['metadataCacheDuration'] = None
settings['security']['metadataValidUntil'] = None
settings['security']['requestedAuthnContext'] = True
settings['security']['signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
settings['security']['wantAssertionsEncrypted'] = False
settings['security']['wantAttributeStatement'] = True
settings['security']['wantNameId'] = True
settings['security']['authnRequestsSigned'] = app.config['SAML_SIGN_REQUEST']
settings['security']['logoutRequestSigned'] = app.config['SAML_SIGN_REQUEST']
settings['security']['logoutResponseSigned'] = app.config['SAML_SIGN_REQUEST']
settings['security']['nameIdEncrypted'] = False
settings['security']['signMetadata'] = True
settings['security']['wantAssertionsSigned'] = True
settings['security']['wantMessagesSigned'] = True
settings['security']['wantNameIdEncrypted'] = False
settings['contactPerson'] = {}
settings['contactPerson']['support'] = {}
settings['contactPerson']['support']['emailAddress'] = app.config['SAML_SP_CONTACT_NAME']
settings['contactPerson']['support']['givenName'] = app.config['SAML_SP_CONTACT_MAIL']
settings['contactPerson']['technical'] = {}
settings['contactPerson']['technical']['emailAddress'] = app.config['SAML_SP_CONTACT_NAME']
settings['contactPerson']['technical']['givenName'] = app.config['SAML_SP_CONTACT_MAIL']
settings['organization'] = {}
settings['organization']['en-US'] = {}
settings['organization']['en-US']['displayname'] = 'PowerDNS-Admin'
settings['organization']['en-US']['name'] = 'PowerDNS-Admin'
settings['organization']['en-US']['url'] = own_url
auth = OneLogin_Saml2_Auth(req, settings)
return auth

View file

@ -140,7 +140,9 @@ class User(db.Model):
def check_password(self, hashed_password):
# Check hased password. Useing bcrypt, the salt is saved into the hash itself
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'), hashed_password.encode('utf-8'))
if (self.plain_text_password):
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'), hashed_password.encode('utf-8'))
return False
def get_user_info_by_id(self):
user_info = User.query.get(int(self.id))
@ -276,9 +278,9 @@ class User(db.Model):
self.set_admin(isadmin)
self.update_profile()
return True
logging.error('Unsupported authentication method')
return False
else:
logging.error('Unsupported authentication method')
return False
def create_user(self):
"""
@ -286,10 +288,10 @@ class User(db.Model):
We will create a local user (in DB) in order to manage user
profile such as name, roles,...
"""
# Set an invalid password hash for non local users
self.password = '*'
db.session.add(self)
db.session.commit()
@ -653,9 +655,41 @@ class Domain(db.Model):
logging.debug(traceback.print_exc())
return {'status': 'error', 'msg': 'Cannot add this domain.'}
def update_soa_setting(self, domain_name, soa_edit_api):
domain = Domain.query.filter(Domain.name == domain_name).first()
if not domain:
return {'status': 'error', 'msg': 'Domain doesnt exist.'}
headers = {}
headers['X-API-Key'] = PDNS_API_KEY
if soa_edit_api == 'OFF':
post_data = {
"soa_edit_api": None,
"kind": domain.type
}
else:
post_data = {
"soa_edit_api": soa_edit_api,
"kind": domain.type
}
try:
jdata = utils.fetch_json(
urljoin(PDNS_STATS_URL, API_EXTENDED_URL + '/servers/localhost/zones/{0}'.format(domain.name)), headers=headers,
method='PUT', data=post_data)
if 'error' in jdata.keys():
logging.error(jdata['error'])
return {'status': 'error', 'msg': jdata['error']}
else:
logging.info('soa-edit-api changed for domain {0} successfully'.format(domain_name))
return {'status': 'ok', 'msg': 'soa-edit-api changed successfully'}
except Exception as e:
logging.debug(e)
logging.debug(traceback.format_exc())
logging.error('Cannot change soa-edit-api for domain {0}'.format(domain_name))
return {'status': 'error', 'msg': 'Cannot change soa-edit-api this domain.'}
def create_reverse_domain(self, domain_name, domain_reverse_name):
"""
Check the existing reverse lookup domain,
Check the existing reverse lookup domain,
if not exists create a new one automatically
"""
domain_obj = Domain.query.filter(Domain.name == domain_name).first()
@ -799,6 +833,50 @@ class Domain(db.Model):
else:
return {'status': 'error', 'msg': 'This domain doesnot exist'}
def enable_domain_dnssec(self, domain_name):
"""
Enable domain DNSSEC
"""
domain = Domain.query.filter(Domain.name == domain_name).first()
if domain:
headers = {}
headers['X-API-Key'] = PDNS_API_KEY
post_data = {
"keytype": "ksk",
"active": True
}
try:
jdata = utils.fetch_json(urljoin(PDNS_STATS_URL, API_EXTENDED_URL + '/servers/localhost/zones/{0}/cryptokeys'.format(domain.name)), headers=headers, method='POST',data=post_data)
if 'error' in jdata:
return {'status': 'error', 'msg': 'DNSSEC is not enabled for this domain', 'jdata' : jdata}
else:
return {'status': 'ok'}
except:
logging.error(traceback.print_exc())
return {'status': 'error', 'msg': 'There was something wrong, please contact administrator'}
else:
return {'status': 'error', 'msg': 'This domain does not exist'}
def delete_dnssec_key(self, domain_name, key_id):
"""
Remove keys DNSSEC
"""
domain = Domain.query.filter(Domain.name == domain_name).first()
if domain:
headers = {}
headers['X-API-Key'] = PDNS_API_KEY
url = '/servers/localhost/zones/{0}/cryptokeys/{1}'.format(domain.name, key_id)
try:
jdata = utils.fetch_json(urljoin(PDNS_STATS_URL, API_EXTENDED_URL + url), headers=headers, method='DELETE')
if 'error' in jdata:
return {'status': 'error', 'msg': 'DNSSEC is not disabled for this domain', 'jdata' : jdata}
else:
return {'status': 'ok'}
except:
return {'status': 'error', 'msg': 'There was something wrong, please contact administrator','id': key_id, 'url': url}
else:
return {'status': 'error', 'msg': 'This domain doesnot exist'}
class DomainUser(db.Model):
__tablename__ = 'domain_user'
@ -953,7 +1031,7 @@ class Record(object):
if r_type == 'PTR': # only ptr
if ':' in r['record_name']: # dirty ipv6 check
r_name = r['record_name']
record = {
"name": r_name,
"type": r_type,
@ -962,7 +1040,7 @@ class Record(object):
"ttl": int(r['record_ttl']) if r['record_ttl'] else 3600,
}
records.append(record)
deleted_records, new_records = self.compare(domain, records)
records = []
@ -974,7 +1052,7 @@ class Record(object):
if r_type == 'PTR': # only ptr
if ':' in r['name']: # dirty ipv6 check
r_name = dns.reversename.from_address(r['name']).to_text()
record = {
"name": r_name,
"type": r_type,
@ -1035,12 +1113,12 @@ class Record(object):
r_name = key[0]
r_type = key[1]
r_changetype = key[2]
if PRETTY_IPV6_PTR: # only if activated
if r_type == 'PTR': # only ptr
if ':' in r_name: # dirty ipv6 check
r_name = dns.reversename.from_address(r_name).to_text()
new_record = {
"name": r_name,
"type": r_type,

View file

@ -1,3 +1,5 @@
var dnssecKeyList = []
function applyChanges(data, url, showResult, refreshPage) {
var success = false;
$.ajax({
@ -116,7 +118,22 @@ function SelectElement(elementID, valueToSelect)
element.value = valueToSelect;
}
function getdnssec(url){
function enable_dns_sec(url) {
$.getJSON(url, function(data) {
var modal = $("#modal_dnssec_info");
if (data['status'] == 'error'){
modal.find('.modal-body p').text(data['msg']);
}
else {
modal.modal('hide');
//location.reload();
window.location.reload(true);
}
})
}
function getdnssec(url, domain){
$.getJSON(url, function(data) {
var modal = $("#modal_dnssec_info");
@ -127,23 +144,36 @@ function getdnssec(url){
else {
dnssec_msg = '';
var dnssec = data['dnssec'];
for (var i = 0; i < dnssec.length; i++) {
if (dnssec[i]['active']){
dnssec_msg += '<form>'+
'<h3><strong>'+dnssec[i]['keytype']+'</strong></h3>'+
'<strong>DNSKEY</strong>'+
'<input class="form-control" autocomplete="off" type="text" readonly="true" value="'+dnssec[i]['dnskey']+'">'+
'</form>'+
'<br/>';
if(dnssec[i]['ds']){
var dsList = dnssec[i]['ds'];
dnssec_msg += '<strong>DS</strong>';
for (var j = 0; j < dsList.length; j++){
dnssec_msg += '<input class="form-control" autocomplete="off" type="text" readonly="true" value="'+dsList[j]+'">';
}
}
dnssec_msg += '</form>';
if (dnssec.length == 0 && parseFloat(PDNS_VERSION) >= 4.1) {
dnssec_msg = '<h3>DNSSEC is disabled. Click on Enable to activate it.';
modal.find('.modal-body p').html(dnssec_msg);
dnssec_footer = '<button type="button" class="btn btn-flat btn-success button_dnssec_enable pull-left" id="'+domain+'">Enable</button><button type="button" class="btn btn-flat btn-default pull-right" data-dismiss="modal">Cancel</button>';
modal.find('.modal-footer ').html(dnssec_footer);
}
else {
if (parseFloat(PDNS_VERSION) >= 4.1) {
dnssec_footer = '<button type="button" class="btn btn-flat btn-danger button_dnssec_disable pull-left" id="'+domain+'">Disable DNSSEC</button><button type="button" class="btn btn-flat btn-default pull-right" data-dismiss="modal">Close</button>';
modal.find('.modal-footer ').html(dnssec_footer);
}
for (var i = 0; i < dnssec.length; i++) {
if (dnssec[i]['active']){
dnssec_msg += '<form>'+
'<h3><strong>'+dnssec[i]['keytype']+'</strong></h3>'+
'<strong>DNSKEY</strong>'+
'<input class="form-control" autocomplete="off" type="text" readonly="true" value="'+dnssec[i]['dnskey']+'">'+
'</form>'+
'<br/>';
if(dnssec[i]['ds']){
var dsList = dnssec[i]['ds'];
dnssec_msg += '<strong>DS</strong>';
for (var j = 0; j < dsList.length; j++){
dnssec_msg += '<input class="form-control" autocomplete="off" type="text" readonly="true" value="'+dsList[j]+'">';
}
}
dnssec_msg += '</form>';
}
}
}
modal.find('.modal-body p').html(dnssec_msg);
}

View file

@ -83,7 +83,7 @@
<small>{{ current_user.role.name }}</small>
</p>
</li>
<!-- Menu Footer-->
<li class="user-footer">
<div class="pull-left">

View file

@ -154,6 +154,7 @@
{% endblock %}
{% block extrascripts %}
<script>
PDNS_VERSION = '{{ pdns_version }}'
// set up history data table
$("#tbl_history").DataTable({
"paging" : false,
@ -214,6 +215,23 @@
modal.modal('show');
});
$(document.body).on("click", ".button_dnssec", function() {
var domain = $(this).prop('id');
getdnssec($SCRIPT_ROOT + '/domain/' + domain + '/dnssec', domain);
});
$(document.body).on("click", ".button_dnssec_enable", function() {
var domain = $(this).prop('id');
enable_dns_sec($SCRIPT_ROOT + '/domain/' + domain + '/dnssec/enable');
});
$(document.body).on("click", ".button_dnssec_disable", function() {
var domain = $(this).prop('id');
enable_dns_sec($SCRIPT_ROOT + '/domain/' + domain + '/dnssec/disable');
});
</script>
{% endblock %}
{% block modals %}
@ -262,4 +280,28 @@
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_dnssec_info">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"
aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">DNSSEC</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-default pull-right"
data-dismiss="modal">Close</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{% endblock %}

View file

@ -4,9 +4,9 @@
{% macro dnssec(domain) %}
{% if domain.dnssec %}
<td><span class="label label-success"><i class="fa fa-lock-alt"></i>&nbsp;Enabled</span></td>
<td><span style="cursor:pointer" class="label label-success button_dnssec" id="{{ domain.name }}"><i class="fa fa-lock"></i>&nbsp;Enabled</span></td>
{% else %}
<td><span class="label label-primary"><i class="fa fa-unlock-alt"></i>&nbsp;Disabled</span></td>
<td><span style="cursor:pointer" class="label label-primary button_dnssec" id="{{ domain.name }}"><i class="fa fa-unlock-alt"></i>&nbsp;Disabled</span></td>
{% endif %}
{% endmacro %}

View file

@ -82,7 +82,7 @@
<button type="button" class="btn btn-flat btn-warning">&nbsp;&nbsp;<i class="fa fa-exclamation-circle"></i>&nbsp;&nbsp;</button>
</td>
<td width="6%">
<button type="button" class="btn btn-flat btn-warning">&nbsp;&nbsp;<i class="fa fa-exclamation-circle"></i>&nbsp;&nbsp;</button>
<button type="button" class="btn btn-flat btn-warning"">&nbsp;&nbsp;<i class="fa fa-exclamation-circle"></i>&nbsp;&nbsp;</button>
</td>
{% endif %}
</td>
@ -104,6 +104,7 @@
{% endblock %}
{% block extrascripts %}
<script>
PDNS_VERSION = '{{ pdns_version }}'
// superglobals
window.records_allow_edit = {{ editable_records|tojson }};
window.nEditing = null;
@ -143,7 +144,7 @@
],
"orderFixed": [[7, 'asc']]
});
// handle delete button
$(document.body).on("click", ".button_delete", function(e) {
e.stopPropagation();
@ -151,25 +152,25 @@
var table = $("#tbl_records").DataTable();
var record = $(this).prop('id');
var nRow = $(this).parents('tr')[0];
var info = "Are you sure you want to delete " + record + "?";
var info = "Are you sure you want to delete " + record + "?";
modal.find('.modal-body p').text(info);
modal.find('#button_delete_confirm').click(function() {
table.row(nRow).remove().draw();
modal.modal('hide');
})
modal.modal('show');
});
// handle edit button
$(document.body).on("click", ".button_edit, .row_record", function(e) {
e.stopPropagation();
if ($(this).is('tr')) {
var nRow = $(this)[0];
var nRow = $(this)[0];
} else {
var nRow = $(this).parents('tr')[0];
}
var table = $("#tbl_records").DataTable();
if (nEditing == nRow) {
/* click on row already being edited, do nothing */
} else if (nEditing !== null && nEditing != nRow && nNew == false) {
@ -189,13 +190,13 @@
nEditing = nRow;
}
});
// handle apply changes button
$(document.body).on("click",".button_apply_changes", function() {
var modal = $("#modal_apply_changes");
var table = $("#tbl_records").DataTable();
var domain = $(this).prop('id');
var info = "Are you sure you want to apply your changes?";
var info = "Are you sure you want to apply your changes?";
modal.find('.modal-body p').text(info);
modal.find('#button_apply_confirm').click(function() {
var data = getTableData(table);
@ -203,9 +204,9 @@
modal.modal('hide');
})
modal.modal('show');
});
// handle add record button
$(document.body).on("click", ".button_add_record", function (e) {
if (nNew || nEditing) {
@ -216,7 +217,7 @@
}
// clear search first
$("#tbl_records").DataTable().search('').columns().search('').draw();
// add new row
var default_type = records_allow_edit[0]
var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', 3600, '', '', '', '0']);
@ -225,7 +226,7 @@
nEditing = nRow;
nNew = true;
});
//handle cancel button
$(document.body).on("click", ".button_cancel", function (e) {
e.stopPropagation();
@ -239,7 +240,7 @@
nEditing = null;
}
});
//handle save button
$(document.body).on("click", ".button_save", function (e) {
e.stopPropagation();
@ -248,13 +249,13 @@
nEditing = null;
nNew = false;
});
//handle update_from_master button
$(document.body).on("click", ".button_update_from_master", function (e) {
var domain = $(this).prop('id');
applyChanges({'domain': domain}, $SCRIPT_ROOT + '/domain/' + domain + '/update');
});
{% if record_helper_setting %}
//handle wacky record types
$(document.body).on("focus", "#current_edit_record_data", function (e) {
@ -271,7 +272,7 @@
<input type=\"text\" class=\"form-control\" name=\"caa_value\" id=\"caa_value\" placeholder=\"eg. letsencrypt.org\"> \
";
} else {
var parts = record_data.val().split(" ");
var parts = record_data.val().split(" ");
var form = " <label for=\"caa_flag\">CAA Flag</label> \
<input type=\"text\" class=\"form-control\" name=\"caa_flag\" id=\"caa_flag\" placeholder=\"0\" value=\"" + parts[0] + "\"> \
<label for=\"caa_tag\">CAA Tag</label> \
@ -328,7 +329,7 @@
<input type=\"text\" class=\"form-control\" name=\"srv_target\" id=\"srv_target\" placeholder=\"sip.example.com\"> \
";
} else {
var parts = record_data.val().split(" ");
var parts = record_data.val().split(" ");
var form = " <label for=\"srv_priority\">SRV Priority</label> \
<input type=\"text\" class=\"form-control\" name=\"srv_priority\" id=\"srv_priority\" placeholder=\"0\" value=\"" + parts[0] + "\"> \
<label for=\"srv_weight\">SRV Weight</label> \
@ -369,7 +370,7 @@
<input type=\"text\" class=\"form-control\" name=\"soa_minimumttl\" id=\"soa_minimumttl\" placeholder=\"300\"> \
";
} else {
var parts = record_data.val().split(" ");
var parts = record_data.val().split(" ");
var form = " <label for=\"soa_primaryns\">Primary Name Server</label> \
<input type=\"text\" class=\"form-control\" name=\"soa_primaryns\" id=\"soa_primaryns\" value=\"" + parts[0] + "\"> \
<label for=\"soa_adminemail\">Primary Contact</label> \
@ -387,7 +388,7 @@
";
}
modal.find('.modal-body p').html(form);
modal.find('#button_save').click(function() {
modal.find('#button_save').click(function() {
soa_primaryns = modal.find('#soa_primaryns').val();
soa_adminemail = modal.find('#soa_adminemail').val();
soa_serial = modal.find('#soa_serial').val();
@ -395,7 +396,7 @@
soa_failedzonerefresh = modal.find('#soa_failedzonerefresh').val();
soa_zoneexpiry = modal.find('#soa_zoneexpiry').val();
soa_minimumttl = modal.find('#soa_minimumttl').val();
data = soa_primaryns + " " + soa_adminemail + " " + soa_serial + " " + soa_zonerefresh + " " + soa_failedzonerefresh + " " + soa_zoneexpiry + " " + soa_minimumttl;
record_data.val(data);
modal.modal('hide');

View file

@ -1,6 +1,22 @@
{% extends "base.html" %}
{% block title %}<title>DNS Control Panel - Domain Management</title>{% endblock %}
{% block dashboard_stat %}
{% if status %}
{% if status.get('status') == 'ok' %}
<div class="alert alert-success">
<strong>Success!</strong> {{ status.get('msg') }}
</div>
{% elif status.get('status') == 'error' %}
<div class="alert alert-danger">
{% if status.get('msg') != None %}
<strong>Error!</strong> {{ status.get('msg') }}
{% else %}
<strong>Error!</strong> An undefined error occurred.
{% endif %}
</div>
{% endif %}
{% endif %}
<section class="content-header">
<h1>
Manage domain <small>{{ domain.name }}</small>
@ -86,6 +102,57 @@
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-header">
<h3 class="box-title">Change SOA-EDIT-API</h3>
</div>
<div class="box-body">
<p>The SOA-EDIT-API setting defines when and how the SOA serial number will be updated after a change is made to the domain.</p>
<ul>
<li>
(OFF) - Not set
</li>
<li>
INCEPTION-INCREMENT - Uses YYYYMMDDSS format for SOA serial numbers. If the SOA serial from the backend is within two days after inception, it gets incremented by two (the backend should keep SS below 98).
</li>
<li>
INCEPTION - Sets the SOA serial to the last inception time in YYYYMMDD01 format. Uses localtime to find the day for inception time. <strong>Not recomended.</strong>
</li>
<li>
INCREMENT-WEEK - Sets the SOA serial to the number of weeks since the epoch, which is the last inception time in weeks. <strong>Not recomended.</strong>
</li>
<li>
INCREMENT-WEEKS - Increments the serial with the number of weeks since the UNIX epoch. This should work in every setup; but the result won't look like YYYYMMDDSS anymore.
</li>
<li>
EPOCH - Sets the SOA serial to the number of seconds since the epoch.
</li>
<li>
INCEPTION-EPOCH - Sets the new SOA serial number to the maximum of the old SOA serial number, and age in seconds of the last inception.
</li>
</ul>
<b>New SOA-EDIT-API Setting:</b>
<form method="post" action="{{ url_for('domain_change_soa_edit_api', domain_name=domain.name) }}">
<select name="soa_edit_api" class="form-control" style="width:15em;">
<option selected value="0">- Unchanged -</option>
<option>OFF</option>
<option>INCEPTION-INCREMENT</option>
<option>INCEPTION</option>
<option>INCREMENT-WEEK</option>
<option>INCREMENT-WEEKS</option>
<option>EPOCH</option>
<option>INCEPTION-EPOCH</option>
</select><br/>
<button type="submit" class="btn btn-flat btn-primary" id="change_soa_edit_api">
<i class="fa fa-check"></i>&nbsp;Change SOA-EDIT-API setting for {{ domain.name }}
</button>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="box">

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}<title>DNS Control Panel - SAML Authentication Error</title>{% endblock %}
{% block dashboard_stat %}
<!-- Content Header (Page header) -->
<section class="content-header">
<h1>
SAML
<small>Error</small>
</h1>
<ol class="breadcrumb">
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i>Home</a></li>
<li>SAML</li>
</ol>
</section>
{% endblock %}
{% block content %}
<!-- Main content -->
<section class="content">
<div class="error-page">
<div>
<h1 class="headline text-yellow" style="font-size:46px;">SAML Authentication Error</h1></div><br/><br/>
<div class="error-content">
<h3>
<i class="fa fa-warning text-yellow"></i> Oops! Something went wrong
</h3><br>
<p>
Login failed.<br>
Error(s) when processing SAML Response:<br>
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
You may <a href="{{ url_for('dashboard') }}">return to the dashboard</a>.
</p>
</div>
<!-- /.error-content -->
</div>
<!-- /.error-page -->
</section>
<!-- /.content -->
{% endblock %}

View file

@ -101,11 +101,16 @@
{% if google_enabled %}
<a href="{{ url_for('google_login') }}">Google oauth login</a>
{% endif %}
{% if saml_enabled %}
<br>
<a href="{{ url_for('saml_login') }}">SAML login</a>
{% endif %}
{% if github_enabled %}
<br>
<a href="{{ url_for('github_login') }}">Github oauth login</a>
{% endif %}
<br>
{% if signup_enabled %}
<br>
<a href="{{ url_for('register') }}" class="text-center">Create an account </a>
{% endif %}
</div>

View file

@ -19,7 +19,7 @@
<div class="col-lg-12">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">Edit my profile</h3>
<h3 class="box-title">Edit my profile{% if external_account %} [Disabled - Authenticated externally]{% endif %}</h3>
</div>
<div class="box-body">
<!-- Custom Tabs -->
@ -29,10 +29,10 @@
Info</a></li>
<li><a href="#tabs-avatar" data-toggle="tab">Change
Avatar</a></li>
<li><a href="#tabs-password" data-toggle="tab">Change
{% if not external_account %}<li><a href="#tabs-password" data-toggle="tab">Change
Password</a></li>
<li><a href="#tabs-authentication" data-toggle="tab">Authentication
</a></li>
</a></li>{% endif %}>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="tabs-personal">
@ -40,21 +40,21 @@
<div class="form-group">
<label for="firstname">First Name</label> <input type="text"
class="form-control" name="firstname" id="firstname"
placeholder="{{ current_user.firstname }}">
placeholder="{{ current_user.firstname }}" {% if external_account %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="lastname">Last Name</label> <input type="text"
class="form-control" name="lastname" id="lastname"
placeholder="{{ current_user.lastname }}">
placeholder="{{ current_user.lastname }}" {% if external_account %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="email">E-mail</label> <input type="text"
class="form-control" name="email" id="email"
placeholder="{{ current_user.email }}">
</div>
placeholder="{{ current_user.email }}" {% if external_account %}disabled{% endif %}>
</div>{% if not external_account %}
<div class="form-group">
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
</div>
</div>{% endif %}
</form>
</div>
<div class="tab-pane" id="tabs-avatar">
@ -69,25 +69,25 @@
else %} <img
src="{{ current_user.email|email_to_gravatar_url(size=200) }}"
alt="" /> {% endif %}
</div>
</div>{% if not external_account %}
<div>
<label for="file">Select image</label> <input type="file"
id="file" name="file">
</div>
</div>
</div>{% endif %}
</div>{% if not external_account %}
<div>
<span class="label label-danger">NOTE! </span> <span>&nbsp;Only
supports <strong>.PNG, .JPG, .JPEG</strong>. The best size
to use is <strong>200x200</strong>.
</span>
</div>
</div>
</div>{% endif %}
</div>{% if not external_account %}
<div class="form-group">
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
</div>
</div>{% endif %}
</form>
</div>
<div class="tab-pane" id="tabs-password">
{% if not external_account %}<div class="tab-pane" id="tabs-password">
{% if not current_user.password %} Your account password is
managed via LDAP which isn't supported to change here. {% else
%}
@ -95,15 +95,15 @@
<div class="form-group">
<label for="password">New Password</label> <input
type="password" class="form-control" name="password"
id="newpassword" />
id="newpassword" {% if external_account %}disabled{% endif %} />
</div>
<div class="form-group">
<label for="rpassword">Re-type New Password</label> <input
type="password" class="form-control" name="rpassword"
id="rpassword" />
id="rpassword" {% if external_account %}disabled{% endif %} />
</div>
<div class="form-group">
<button type="submit" class="btn btn-flat btn-primary">Change
<button type="submit" class="btn btn-flat btn-primary" {% if external_account %}disabled{% endif %}>Change
password</button>
</div>
</form>
@ -112,7 +112,7 @@
<div class="tab-pane" id="tabs-authentication">
<form action="{{ user_profile }}" method="post">
<div class="form-group">
<input type="checkbox" id="otp_toggle" class="otp_toggle" {% if current_user.otp_secret %}checked{% endif %}>
<input type="checkbox" id="otp_toggle" class="otp_toggle" {% if current_user.otp_secret %}checked{% endif %} {% if external_account %}disabled{% endif %}>
<label for="otp_toggle">Enable Two Factor Authentication</label>
{% if current_user.otp_secret %}
<div id="token_information">
@ -124,7 +124,7 @@
{% endif %}
</div>
</form>
</div>
</div>{% endif %}
</div>
</div>
</div>

View file

@ -22,6 +22,10 @@ from app.lib import utils
from app.lib.log import logger
from app.decorators import admin_role_required, can_access_domain
if app.config['SAML_ENABLED']:
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.utils import OneLogin_Saml2_Utils
# LOG CONFIG
logging = logger('MODEL', app.config['LOG_LEVEL'], app.config['LOG_FILE']).config()
@ -184,6 +188,70 @@ def github_login():
return abort(400)
return github.authorize(callback=url_for('authorized', _external=True))
@app.route('/saml/login')
def saml_login():
if not app.config.get('SAML_ENABLED'):
return abort(400)
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
redirect_url=OneLogin_Saml2_Utils.get_self_url(req) + url_for('saml_authorized')
return redirect(auth.login(return_to=redirect_url))
@app.route('/saml/metadata')
def saml_metadata():
if not app.config.get('SAML_ENABLED'):
return abort(400)
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
settings = auth.get_settings()
metadata = settings.get_sp_metadata()
errors = settings.validate_metadata(metadata)
if len(errors) == 0:
resp = make_response(metadata, 200)
resp.headers['Content-Type'] = 'text/xml'
else:
resp = make_response(errors.join(', '), 500)
return resp
@app.route('/saml/authorized', methods=['GET', 'POST'])
def saml_authorized():
errors = []
if not app.config.get('SAML_ENABLED'):
return abort(400)
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
auth.process_response()
errors = auth.get_errors()
if len(errors) == 0:
session['samlUserdata'] = auth.get_attributes()
session['samlNameId'] = auth.get_nameid()
session['samlSessionIndex'] = auth.get_session_index()
self_url = OneLogin_Saml2_Utils.get_self_url(req)
self_url = self_url+req['script_name']
if 'RelayState' in request.form and self_url != request.form['RelayState']:
return redirect(auth.redirect_to(request.form['RelayState']))
user = User.query.filter_by(username=session['samlNameId'].lower()).first()
if not user:
# create user
user = User(username=session['samlNameId'],
plain_text_password = None,
email=session['samlNameId'])
user.create_local_user()
session['user_id'] = user.id
if session['samlUserdata'].has_key("email"):
user.email = session['samlUserdata']["email"][0].lower()
if session['samlUserdata'].has_key("givenname"):
user.firstname = session['samlUserdata']["givenname"][0]
if session['samlUserdata'].has_key("surname"):
user.lastname = session['samlUserdata']["surname"][0]
user.plain_text_password = None
user.update_profile()
session['external_auth'] = True
login_user(user, remember=False)
return redirect(url_for('index'))
else:
return render_template('errors/SAML.html', errors=errors)
@app.route('/login', methods=['GET', 'POST'])
@login_manager.unauthorized_handler
@ -191,9 +259,10 @@ def login():
LOGIN_TITLE = app.config['LOGIN_TITLE'] if 'LOGIN_TITLE' in app.config.keys() else ''
BASIC_ENABLED = app.config['BASIC_ENABLED']
SIGNUP_ENABLED = app.config['SIGNUP_ENABLED']
LDAP_ENABLE = app.config.get('LDAP_ENABLE')
LDAP_ENABLED = app.config.get('LDAP_ENABLED')
GITHUB_ENABLE = app.config.get('GITHUB_OAUTH_ENABLE')
GOOGLE_ENABLE = app.config.get('GOOGLE_OAUTH_ENABLE')
SAML_ENABLED = app.config.get('SAML_ENABLED')
if g.user is not None and current_user.is_authenticated:
return redirect(url_for('dashboard'))
@ -209,7 +278,7 @@ def login():
user = User(username=email,
firstname=first_name,
lastname=surname,
plain_text_password=gen_salt(7),
plain_text_password=None,
email=email)
result = user.create_local_user()
@ -219,6 +288,7 @@ def login():
session['user_id'] = user.id
login_user(user, remember = False)
session['external_auth'] = True
return redirect(url_for('index'))
if 'github_token' in session:
@ -228,7 +298,7 @@ def login():
if not user:
# create user
user = User(username=user_info['name'],
plain_text_password=gen_salt(7),
plain_text_password=None,
email=user_info['email'])
result = user.create_local_user()
@ -237,6 +307,7 @@ def login():
return redirect(url_for('login'))
session['user_id'] = user.id
session['external_auth'] = True
login_user(user, remember = False)
return redirect(url_for('index'))
@ -244,7 +315,8 @@ def login():
return render_template('login.html',
github_enabled=GITHUB_ENABLE,
google_enabled=GOOGLE_ENABLE,
ldap_enabled=LDAP_ENABLE, login_title=LOGIN_TITLE,
saml_enabled=SAML_ENABLED,
ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
# process login
@ -270,18 +342,37 @@ def login():
try:
auth = user.is_validate(method=auth_method)
if auth == False:
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLE, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,
saml_enabled=SAML_ENABLED)
except Exception as e:
return render_template('login.html', error=e, ldap_enabled=LDAP_ENABLE, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error=e, ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,
saml_enabled=SAML_ENABLED)
# check if user enabled OPT authentication
if user.otp_secret:
if otp_token:
good_token = user.verify_totp(otp_token)
if not good_token:
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLE, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,
saml_enabled=SAML_ENABLED)
else:
return render_template('login.html', error='Token required', ldap_enabled=LDAP_ENABLE, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error='Token required', ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled = GITHUB_ENABLE,
saml_enabled = SAML_ENABLED)
login_user(user, remember = remember_me)
return redirect(request.args.get('next') or url_for('index'))
@ -297,22 +388,53 @@ def login():
try:
result = user.create_local_user()
if result['status'] == True:
return render_template('login.html', username=username, password=password, ldap_enabled=LDAP_ENABLE, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
if result == True:
return render_template('login.html', username=username, password=password, ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,saml_enabled=SAML_ENABLED)
else:
return render_template('register.html', error=result['msg'])
except Exception as e:
return render_template('register.html', error=e)
@app.route('/logout')
def logout():
def clear_session():
session.pop('user_id', None)
session.pop('github_token', None)
session.pop('google_token', None)
session.clear()
logout_user()
@app.route('/logout')
def logout():
if app.config.get('SAML_ENABLED') and 'samlSessionIndex' in session and app.config.get('SAML_LOGOUT'):
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
if app.config.get('SAML_LOGOUT_URL'):
return redirect(auth.logout(name_id_format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
return_to = app.config.get('SAML_LOGOUT_URL'),
session_index = session['samlSessionIndex'], name_id=session['samlNameId']))
return redirect(auth.logout(name_id_format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
session_index = session['samlSessionIndex'],
name_id=session['samlNameId']))
clear_session()
return redirect(url_for('login'))
@app.route('/saml/sls')
def saml_logout():
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
url = auth.process_slo()
errors = auth.get_errors()
if len(errors) == 0:
clear_session()
if url is not None:
return redirect(url)
elif app.config.get('SAML_LOGOUT_URL') is not None:
return redirect(app.config.get('SAML_LOGOUT_URL'))
else:
return redirect(url_for('login'))
else:
return render_template('errors/SAML.html', errors=errors)
@app.route('/dashboard', methods=['GET', 'POST'])
@login_required
@ -330,7 +452,7 @@ def dashboard():
uptime = list([uptime for uptime in statistics if uptime['name'] == 'uptime'])[0]['value']
else:
uptime = 0
return render_template('dashboard.html', domain_count=domain_count, users=users, history_number=history_number, uptime=uptime, histories=history)
return render_template('dashboard.html', domain_count=domain_count, users=users, history_number=history_number, uptime=uptime, histories=history,pdns_version=app.config['PDNS_VERSION'])
@app.route('/dashboard-domains', methods=['GET'])
@ -407,27 +529,24 @@ def dashboard_domains():
def domain(domain_name):
r = Record()
domain = Domain.query.filter(Domain.name == domain_name).first()
if domain:
# query domain info from PowerDNS API
zone_info = r.get_record_data(domain.name)
if zone_info:
jrecords = zone_info['records']
else:
# can not get any record, API server might be down
return redirect(url_for('error', code=500))
if not domain:
return redirect(url_for('error', code=404))
records = []
#TODO: This should be done in the "model" instead of "view"
if NEW_SCHEMA:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
for subrecord in jr['records']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if subrecord['disabled'] else 'Active', ttl=jr['ttl'], data=subrecord['content'])
records.append(record)
else:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if jr['disabled'] else 'Active', ttl=jr['ttl'], data=jr['content'])
# query domain info from PowerDNS API
zone_info = r.get_record_data(domain.name)
if zone_info:
jrecords = zone_info['records']
else:
# can not get any record, API server might be down
return redirect(url_for('error', code=500))
records = []
#TODO: This should be done in the "model" instead of "view"
if NEW_SCHEMA:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
for subrecord in jr['records']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if subrecord['disabled'] else 'Active', ttl=jr['ttl'], data=subrecord['content'])
records.append(record)
if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name):
editable_records = app.config['RECORDS_ALLOW_EDIT']
@ -435,7 +554,15 @@ def domain(domain_name):
editable_records = app.config['REVERSE_ALLOW_EDIT']
return render_template('domain.html', domain=domain, records=records, editable_records=editable_records)
else:
return redirect(url_for('error', code=404))
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if jr['disabled'] else 'Active', ttl=jr['ttl'], data=jr['content'])
records.append(record)
if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name):
editable_records = app.config['FORWARD_RECORDS_ALLOW_EDIT']
else:
editable_records = app.config['REVERSE_RECORDS_ALLOW_EDIT']
return render_template('domain.html', domain=domain, records=records, editable_records=editable_records,pdns_version=app.config['PDNS_VERSION'])
@app.route('/admin/domain/add', methods=['GET', 'POST'])
@ -539,6 +666,30 @@ def domain_management(domain_name):
return redirect(url_for('domain_management', domain_name=domain_name))
@app.route('/admin/domain/<path:domain_name>/change_soa_setting', methods=['POST'])
@login_required
@admin_role_required
def domain_change_soa_edit_api(domain_name):
domain = Domain.query.filter(Domain.name == domain_name).first()
if not domain:
return redirect(url_for('error', code=404))
new_setting = request.form.get('soa_edit_api')
if new_setting == None:
return redirect(url_for('error', code=500))
if new_setting == '0':
return redirect(url_for('domain_management', domain_name=domain_name))
d = Domain()
status = d.update_soa_setting(domain_name=domain_name, soa_edit_api=new_setting)
if status['status'] != None:
users = User.query.all()
d = Domain(name=domain_name)
domain_user_ids = d.get_user()
return render_template('domain_management.html', domain=domain, users=users, domain_user_ids=domain_user_ids, status=status)
else:
return redirect(url_for('error', code=500))
@app.route('/domain/<path:domain_name>/apply', methods=['POST'], strict_slashes=False)
@login_required
@can_access_domain
@ -602,14 +753,36 @@ def record_delete(domain_name, record_name, record_type):
@app.route('/domain/<path:domain_name>/dnssec', methods=['GET'])
@can_access_domain
@login_required
@can_access_domain
def domain_dnssec(domain_name):
domain = Domain()
dnssec = domain.get_domain_dnssec(domain_name)
return make_response(jsonify(dnssec), 200)
@app.route('/domain/<path:domain_name>/dnssec/enable', methods=['GET'])
@login_required
@can_access_domain
def domain_dnssec_enable(domain_name):
domain = Domain()
dnssec = domain.enable_domain_dnssec(domain_name)
return make_response(jsonify(dnssec), 200)
@app.route('/domain/<path:domain_name>/dnssec/disable', methods=['GET'])
@login_required
@can_access_domain
def domain_dnssec_disable(domain_name):
domain = Domain()
dnssec = domain.get_domain_dnssec(domain_name)
for key in dnssec['dnssec']:
response = domain.delete_dnssec_key(domain_name,key['id']);
return make_response(jsonify( { 'status': 'ok', 'msg': 'DNSSEC removed.' } ))
@app.route('/domain/<path:domain_name>/managesetting', methods=['GET', 'POST'])
@login_required
@admin_role_required
@ -751,7 +924,7 @@ def create_template_from_zone():
return make_response(jsonify({'status': 'error', 'msg': 'Error when applying new changes'}), 500)
@app.route('/template/<string:template>/edit', methods=['GET'])
@app.route('/template/<path:template>/edit', methods=['GET'])
@login_required
@admin_role_required
def edit_template(template):
@ -771,7 +944,7 @@ def edit_template(template):
return redirect(url_for('templates'))
@app.route('/template/<string:template>/apply', methods=['POST'], strict_slashes=False)
@app.route('/template/<path:template>/apply', methods=['POST'], strict_slashes=False)
@login_required
def apply_records(template):
try:
@ -801,7 +974,7 @@ def apply_records(template):
return make_response(jsonify({'status': 'error', 'msg': 'Error when applying new changes'}), 500)
@app.route('/template/<string:template>/delete', methods=['GET'])
@app.route('/template/<path:template>/delete', methods=['GET'])
@login_required
@admin_role_required
def delete_template(template):
@ -976,8 +1149,11 @@ def admin_settings_edit(setting):
@app.route('/user/profile', methods=['GET', 'POST'])
@login_required
def user_profile():
if request.method == 'GET':
return render_template('user_profile.html')
external_account = False
if session.has_key('external_auth'):
external_account = session['external_auth']
if request.method == 'GET' or external_account:
return render_template('user_profile.html', external_account=external_account)
if request.method == 'POST':
# get new profile info
firstname = request.form['firstname'] if 'firstname' in request.form else ''
@ -1011,7 +1187,7 @@ def user_profile():
user = User(username=current_user.username, plain_text_password=new_password, firstname=firstname, lastname=lastname, email=email, avatar=save_file_name, reload_info=False)
user.update_profile()
return render_template('user_profile.html')
return render_template('user_profile.html', external_account=external_account)
@app.route('/user/avatar/<path:filename>')
@ -1065,12 +1241,12 @@ def dyndns_update():
domain = None
domain_segments = hostname.split('.')
for index in range(len(domain_segments)):
domain_segments.pop(0)
full_domain = '.'.join(domain_segments)
potential_domain = Domain.query.filter(Domain.name == full_domain).first()
if potential_domain in domains:
domain = potential_domain
break
domain_segments.pop(0)
if not domain:
history = History(msg="DynDNS update: attempted update of {0} but it does not exist for this user".format(hostname), created_by=current_user.username)

View file

@ -28,15 +28,15 @@ SQLA_DB_HOST = 'mysqlhostorip'
SQLA_DB_NAME = 'powerdnsadmin'
#MySQL
SQLALCHEMY_DATABASE_URI = 'mysql://'+SQLA_DB_USER+':'\
+SQLA_DB_PASSWORD+'@'+SQLA_DB_HOST+'/'+SQLA_DB_NAME
#SQLALCHEMY_DATABASE_URI = 'mysql://'+SQLA_DB_USER+':'\
# +SQLA_DB_PASSWORD+'@'+SQLA_DB_HOST+'/'+SQLA_DB_NAME
#SQLite
#SQLALCHEMY_DATABASE_URI = 'sqlite:////path/to/your/pdns.db'
SQLALCHEMY_DATABASE_URI = 'sqlite:///pdns.db'
SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'db_repository')
SQLALCHEMY_TRACK_MODIFICATIONS = True
# LDAP CONFIG
LDAP_ENABLE = False
LDAP_ENABLED = False
LDAP_TYPE = 'ldap'
LDAP_URI = 'ldaps://your-ldap-server:636'
# with LDAP_BIND_TYPE you can specify 'direct' or 'search' to use user credentials
@ -45,6 +45,9 @@ LDAP_BIND_TYPE= 'direct' # direct or search
LDAP_USERNAME = 'cn=dnsuser,ou=users,ou=services,dc=duykhanh,dc=me'
LDAP_PASSWORD = 'dnsuser'
LDAP_SEARCH_BASE = 'ou=System Admins,ou=People,dc=duykhanh,dc=me'
LDAP_GROUP_SECURITY = False
LDAP_ADMIN_GROUP = 'CN=PowerDNS-Admin Admin,OU=Custom,DC=ivan,DC=local'
LDAP_USER_GROUP = 'CN=PowerDNS-Admin User,OU=Custom,DC=ivan,DC=local'
# Additional options only if LDAP_TYPE=ldap
LDAP_USERNAMEFIELD = 'uid'
LDAP_FILTER = '(objectClass=inetorgperson)'
@ -73,6 +76,7 @@ GITHUB_OAUTH_URL = 'http://127.0.0.1:9191/api/v3/'
GITHUB_OAUTH_TOKEN = 'http://127.0.0.1:9191/oauth/token'
GITHUB_OAUTH_AUTHORIZE = 'http://127.0.0.1:9191/oauth/authorize'
# Google OAuth
GOOGLE_OAUTH_ENABLE = False
GOOGLE_OAUTH_CLIENT_ID = ' '
@ -85,6 +89,28 @@ GOOGLE_TOKEN_PARAMS = {
GOOGLE_AUTHORIZE_URL='https://accounts.google.com/o/oauth2/auth'
GOOGLE_BASE_URL='https://www.googleapis.com/oauth2/v1/'
# SAML Authnetication
SAML_ENABLED = False
SAML_DEBUG = True
SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml')
##Example for ADFS Metadata-URL
SAML_METADATA_URL = 'https://<hostname>/FederationMetadata/2007-06/FederationMetadata.xml'
#Cache Lifetime in Seconds
SAML_METADATA_CACHE_LIFETIME = 1
SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
SAML_SP_CONTACT_NAME = '<contact name>'
SAML_SP_CONTACT_MAIL = '<contact mail>'
#Cofigures if SAML tokens should be encrypted.
#If enabled a new app certificate will be generated on restart
SAML_SIGN_REQUEST = False
#Use SAML standard logout mechanism retreived from idp metadata
#If configured false don't care about SAML session on logout.
#Logout from PowerDNS-Admin only and keep SAML session authenticated.
SAML_LOGOUT = False
#Configure to redirect to a different url then PowerDNS-Admin login after SAML logout
#for example redirect to google.com after successful saml logout
#SAML_LOGOUT_URL = 'https://google.com'
#Default Auth
BASIC_ENABLED = True
SIGNUP_ENABLED = True
@ -95,10 +121,9 @@ PDNS_API_KEY = 'you never know'
PDNS_VERSION = '4.1.1'
# RECORDS ALLOWED TO EDIT
RECORDS_ALLOW_EDIT = ['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'PTR', 'SPF', 'SRV', 'TXT', 'NS']
# RECORDS ALLOWED TO EDIT FOR REVERSE DOMAINS
REVERSE_ALLOW_EDIT = ['PTR', 'NS']
RECORDS_ALLOW_EDIT = ['SOA', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'PTR', 'SPF', 'SRV', 'TXT', 'LOC', 'NS', 'PTR']
FORWARD_RECORDS_ALLOW_EDIT = ['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'PTR', 'SPF', 'SRV', 'TXT', 'LOC' 'NS']
REVERSE_RECORDS_ALLOW_EDIT = ['TXT', 'LOC', 'NS', 'PTR']
# EXPERIMENTAL FEATURES
PRETTY_IPV6_PTR = False

View file

@ -30,7 +30,7 @@ SIGNUP_ENABLED = True
# LDAP CONFIG
LDAP_ENABLE = False
LDAP_ENABLED = False
LDAP_TYPE = 'ldap'
LDAP_URI = 'ldaps://your-ldap-server:636'
# with LDAP_BIND_TYPE you can specify 'direct' or 'search' to use user credentials
@ -82,6 +82,29 @@ GOOGLE_AUTHORIZE_URL='https://accounts.google.com/o/oauth2/auth'
GOOGLE_BASE_URL='https://www.googleapis.com/oauth2/v1/'
# SAML AUTHENTICATION
SAML_ENABLED = False
SAML_DEBUG = True
SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml')
##Example for ADFS Metadata-URL
SAML_METADATA_URL = 'https://<hostname>/FederationMetadata/2007-06/FederationMetadata.xml'
#Cache Lifetime in Seconds
SAML_METADATA_CACHE_LIFETIME = 1
SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
SAML_SP_CONTACT_NAME = '<contact name>'
SAML_SP_CONTACT_MAIL = '<contact mail>'
#Cofigures if SAML tokens should be encrypted.
#If enabled a new app certificate will be generated on restart
SAML_SIGN_REQUEST = False
#Use SAML standard logout mechanism retreived from idp metadata
#If configured false don't care about SAML session on logout.
#Logout from PowerDNS-Admin only and keep SAML session authenticated.
SAML_LOGOUT = False
#Configure to redirect to a different url then PowerDNS-Admin login after SAML logout
#for example redirect to google.com after successful saml logout
#SAML_LOGOUT_URL = 'https://google.com'
# POWERDNS CONFIG
PDNS_STATS_URL = 'http://192.168.100.100:8081/'
PDNS_API_KEY = 'changeme'

29
docker/DOCKER.md Normal file
View file

@ -0,0 +1,29 @@
# Docker support
This is a updated version of the current docker support.
Container support is only for development purposes and should not be used in production without your own modificatins.
It's not needed to reload the container after you make changes in your current branch.
Images are currently not available in docker hub or other repository, so you have to build them yourself.
After a successful launch PowerDNS-Admin is reachable at http://localhost:9393
PowerDNS runs op port localhost udp/5353
## Basic commands:
### Build images
cd to this directory
```# ./build-images.sh```
### Run containers
Build the images before you run this command.
```# docker-compose up```
### Stop containers
```# docker-compose stop```
### Remove containers
```# docker-compose rm```

View file

@ -0,0 +1,42 @@
# PowerDNS-Admin
# Original from:
# https://github.com/ngoduykhanh/PowerDNS-Admin
#
# Initial image by winggundamth(/powerdns-mysql:trusty)
#
#
FROM alpine
MAINTAINER Jeroen Boonstra <jeroen [at] provider.nl>
ENV APP_USER=web APP_NAME=powerdns-admin
ENV APP_PATH=/home/$APP_USER/$APP_NAME
RUN apk add --update \
sudo \
python \
libxml2 \
xmlsec \
git \
python-dev \
py-pip \
build-base \
libxml2-dev \
xmlsec-dev \
libffi-dev \
openldap-dev \
&& adduser -S web
RUN sudo -u $APP_USER -H git clone --depth=1 \
https://github.com/thomasDOTde/PowerDNS-Admin $APP_PATH
RUN pip install -r $APP_PATH/requirements.txt
COPY docker-entrypoint.sh /docker-entrypoint.sh
USER $APP_USER
WORKDIR $APP_PATH
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["python", "run.py"]
EXPOSE 9393
VOLUME ["/var/log"]

View file

@ -0,0 +1,12 @@
#!/bin/sh
set -e
if [ "$WAITFOR_DB" -a ! -f "$APP_PATH/config.py" ]; then
cp "$APP_PATH/config_template_docker.py" "$APP_PATH/config.py"
fi
cd $APP_PATH && python create_db.py
# Start PowerDNS Admin
exec "$@"

View file

@ -0,0 +1,40 @@
# PowerDNS Authoritative Server with MySQL backend
# https://www.powerdns.com
#
# The PowerDNS Authoritative Server is the only solution that enables
# authoritative DNS service from all major databases, including but not limited
# to MySQL, PostgreSQL, SQLite3, Oracle, Sybase, Microsoft SQL Server, LDAP and
# plain text files.
FROM winggundamth/ubuntu-base:trusty
MAINTAINER Jirayut Nimsaeng <w [at] winginfotech.net>
ENV FROM_BASE=trusty-20160503.1
# 1) Add PowerDNS repository https://repo.powerdns.com
# 2) Install PowerDNS server
# 3) Clean to reduce Docker image size
ARG APT_CACHER_NG
COPY build-files /build-files
RUN [ -n "$APT_CACHER_NG" ] && \
echo "Acquire::http::Proxy \"$APT_CACHER_NG\";" \
> /etc/apt/apt.conf.d/11proxy || true; \
apt-get update && \
apt-get install -y curl && \
curl https://repo.powerdns.com/FD380FBB-pub.asc | apt-key add - && \
echo 'deb [arch=amd64] http://repo.powerdns.com/ubuntu trusty-auth-40 main' \
> /etc/apt/sources.list.d/pdns-$(lsb_release -cs).list && \
mv /build-files/pdns-pin /etc/apt/preferences.d/pdns && \
apt-get update && \
apt-get install -y pdns-server pdns-backend-mysql mysql-client && \
mv /build-files/pdns.mysql.conf /etc/powerdns/pdns.d/pdns.mysql.conf && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/11proxy /build-files \
/etc/powerdns/pdns.d/pdns.simplebind.conf
# 1) Copy Docker entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
EXPOSE 53/udp 53 8081
VOLUME ["/var/log", "/etc/powerdns"]
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/sbin/pdns_server", "--guardian=yes"]

View file

@ -0,0 +1,3 @@
Package: pdns-*
Pin: origin repo.powerdns.com
Pin-Priority: 600

View file

@ -0,0 +1,6 @@
launch+=gmysql
gmysql-port=3306
gmysql-host=172.17.0.1
gmysql-password=CHANGEME
gmysql-user=powerdns
gmysql-dbname=powerdns

View file

@ -0,0 +1,89 @@
#!/bin/sh
# Author: Jirayut 'Dear' Nimsaeng
#
set -e
PDNS_CONF_PATH="/etc/powerdns/pdns.conf"
PDNS_MYSQL_CONF_PATH="/etc/powerdns/pdns.d/pdns.mysql.conf"
PDNS_MYSQL_HOST="localhost"
PDNS_MYSQL_PORT="3306"
PDNS_MYSQL_USERNAME="powerdns"
PDNS_MYSQL_PASSWORD="$PDNS_DB_PASSWORD"
PDNS_MYSQL_DBNAME="powerdns"
if [ -z "$PDNS_DB_PASSWORD" ]; then
echo 'ERROR: PDNS_DB_PASSWORD environment variable not found'
exit 1
fi
# Configure variables
if [ "$PDNS_DB_HOST" ]; then
PDNS_MYSQL_HOST="$PDNS_DB_HOST"
fi
if [ "$PDNS_DB_PORT" ]; then
PDNS_MYSQL_PORT="$PDNS_DB_PORT"
fi
if [ "$PDNS_DB_USERNAME" ]; then
PDNS_MYSQL_USERNAME="$PDNS_DB_USERNAME"
fi
if [ "$PDNS_DB_NAME" ]; then
PDNS_MYSQL_DBNAME="$PDNS_DB_NAME"
fi
# Configure mysql backend
sed -i \
-e "s/^gmysql-host=.*/gmysql-host=$PDNS_MYSQL_HOST/g" \
-e "s/^gmysql-port=.*/gmysql-port=$PDNS_MYSQL_PORT/g" \
-e "s/^gmysql-user=.*/gmysql-user=$PDNS_MYSQL_USERNAME/g" \
-e "s/^gmysql-password=.*/gmysql-password=$PDNS_MYSQL_PASSWORD/g" \
-e "s/^gmysql-dbname=.*/gmysql-dbname=$PDNS_MYSQL_DBNAME/g" \
$PDNS_MYSQL_CONF_PATH
if [ "$PDNS_SLAVE" != "1" ]; then
# Configure to be master
sed -i \
-e "s/^#\?\smaster=.*/master=yes/g" \
-e "s/^#\?\sslave=.*/slave=no/g" \
$PDNS_CONF_PATH
else
# Configure to be slave
sed -i \
-e "s/^#\?\smaster=.*/master=no/g" \
-e "s/^#\?\sslave=.*/slave=yes/g" \
$PDNS_CONF_PATH
fi
if [ "$PDNS_API_KEY" ]; then
# Enable API
sed -i \
-e "s/^#\?\sapi=.*/api=yes/g" \
-e "s!^#\?\sapi-logfile=.*!api-logfile=/dev/stdout!g" \
-e "s/^#\?\sapi-key=.*/api-key=$PDNS_API_KEY/g" \
-e "s/^#\?\swebserver=.*/webserver=yes/g" \
-e "s/^#\?\swebserver-address=.*/webserver-address=0.0.0.0/g" \
$PDNS_CONF_PATH
fi
if [ "$PDNS_WEBSERVER_ALLOW_FROM" ]; then
sed -i \
"s/^#\?\swebserver-allow-from=.*/webserver-allow-from=$PDNS_WEBSERVER_ALLOW_FROM/g" \
$PDNS_CONF_PATH
fi
MYSQL_COMMAND="mysql -h $PDNS_MYSQL_HOST -P $PDNS_MYSQL_PORT -u $PDNS_MYSQL_USERNAME -p$PDNS_MYSQL_PASSWORD"
until $MYSQL_COMMAND -e ";" ; do
>&2 echo "MySQL is unavailable - sleeping"
sleep 1
done
>&2 echo "MySQL is up - initial database if not exists"
MYSQL_CHECK_IF_HAS_TABLE="SELECT COUNT(DISTINCT table_name) FROM information_schema.columns WHERE table_schema = '$PDNS_MYSQL_DBNAME';"
MYSQL_NUM_TABLE=$($MYSQL_COMMAND --batch --skip-column-names -e "$MYSQL_CHECK_IF_HAS_TABLE")
if [ "$MYSQL_NUM_TABLE" -eq 0 ]; then
$MYSQL_COMMAND -D $PDNS_MYSQL_DBNAME < /usr/share/doc/pdns-backend-mysql/schema.mysql.sql
fi
# Start PowerDNS
exec "$@"

10
docker/build-images.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
IMAGES=(PowerDNS-MySQL PowerDNS-Admin)
for IMAGE in "${IMAGES[@]}"
do
echo building $(basename $IMAGE | tr '[A-Z]' '[a-z]')
cd $IMAGE
docker build -t $(basename $IMAGE | tr '[A-Z]' '[a-z]') .
cd ..
done

50
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,50 @@
version: '2'
services:
powerdns-authoritative:
image: powerdns-mysql
hostname: powerdns-authoritative
depends_on:
- powerdns-authoritative-mariadb
links:
- powerdns-authoritative-mariadb:mysqldb
ports:
- 5553:53/udp
- 8081:8081
environment:
- PDNS_DB_HOST=mysqldb
- PDNS_DB_USERNAME=root
- PDNS_DB_NAME=powerdns
- PDNS_DB_PASSWORD=PowerDNSPassword
- PDNS_API_KEY=PowerDNSAPIKey
powerdns-authoritative-mariadb:
image: mariadb:10.1.15
hostname: powerdns-authoritative-mariadb
environment:
- MYSQL_DATABASE=powerdns
- MYSQL_ROOT_PASSWORD=PowerDNSPassword
powerdns-admin:
image: powerdns-admin
hostname: powerdns-admin
depends_on:
- powerdns-admin-mariadb
- powerdns-authoritative
links:
- powerdns-admin-mariadb:mysqldb
- powerdns-authoritative:powerdns-server
volumes:
- ../:/home/web/powerdns-admin
ports:
- 9393:9393
environment:
- WAITFOR_DB=60
powerdns-admin-mariadb:
image: mariadb:10.1.15
hostname: powerdns-admin-mariadb
environment:
- MYSQL_DATABASE=powerdns-admin
- MYSQL_ROOT_PASSWORD=PowerDNSAdminPassword

View file

@ -14,3 +14,6 @@ pyotp==2.2.6
qrcode==6.0
dnspython==1.15.0
gunicorn==19.7.1
python3-saml
pyOpenSSL>=0.15
pytz>=2017.3

2
run.py
View file

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
from app import app
from config import PORT

3
run_travis.sh Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
python run.py&
nosetests --with-coverage