Merge branch 'PowerDNS-Admin:master' into shine/config_table_key_uniqueness
18
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "*"
|
||||||
|
update-types: [ "version-update:semver-major" ]
|
||||||
|
labels: feature / dependency
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "*"
|
||||||
|
update-types: [ "version-update:semver-major" ]
|
||||||
|
labels: feature / dependency
|
91
.github/labels.yml
vendored
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
labels:
|
||||||
|
- name: bug / broken-feature
|
||||||
|
description: Existing feature malfunctioning or broken
|
||||||
|
color: 'd73a4a'
|
||||||
|
- name: bug / security-vulnerability
|
||||||
|
description: Security vulnerability identified with the application
|
||||||
|
color: 'd73a4a'
|
||||||
|
- name: docs / discussion
|
||||||
|
description: Documentation change proposals
|
||||||
|
color: '0075ca'
|
||||||
|
- name: docs / request
|
||||||
|
description: Documentation change request
|
||||||
|
color: '0075ca'
|
||||||
|
- name: feature / dependency
|
||||||
|
description: Existing feature dependency
|
||||||
|
color: '008672'
|
||||||
|
- name: feature / discussion
|
||||||
|
description: New or existing feature discussion
|
||||||
|
color: '008672'
|
||||||
|
- name: feature / request
|
||||||
|
description: New feature or enhancement request
|
||||||
|
color: '008672'
|
||||||
|
- name: help / deployment
|
||||||
|
description: Questions regarding application deployment
|
||||||
|
color: 'd876e3'
|
||||||
|
- name: help / features
|
||||||
|
description: Questions regarding the use of application features
|
||||||
|
color: 'd876e3'
|
||||||
|
- name: help / other
|
||||||
|
description: General questions not specific to application deployment or features
|
||||||
|
color: 'd876e3'
|
||||||
|
- name: mod / accepted
|
||||||
|
description: This request has been accepted
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / announcement
|
||||||
|
description: This is an admin announcement
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / changes-requested
|
||||||
|
description: Changes have been requested before proceeding
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / duplicate
|
||||||
|
description: This issue or pull request already exists
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / good-first-issue
|
||||||
|
description: Good for newcomers
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / help-wanted
|
||||||
|
description: Extra attention is needed
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / invalid
|
||||||
|
description: This doesn't seem right
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / rejected
|
||||||
|
description: This request has been rejected
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / reviewed
|
||||||
|
description: This request has been reviewed
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / reviewing
|
||||||
|
description: This request is being reviewed
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / stale
|
||||||
|
description: This request has gone stale
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / tested
|
||||||
|
description: This has been tested
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / testing
|
||||||
|
description: This is being tested
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: mod / wont-fix
|
||||||
|
description: This will not be worked on
|
||||||
|
color: 'e5ef23'
|
||||||
|
- name: skill / database
|
||||||
|
description: Requires a database skill-set
|
||||||
|
color: '5319E7'
|
||||||
|
- name: skill / docker
|
||||||
|
description: Requires a Docker skill-set
|
||||||
|
color: '5319E7'
|
||||||
|
- name: skill / documentation
|
||||||
|
description: Requires a documentation skill-set
|
||||||
|
color: '5319E7'
|
||||||
|
- name: skill / html
|
||||||
|
description: Requires a HTML skill-set
|
||||||
|
color: '5319E7'
|
||||||
|
- name: skill / javascript
|
||||||
|
description: Requires a JavaScript skill-set
|
||||||
|
color: '5319E7'
|
||||||
|
- name: skill / python
|
||||||
|
description: Requires a Python skill-set
|
||||||
|
color: '5319E7'
|
9
.github/stale.yml
vendored
@ -5,11 +5,12 @@ daysUntilClose: 7
|
|||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- pinned
|
- pinned
|
||||||
- security
|
- bug / broken-feature
|
||||||
- enhancement
|
- bug / security-vulnerability
|
||||||
- feature request
|
- feature / request
|
||||||
|
- mod / help-wanted
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: mod / stale
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
25
.github/workflows/build-and-publish.yml
vendored
@ -1,4 +1,7 @@
|
|||||||
|
name: 'Docker Image'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'master'
|
- 'master'
|
||||||
@ -7,45 +10,45 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-docker-image:
|
build-and-push-docker-image:
|
||||||
name: Build Docker image and push to repositories
|
name: Build Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Repository Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker Image Metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v3
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ngoduykhanh/powerdns-admin
|
powerdnsadmin/pda-legacy
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Docker Buildx Setup
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Docker Hub Authentication
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME_V2 }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN_V2 }}
|
||||||
|
|
||||||
- name: Build latest image
|
- name: Docker Image Build
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ngoduykhanh/powerdns-admin:latest
|
tags: powerdnsadmin/pda-legacy:latest
|
||||||
|
|
||||||
- name: Build release image
|
- name: Docker Image Release Tagging
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
with:
|
with:
|
||||||
|
73
.github/workflows/codeql-analysis.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [ master ]
|
||||||
|
schedule:
|
||||||
|
- cron: '45 2 * * 2'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript', 'python' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
||||||
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
|
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||||
|
|
||||||
|
# - run: |
|
||||||
|
# echo "Run, Build Application using script"
|
||||||
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
2
.gitignore
vendored
@ -38,5 +38,7 @@ node_modules
|
|||||||
powerdnsadmin/static/generated
|
powerdnsadmin/static/generated
|
||||||
.webassets-cache
|
.webassets-cache
|
||||||
.venv*
|
.venv*
|
||||||
|
venv*
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
yarn-error.log
|
||||||
|
16
README.md
@ -1,8 +1,8 @@
|
|||||||
# PowerDNS-Admin
|
# PowerDNS-Admin
|
||||||
A PowerDNS web interface with advanced features.
|
A PowerDNS web interface with advanced features.
|
||||||
|
|
||||||
[](https://lgtm.com/projects/g/ngoduykhanh/PowerDNS-Admin/context:python)
|
[](https://github.com/PowerDNS-Admin/PowerDNS-Admin/actions/workflows/codeql-analysis.yml)
|
||||||
[](https://lgtm.com/projects/g/ngoduykhanh/PowerDNS-Admin/context:javascript)
|
[](https://github.com/PowerDNS-Admin/PowerDNS-Admin/actions/workflows/build-and-publish.yml)
|
||||||
|
|
||||||
#### Features:
|
#### Features:
|
||||||
- Multiple domain management
|
- Multiple domain management
|
||||||
@ -21,11 +21,11 @@ A PowerDNS web interface with advanced features.
|
|||||||
|
|
||||||
## Running PowerDNS-Admin
|
## Running PowerDNS-Admin
|
||||||
There are several ways to run PowerDNS-Admin. The easiest way is to use Docker.
|
There are several ways to run PowerDNS-Admin. The easiest way is to use Docker.
|
||||||
If you are looking to install and run PowerDNS-Admin directly onto your system check out the [Wiki](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki#installation-guides) for ways to do that.
|
If you are looking to install and run PowerDNS-Admin directly onto your system check out the [Wiki](https://github.com/PowerDNS-Admin/PowerDNS-Admin/wiki#installation-guides) for ways to do that.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
This are two options to run PowerDNS-Admin using Docker.
|
Here are two options to run PowerDNS-Admin using Docker.
|
||||||
To get started as quickly as possible try option 1. If you want to make modifications to the configuration option 2 may be cleaner.
|
To get started as quickly as possible, try option 1. If you want to make modifications to the configuration option 2 may be cleaner.
|
||||||
|
|
||||||
#### Option 1: From Docker Hub
|
#### Option 1: From Docker Hub
|
||||||
The easiest is to just run the latest Docker image from Docker Hub:
|
The easiest is to just run the latest Docker image from Docker Hub:
|
||||||
@ -34,14 +34,14 @@ $ docker run -d \
|
|||||||
-e SECRET_KEY='a-very-secret-key' \
|
-e SECRET_KEY='a-very-secret-key' \
|
||||||
-v pda-data:/data \
|
-v pda-data:/data \
|
||||||
-p 9191:80 \
|
-p 9191:80 \
|
||||||
ngoduykhanh/powerdns-admin:latest
|
powerdnsadmin/pda-legacy:latest
|
||||||
```
|
```
|
||||||
This creates a volume called `pda-data` to persist the SQLite database with the configuration.
|
This creates a volume called `pda-data` to persist the SQLite database with the configuration.
|
||||||
|
|
||||||
#### Option 2: Using docker-compose
|
#### Option 2: Using docker-compose
|
||||||
1. Update the configuration
|
1. Update the configuration
|
||||||
Edit the `docker-compose.yml` file to update the database connection string in `SQLALCHEMY_DATABASE_URI`.
|
Edit the `docker-compose.yml` file to update the database connection string in `SQLALCHEMY_DATABASE_URI`.
|
||||||
Other environment variables are mentioned in the [legal_envvars](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/configs/docker_config.py#L5-L46).
|
Other environment variables are mentioned in the [legal_envvars](https://github.com/PowerDNS-Admin/PowerDNS-Admin/blob/master/configs/docker_config.py#L5-L46).
|
||||||
To use the Docker secrets feature it is possible to append `_FILE` to the environment variables and point to a file with the values stored in it.
|
To use the Docker secrets feature it is possible to append `_FILE` to the environment variables and point to a file with the values stored in it.
|
||||||
Make sure to set the environment variable `SECRET_KEY` to a long random string (https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)
|
Make sure to set the environment variable `SECRET_KEY` to a long random string (https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)
|
||||||
|
|
||||||
@ -56,5 +56,5 @@ You can then access PowerDNS-Admin by pointing your browser to http://localhost:
|
|||||||

|

|
||||||
|
|
||||||
## LICENSE
|
## LICENSE
|
||||||
MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE)
|
MIT. See [LICENSE](https://github.com/PowerDNS-Admin/PowerDNS-Admin/blob/master/LICENSE)
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
|||||||
SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
|
SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
|
||||||
BIND_ADDRESS = '0.0.0.0'
|
BIND_ADDRESS = '0.0.0.0'
|
||||||
PORT = 9191
|
PORT = 9191
|
||||||
OFFLINE_MODE = False
|
|
||||||
|
|
||||||
### DATABASE CONFIG
|
### DATABASE CONFIG
|
||||||
SQLA_DB_USER = 'pda'
|
SQLA_DB_USER = 'pda'
|
||||||
@ -113,6 +112,14 @@ SAML_ENABLED = False
|
|||||||
# ### the user is set as a non-administrator user.
|
# ### the user is set as a non-administrator user.
|
||||||
# #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin'
|
# #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin'
|
||||||
|
|
||||||
|
## Attribute to get admin status for groups with the IdP
|
||||||
|
# ### Default: Don't set administrator group with SAML attributes
|
||||||
|
#SAML_GROUP_ADMIN_NAME = 'GroupName'
|
||||||
|
|
||||||
|
## Attribute to get operator status for groups with the IdP
|
||||||
|
# ### Default: Don't set operator group with SAML attributes
|
||||||
|
#SAML_GROUP_OPERATOR_NAME = 'GroupName'
|
||||||
|
|
||||||
# ## Attribute to get account names from
|
# ## Attribute to get account names from
|
||||||
# ### Default: Don't control accounts with SAML attribute
|
# ### Default: Don't control accounts with SAML attribute
|
||||||
# ### If set, the user will be added and removed from accounts to match
|
# ### If set, the user will be added and removed from accounts to match
|
||||||
@ -120,6 +127,16 @@ SAML_ENABLED = False
|
|||||||
# ### be created and the user added to them.
|
# ### be created and the user added to them.
|
||||||
# SAML_ATTRIBUTE_ACCOUNT = 'https://example.edu/pdns-account'
|
# SAML_ATTRIBUTE_ACCOUNT = 'https://example.edu/pdns-account'
|
||||||
|
|
||||||
|
# ## Attribute name that aggregates group names
|
||||||
|
# ### Default: Don't collect IdP groups from SAML group attributes
|
||||||
|
# ### In Okta, you can assign administrators by group using "Group Attribute Statements."
|
||||||
|
# ### In this case, the SAML_ATTRIBUTE_GROUP will be the attribute name for a collection of
|
||||||
|
# ### groups passed in the SAML assertion. From there, you can specify a SAML_GROUP_ADMIN_NAME.
|
||||||
|
# ### If the user is a member of this group, and that group name is included in the collection,
|
||||||
|
# ### the user will be set as an administrator.
|
||||||
|
# #SAML_ATTRIBUTE_GROUP = 'https://example.edu/pdns-groups'
|
||||||
|
# #SAML_GROUP_ADMIN_NAME = 'PowerDNSAdmin-Administrators'
|
||||||
|
|
||||||
# SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
|
# SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
|
||||||
# SAML_SP_CONTACT_NAME = '<contact name>'
|
# SAML_SP_CONTACT_NAME = '<contact name>'
|
||||||
# SAML_SP_CONTACT_MAIL = '<contact mail>'
|
# SAML_SP_CONTACT_MAIL = '<contact mail>'
|
||||||
@ -133,8 +150,8 @@ SAML_ENABLED = False
|
|||||||
# CAUTION: For production use, usage of self-signed certificates it's highly discouraged.
|
# CAUTION: For production use, usage of self-signed certificates it's highly discouraged.
|
||||||
# Use certificates from trusted CA instead
|
# Use certificates from trusted CA instead
|
||||||
# ###########################################################################################
|
# ###########################################################################################
|
||||||
# SAML_CERT_FILE = '/etc/pki/powerdns-admin/cert.crt'
|
# SAML_CERT = '/etc/pki/powerdns-admin/cert.crt'
|
||||||
# SAML_CERT_KEY = '/etc/pki/powerdns-admin/key.pem'
|
# SAML_KEY = '/etc/pki/powerdns-admin/key.pem'
|
||||||
|
|
||||||
# Configures if SAML tokens should be encrypted.
|
# Configures if SAML tokens should be encrypted.
|
||||||
# SAML_SIGN_REQUEST = False
|
# SAML_SIGN_REQUEST = False
|
||||||
@ -148,6 +165,10 @@ SAML_ENABLED = False
|
|||||||
|
|
||||||
# #SAML_ASSERTION_ENCRYPTED = True
|
# #SAML_ASSERTION_ENCRYPTED = True
|
||||||
|
|
||||||
|
# Some IdPs, like Okta, do not return Attribute Statements by default
|
||||||
|
# Set the following to False if you are using Okta and not manually configuring Attribute Statements
|
||||||
|
# #SAML_WANT_ATTRIBUTE_STATEMENT = True
|
||||||
|
|
||||||
# Remote authentication settings
|
# Remote authentication settings
|
||||||
|
|
||||||
# Whether to enable remote user authentication or not
|
# Whether to enable remote user authentication or not
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
BIND_ADDRESS = '0.0.0.0'
|
BIND_ADDRESS = '0.0.0.0'
|
||||||
PORT = 80
|
PORT = 80
|
||||||
SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
|
SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
|
||||||
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
CSRF_COOKIE_HTTPONLY = True
|
||||||
|
|
||||||
legal_envvars = (
|
legal_envvars = (
|
||||||
'SECRET_KEY',
|
'SECRET_KEY',
|
||||||
@ -48,7 +50,6 @@ legal_envvars = (
|
|||||||
'SAML_LOGOUT',
|
'SAML_LOGOUT',
|
||||||
'SAML_LOGOUT_URL',
|
'SAML_LOGOUT_URL',
|
||||||
'SAML_ASSERTION_ENCRYPTED',
|
'SAML_ASSERTION_ENCRYPTED',
|
||||||
'OFFLINE_MODE',
|
|
||||||
'REMOTE_USER_LOGOUT_URL',
|
'REMOTE_USER_LOGOUT_URL',
|
||||||
'REMOTE_USER_COOKIES',
|
'REMOTE_USER_COOKIES',
|
||||||
'SIGNUP_ENABLED',
|
'SIGNUP_ENABLED',
|
||||||
@ -56,7 +57,9 @@ legal_envvars = (
|
|||||||
'LDAP_ENABLED',
|
'LDAP_ENABLED',
|
||||||
'SAML_CERT',
|
'SAML_CERT',
|
||||||
'SAML_KEY',
|
'SAML_KEY',
|
||||||
'FILESYSTEM_SESSIONS_ENABLED'
|
'FILESYSTEM_SESSIONS_ENABLED',
|
||||||
|
'SESSION_COOKIE_SECURE',
|
||||||
|
'CSRF_COOKIE_SECURE',
|
||||||
)
|
)
|
||||||
|
|
||||||
legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
|
legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
|
||||||
@ -73,12 +76,13 @@ legal_envvars_bool = (
|
|||||||
'SAML_WANT_MESSAGE_SIGNED',
|
'SAML_WANT_MESSAGE_SIGNED',
|
||||||
'SAML_LOGOUT',
|
'SAML_LOGOUT',
|
||||||
'SAML_ASSERTION_ENCRYPTED',
|
'SAML_ASSERTION_ENCRYPTED',
|
||||||
'OFFLINE_MODE',
|
|
||||||
'REMOTE_USER_ENABLED',
|
'REMOTE_USER_ENABLED',
|
||||||
'SIGNUP_ENABLED',
|
'SIGNUP_ENABLED',
|
||||||
'LOCAL_DB_ENABLED',
|
'LOCAL_DB_ENABLED',
|
||||||
'LDAP_ENABLED',
|
'LDAP_ENABLED',
|
||||||
'FILESYSTEM_SESSIONS_ENABLED'
|
'FILESYSTEM_SESSIONS_ENABLED',
|
||||||
|
'SESSION_COOKIE_SECURE',
|
||||||
|
'CSRF_COOKIE_SECURE',
|
||||||
)
|
)
|
||||||
|
|
||||||
# import everything from environment variables
|
# import everything from environment variables
|
||||||
|
16
deploy/auto-setup/setup_linux.sh
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Create a new group for PowerDNS-Admin
|
||||||
|
groupadd powerdnsadmin
|
||||||
|
|
||||||
|
# Create a user for PowerDNS-Admin
|
||||||
|
useradd --system -g powerdnsadmin powerdnsadmin
|
||||||
|
|
||||||
|
# Make the new user and group the owners of the PowerDNS-Admin files
|
||||||
|
chown -R powerdnsadmin:powerdnsadmin /opt/web/powerdns-admin
|
||||||
|
|
||||||
|
# Start the PowerDNS-Admin service
|
||||||
|
systemctl start powerdns-admin
|
||||||
|
|
||||||
|
# Enable the PowerDNS-Admin service to start automatically at boot
|
||||||
|
systemctl enable powerdns-admin
|
16
deploy/auto-setup/setup_win.bat
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
|
||||||
|
rem Create a new group for PowerDNS-Admin
|
||||||
|
net localgroup powerdnsadmin /add
|
||||||
|
|
||||||
|
rem Create a user for PowerDNS-Admin
|
||||||
|
net user powerdnsadmin /add /passwordchg:no /homedir:nul /active:yes /expires:never /passwordreq:no /s
|
||||||
|
|
||||||
|
rem Make the new user and group the owners of the PowerDNS-Admin files
|
||||||
|
icacls "C:\path\to\powerdns-admin" /setowner "powerdnsadmin"
|
||||||
|
|
||||||
|
rem Start the PowerDNS-Admin service
|
||||||
|
net start powerdns-admin
|
||||||
|
|
||||||
|
rem Enable the PowerDNS-Admin service to start automatically at boot
|
||||||
|
sc config powerdns-admin start= auto
|
15
deploy/docker/portainer.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: '3.3'
|
||||||
|
services:
|
||||||
|
|
||||||
|
core:
|
||||||
|
image: powerdnsadmin/pda-legacy:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- SECRET_KEY=INSECURE-CHANGE-ME-9I0DAtfkfj5JmBkPSaHah3ECAa8Df5KK
|
||||||
|
ports:
|
||||||
|
- "12000:9191"
|
||||||
|
volumes:
|
||||||
|
- "core_data:/data"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
core_data:
|
2
deploy/kubernetes/README.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Kubernetes
|
||||||
|
Example and simplified deployment for kubernetes.
|
8
deploy/kubernetes/configmap.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
kind: ConfigMap
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: powerdnsadmin-env
|
||||||
|
data:
|
||||||
|
FLASK_APP: powerdnsadmin/__init__.py
|
||||||
|
SECRET_KEY: changeme_secret
|
||||||
|
SQLALCHEMY_DATABASE_URI: 'mysql://user:password@host/database'
|
29
deploy/kubernetes/deployment.yml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
kind: Deployment
|
||||||
|
apiVersion: apps/v1
|
||||||
|
metadata:
|
||||||
|
name: powerdnsadmin
|
||||||
|
labels:
|
||||||
|
app: powerdnsadmin
|
||||||
|
spec:
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: powerdnsadmin
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: powerdnsadmin
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: powerdnsadmin
|
||||||
|
image: powerdnsadmin/pda-legacy
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: powerdnsadmin-env
|
||||||
|
imagePullPolicy: Always
|
||||||
|
restartPolicy: Always
|
15
deploy/kubernetes/service.yml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: powerdnsadmin
|
||||||
|
namespace: powerdnsadmin
|
||||||
|
labels:
|
||||||
|
app: powerdnsadmin
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
|
selector:
|
||||||
|
app: powerdnsadmin
|
||||||
|
|
@ -2,7 +2,7 @@ version: "3"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ngoduykhanh/powerdns-admin:latest
|
image: powerdnsadmin/pda-legacy:latest
|
||||||
container_name: powerdns_admin
|
container_name: powerdns_admin
|
||||||
ports:
|
ports:
|
||||||
- "9191:80"
|
- "9191:80"
|
||||||
@ -15,4 +15,3 @@ services:
|
|||||||
- GUNICORN_TIMEOUT=60
|
- GUNICORN_TIMEOUT=60
|
||||||
- GUNICORN_WORKERS=2
|
- GUNICORN_WORKERS=2
|
||||||
- GUNICORN_LOGLEVEL=DEBUG
|
- GUNICORN_LOGLEVEL=DEBUG
|
||||||
- OFFLINE_MODE=False # True for offline, False for external resources
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
FROM alpine:3.13 AS builder
|
FROM alpine:3.17 AS builder
|
||||||
LABEL maintainer="k@ndk.name"
|
|
||||||
|
|
||||||
ARG BUILD_DEPENDENCIES="build-base \
|
ARG BUILD_DEPENDENCIES="build-base \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
@ -30,7 +29,7 @@ COPY ./requirements.txt /build/requirements.txt
|
|||||||
|
|
||||||
# Get application dependencies
|
# Get application dependencies
|
||||||
RUN pip install --upgrade pip && \
|
RUN pip install --upgrade pip && \
|
||||||
pip install -r requirements.txt
|
pip install --use-pep517 -r requirements.txt
|
||||||
|
|
||||||
# Add sources
|
# Add sources
|
||||||
COPY . /build
|
COPY . /build
|
||||||
@ -66,12 +65,12 @@ RUN mkdir -p /app && \
|
|||||||
cp -r /build/configs/docker_config.py /app/configs
|
cp -r /build/configs/docker_config.py /app/configs
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
FROM alpine:3.13
|
FROM alpine:3.17
|
||||||
|
|
||||||
ENV FLASK_APP=/app/powerdnsadmin/__init__.py \
|
ENV FLASK_APP=/app/powerdnsadmin/__init__.py \
|
||||||
USER=pda
|
USER=pda
|
||||||
|
|
||||||
RUN apk add --no-cache mariadb-connector-c postgresql-client py3-gunicorn py3-psycopg2 xmlsec tzdata libcap && \
|
RUN apk add --no-cache mariadb-connector-c postgresql-client py3-gunicorn py3-pyldap py3-flask py3-psycopg2 xmlsec tzdata libcap && \
|
||||||
addgroup -S ${USER} && \
|
addgroup -S ${USER} && \
|
||||||
adduser -S -D -G ${USER} ${USER} && \
|
adduser -S -D -G ${USER} ${USER} && \
|
||||||
mkdir /data && \
|
mkdir /data && \
|
||||||
@ -80,7 +79,7 @@ RUN apk add --no-cache mariadb-connector-c postgresql-client py3-gunicorn py3-ps
|
|||||||
apk del libcap
|
apk del libcap
|
||||||
|
|
||||||
COPY --from=builder /usr/bin/flask /usr/bin/
|
COPY --from=builder /usr/bin/flask /usr/bin/
|
||||||
COPY --from=builder /usr/lib/python3.8/site-packages /usr/lib/python3.8/site-packages/
|
COPY --from=builder /usr/lib/python3.10/site-packages /usr/lib/python3.10/site-packages/
|
||||||
COPY --from=builder --chown=root:${USER} /app /app/
|
COPY --from=builder --chown=root:${USER} /app /app/
|
||||||
COPY ./docker/entrypoint.sh /usr/bin/
|
COPY ./docker/entrypoint.sh /usr/bin/
|
||||||
|
|
||||||
|
@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
#### Accessing the API
|
#### Accessing the API
|
||||||
|
|
||||||
|
PDA has its own API, that should not be confused with the PowerDNS API. Keep in mind that you have to enable PowerDNS API with a key that will be used by PDA to manage it. Therefore, you should use PDA created keys to browse PDA's API, on PDA's adress and port. They don't grant access to PowerDNS' API.
|
||||||
|
|
||||||
The PDA API consists of two distinct parts:
|
The PDA API consists of two distinct parts:
|
||||||
|
|
||||||
- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
|
- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
|
||||||
@ -18,7 +20,7 @@ The PDA API consists of two distinct parts:
|
|||||||
|
|
||||||
The requests to the API needs two headers:
|
The requests to the API needs two headers:
|
||||||
|
|
||||||
- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's armless to use it on each call
|
- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's harmless to use it on each call
|
||||||
- The authentication header to provide either the login:password basic authentication or the Api Key authentication.
|
- The authentication header to provide either the login:password basic authentication or the Api Key authentication.
|
||||||
|
|
||||||
When you access the `/powerdnsadmin` endpoint, you must use the Basic Auth:
|
When you access the `/powerdnsadmin` endpoint, you must use the Basic Auth:
|
||||||
|
46
docs/wiki/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# PowerDNS-Admin wiki
|
||||||
|
|
||||||
|
## Database Setup guides
|
||||||
|
|
||||||
|
- [MySQL / MariaDB](database-setup/Setup-MySQL-or-MariaDB.md)
|
||||||
|
- [PostgreSQL](database-setup/Setup-PostgreSQL.md)
|
||||||
|
|
||||||
|
## Installation guides
|
||||||
|
|
||||||
|
- [General (Read this first)](install/General.md)
|
||||||
|
- BSD:
|
||||||
|
- [Install on FreeBSD 12.1-RELEASE](install/Running-on-FreeBSD.md)
|
||||||
|
- Containers:
|
||||||
|
- [Install on Docker](install/Running-PowerDNS-Admin-on-Docker.md)
|
||||||
|
- Debian:
|
||||||
|
- [Install on Ubuntu or Debian](install/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md)
|
||||||
|
- Red-Hat:
|
||||||
|
- [Install on Centos 7](install/Running-PowerDNS-Admin-on-Centos-7.md)
|
||||||
|
- [Install on Fedora 23](install/Running-PowerDNS-Admin-on-Fedora-23.md)
|
||||||
|
- [Install on Fedora 30](install/Running-PowerDNS-Admin-on-Fedora-30.md)
|
||||||
|
|
||||||
|
### Post install Setup
|
||||||
|
|
||||||
|
- [Environment Variables](configuration/Environment-variables.md)
|
||||||
|
- [Getting started](configuration/Getting-started.md)
|
||||||
|
- SystemD:
|
||||||
|
- [Running PowerDNS-Admin as a service using Systemd](install/Running-PowerDNS-Admin-as-a-service-(Systemd).md)
|
||||||
|
|
||||||
|
### Web Server configuration
|
||||||
|
|
||||||
|
- [Supervisord](web-server/Supervisord-example.md)
|
||||||
|
- [Systemd](web-server/Systemd-example.md)
|
||||||
|
- [Systemd + Gunicorn + Nginx](web-server/Running-PowerDNS-Admin-with-Systemd-Gunicorn-and-Nginx.md)
|
||||||
|
- [Systemd + Gunicorn + Apache](web-server/Running-PowerDNS-Admin-with-Systemd,-Gunicorn-and-Apache.md)
|
||||||
|
- [uWSGI](web-server/uWSGI-example.md)
|
||||||
|
- [WSGI-Apache](web-server/WSGI-Apache-example.md)
|
||||||
|
|
||||||
|
## Using PowerDNS-Admin
|
||||||
|
|
||||||
|
- Setting up a domain
|
||||||
|
- Adding a record
|
||||||
|
- <whatever else>
|
||||||
|
|
||||||
|
## Feature usage
|
||||||
|
|
||||||
|
- [DynDNS2](features/DynDNS2.md)
|
@ -0,0 +1,34 @@
|
|||||||
|
Active Directory Setup - Tested with Windows Server 2012
|
||||||
|
|
||||||
|
1) Login as an admin to PowerDNS Admin
|
||||||
|
|
||||||
|
2) Go to Settings --> Authentication
|
||||||
|
|
||||||
|
3) Under Authentication, select LDAP
|
||||||
|
|
||||||
|
4) Click the Radio Button for Active Directory
|
||||||
|
|
||||||
|
5) Fill in the required info -
|
||||||
|
|
||||||
|
* LDAP URI - ldap://ip.of.your.domain.controller:389
|
||||||
|
* LDAP Base DN - dc=youdomain,dc=com
|
||||||
|
* Active Directory domain - yourdomain.com
|
||||||
|
* Basic filter - (objectCategory=person)
|
||||||
|
* the brackets here are **very important**
|
||||||
|
* Username field - sAMAccountName
|
||||||
|
* GROUP SECURITY - Status - On
|
||||||
|
* Admin group - CN=Your_AD_Admin_Group,OU=Your_AD_OU,DC=yourdomain,DC=com
|
||||||
|
* Operator group - CN=Your_AD_Operator_Group,OU=Your_AD_OU,DC=yourdomain,DC=com
|
||||||
|
* User group - CN=Your_AD_User_Group,OU=Your_AD_OU,DC=yourdomain,DC=com
|
||||||
|
|
||||||
|
6) Click Save
|
||||||
|
|
||||||
|
7) Logout and re-login as an LDAP user from each of the above groups.
|
||||||
|
|
||||||
|
If you're having problems getting the correct information for your groups, the following tool can be useful -
|
||||||
|
|
||||||
|
https://docs.microsoft.com/en-us/sysinternals/downloads/adexplorer
|
||||||
|
|
||||||
|
In our testing, groups with spaces in the name did not work, we had to create groups with underscores to get everything operational.
|
||||||
|
|
||||||
|
YMMV
|
61
docs/wiki/configuration/Environment-variables.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Supported environment variables
|
||||||
|
|
||||||
|
| Variable | Description | Required | Default value |
|
||||||
|
| ---------| ----------- | -------- | ------------- |
|
||||||
|
| BIND_ADDRESS |
|
||||||
|
| CSRF_COOKIE_SECURE |
|
||||||
|
| FILESYSTEM_SESSIONS_ENABLED |
|
||||||
|
| LDAP_ENABLED |
|
||||||
|
| LOCAL_DB_ENABLED |
|
||||||
|
| LOG_LEVEL |
|
||||||
|
| MAIL_DEBUG |
|
||||||
|
| MAIL_DEFAULT_SENDER |
|
||||||
|
| MAIL_PASSWORD |
|
||||||
|
| MAIL_PORT |
|
||||||
|
| MAIL_SERVER |
|
||||||
|
| MAIL_USERNAME |
|
||||||
|
| MAIL_USE_SSL |
|
||||||
|
| MAIL_USE_TLS |
|
||||||
|
| OFFLINE_MODE |
|
||||||
|
| OIDC_OAUTH_API_URL | | | |
|
||||||
|
| OIDC_OAUTH_AUTHORIZE_URL |
|
||||||
|
| OIDC_OAUTH_TOKEN_URL | | | |
|
||||||
|
| PORT |
|
||||||
|
| REMOTE_USER_COOKIES |
|
||||||
|
| REMOTE_USER_LOGOUT_URL |
|
||||||
|
| SALT |
|
||||||
|
| SAML_ASSERTION_ENCRYPTED |
|
||||||
|
| SAML_ATTRIBUTE_ACCOUNT |
|
||||||
|
| SAML_ATTRIBUTE_ADMIN |
|
||||||
|
| SAML_ATTRIBUTE_EMAIL |
|
||||||
|
| SAML_ATTRIBUTE_GIVENNAME |
|
||||||
|
| SAML_ATTRIBUTE_GROUP |
|
||||||
|
| SAML_ATTRIBUTE_NAME |
|
||||||
|
| SAML_ATTRIBUTE_SURNAME |
|
||||||
|
| SAML_ATTRIBUTE_USERNAME |
|
||||||
|
| SAML_CERT |
|
||||||
|
| SAML_DEBUG |
|
||||||
|
| SAML_ENABLED |
|
||||||
|
| SAML_GROUP_ADMIN_NAME |
|
||||||
|
| SAML_GROUP_TO_ACCOUNT_MAPPING |
|
||||||
|
| SAML_IDP_SSO_BINDING |
|
||||||
|
| SAML_IDP_ENTITY_ID |
|
||||||
|
| SAML_KEY |
|
||||||
|
| SAML_LOGOUT |
|
||||||
|
| SAML_LOGOUT_URL |
|
||||||
|
| SAML_METADATA_CACHE_LIFETIME |
|
||||||
|
| SAML_METADATA_URL |
|
||||||
|
| SAML_NAMEID_FORMAT |
|
||||||
|
| SAML_PATH |
|
||||||
|
| SAML_SIGN_REQUEST |
|
||||||
|
| SAML_SP_CONTACT_MAIL |
|
||||||
|
| SAML_SP_CONTACT_NAME |
|
||||||
|
| SAML_SP_ENTITY_ID |
|
||||||
|
| SAML_WANT_MESSAGE_SIGNED |
|
||||||
|
| SECRET_KEY | Flask secret key [^1] | Y | no default |
|
||||||
|
| SESSION_COOKIE_SECURE |
|
||||||
|
| SIGNUP_ENABLED |
|
||||||
|
| SQLALCHEMY_DATABASE_URI | SQL Alchemy URI to connect to database | N | no default |
|
||||||
|
| SQLALCHEMY_TRACK_MODIFICATIONS |
|
||||||
|
|
||||||
|
[^1]: Flask secret key (see https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY for how to generate)
|
16
docs/wiki/configuration/Getting-started.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Getting started with PowerDNS-Admin
|
||||||
|
|
||||||
|
|
||||||
|
In your FLASK_CONF (check the installation directions for where yours is) file, make sure you have the database URI filled in (in some previous documentation this was called config.py):
|
||||||
|
|
||||||
|
For MySQL / MariaDB:
|
||||||
|
```
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'mysql://username:password@127.0.0.1/db_name'
|
||||||
|
```
|
||||||
|
|
||||||
|
For Postgres:
|
||||||
|
```
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'postgresql://powerdnsadmin:powerdnsadmin@127.0.0.1/powerdnsadmindb'
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your web browser and go to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register a user. The first user will be in the Administrator role.
|
4
docs/wiki/database-setup/README.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Database setup guides
|
||||||
|
|
||||||
|
- [MySQL / MariaDB](Setup-MySQL-or-MariaDB.md)
|
||||||
|
- [PostgreSQL](Setup-PostgreSQL.md)
|
56
docs/wiki/database-setup/Setup-MySQL-or-MariaDB.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Setup MySQL database for PowerDNS-Admin
|
||||||
|
|
||||||
|
This guide will show you how to prepare a MySQL or MariaDB database for PowerDNS-Admin.
|
||||||
|
|
||||||
|
We assume the database is installed per your platform's directions (apt, yum, etc). Directions to do this can be found below:
|
||||||
|
- MariaDB:
|
||||||
|
- https://mariadb.com/kb/en/getting-installing-and-upgrading-mariadb/
|
||||||
|
- https://www.digitalocean.com/community/tutorials/how-to-install-mariadb-on-ubuntu-20-04
|
||||||
|
- MySQL:
|
||||||
|
- https://dev.mysql.com/downloads/mysql/
|
||||||
|
- https://www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-20-04
|
||||||
|
|
||||||
|
The following directions assume a default configuration and for productions setups `mysql_secure_installation` has been run.
|
||||||
|
|
||||||
|
## Setup database:
|
||||||
|
|
||||||
|
Connect to the database (Usually using `mysql -u root -p` if a password has been set on the root database user or `sudo mysql` if not), then enter the following:
|
||||||
|
```
|
||||||
|
CREATE DATABASE `powerdnsadmin` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
GRANT ALL PRIVILEGES ON `powerdnsadmin`.* TO 'pdnsadminuser'@'localhost' IDENTIFIED BY 'YOUR_PASSWORD_HERE';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
- If your database server is located on a different machine then change 'localhost' to '%'
|
||||||
|
- Replace YOUR_PASSWORD_HERE with a secure password.
|
||||||
|
|
||||||
|
Once there are no errors you can type `quit` in the mysql shell to exit from it.
|
||||||
|
|
||||||
|
## Install required packages:
|
||||||
|
### Red-hat based systems:
|
||||||
|
```
|
||||||
|
yum install MariaDB-shared mariadb-devel mysql-community-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian based systems:
|
||||||
|
```
|
||||||
|
apt install libmysqlclient-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install python packages:
|
||||||
|
```
|
||||||
|
pip3 install mysqlclient==2.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known issues:
|
||||||
|
|
||||||
|
Problem: If you plan to manage large zones, you may encounter some issues while applying changes. This is due to PowerDNS-Admin trying to insert the entire modified zone into the column history.detail.
|
||||||
|
|
||||||
|
Using MySQL/MariaDB, this column is created by default as TEXT and thus limited to 65,535 characters.
|
||||||
|
|
||||||
|
Solution: Convert the column to MEDIUMTEXT:
|
||||||
|
1. Connect to the database shell as described in the setup database section:
|
||||||
|
2. Execute the following commands:
|
||||||
|
```
|
||||||
|
USE powerdnsadmin;
|
||||||
|
ALTER TABLE history MODIFY detail MEDIUMTEXT;
|
||||||
|
```
|
84
docs/wiki/database-setup/Setup-PostgreSQL.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Setup Postgres database for PowerDNS-Admin
|
||||||
|
|
||||||
|
This guide will show you how to prepare a PostgreSQL database for PowerDNS-Admin.
|
||||||
|
|
||||||
|
We assume the database is installed per your platform's directions (apt, yum, etc). Directions to do this can be found below:
|
||||||
|
|
||||||
|
- https://www.postgresql.org/download/
|
||||||
|
- https://www.digitalocean.com/community/tutorials/how-to-install-postgresql-on-ubuntu-22-04-quickstart
|
||||||
|
|
||||||
|
We assume a default configuration and only the postgres user existing.
|
||||||
|
|
||||||
|
## Setup database
|
||||||
|
The below will create a database called powerdnsadmindb and a user of powerdnsadmin.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sudo su - postgres
|
||||||
|
$ createuser powerdnsadmin
|
||||||
|
$ createdb powerdnsadmindb
|
||||||
|
$ psql
|
||||||
|
postgres=# alter user powerdnsadmin with encrypted password 'powerdnsadmin';
|
||||||
|
postgres=# grant all privileges on database powerdnsadmindb to powerdnsadmin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Please change the information above (db, user, password) to fit your setup.
|
||||||
|
|
||||||
|
### Setup Remote access to database:
|
||||||
|
If your database is on a different server postgres does not allow remote connections by default.
|
||||||
|
|
||||||
|
To change this follow the below directions:
|
||||||
|
```
|
||||||
|
[root@host ~]$ sudo su - postgres
|
||||||
|
# Edit /var/lib/pgsql/data/postgresql.conf
|
||||||
|
# Change the following line:
|
||||||
|
listen_addresses = 'localhost'
|
||||||
|
# to:
|
||||||
|
listen_addresses = '*'
|
||||||
|
# Edit /var/lib/pgsql/data/pg_hba.conf
|
||||||
|
# Add the following lines to the end of the
|
||||||
|
host all all 0.0.0.0/0 md5
|
||||||
|
host all all ::/0 md5
|
||||||
|
|
||||||
|
[postgres@host ~]$ exit
|
||||||
|
[root@host ~]$ sudo systemctl restart postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
On debian based systems these files are located in:
|
||||||
|
```
|
||||||
|
/etc/postgresql/<version>/main/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install required packages:
|
||||||
|
### Red-hat based systems:
|
||||||
|
```
|
||||||
|
sudo yum install postgresql-libs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian based systems:
|
||||||
|
```
|
||||||
|
apt install libpq-dev python-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install python packages:
|
||||||
|
```
|
||||||
|
pip3 install psycopg2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Issues:
|
||||||
|
|
||||||
|
** To fill in **
|
||||||
|
|
||||||
|
|
||||||
|
## Docker (TODO: to move to docker docs)
|
||||||
|
TODO: Setup a local Docker postgres database ready to go (should probably move to the top).
|
||||||
|
```
|
||||||
|
docker run --name pdnsadmin-test -e BIND_ADDRESS=0.0.0.0
|
||||||
|
-e SECRET_KEY='a-very-secret-key'
|
||||||
|
-e PORT='9191'
|
||||||
|
-e SQLA_DB_USER='powerdns_admin_user'
|
||||||
|
-e SQLA_DB_PASSWORD='exceptionallysecure'
|
||||||
|
-e SQLA_DB_HOST='192.168.0.100'
|
||||||
|
-e SQLA_DB_NAME='powerdns_admin_test'
|
||||||
|
-v /data/node_modules:/var/www/powerdns-admin/node_modules -d -p 9191:9191 ixpict/powerdns-admin-pgsql:latest
|
||||||
|
```
|
16
docs/wiki/features/DynDNS2.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
Usage:
|
||||||
|
IPv4: http://user:pass@yournameserver.yoursite.tld/nic/update?hostname=record.domain.tld&myip=127.0.0.1
|
||||||
|
IPv6: http://user:pass@yournameserver.yoursite.tld/nic/update?hostname=record.domain.tld&myip=::1
|
||||||
|
Multiple IPs: http://user:pass@yournameserver.yoursite.tld/nic/update?hostname=record.domain.tld&myip=127.0.0.1,127.0.0.2,::1,::2
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- user needs to be a LOCAL user, not LDAP etc
|
||||||
|
- user must have already logged-in
|
||||||
|
- user needs to be added to Domain Access Control list of domain.tld - admin status (manage all) does not suffice
|
||||||
|
- record has to exist already - unless on-demand creation is allowed
|
||||||
|
- ipv4 address in myip field will change A record
|
||||||
|
- ipv6 address in myip field will change AAAA record
|
||||||
|
- use commas to separate multiple IP addresses in the myip field, mixing v4 & v6 is allowed
|
||||||
|
|
||||||
|
DynDNS also works without authentication header (user:pass@) when already authenticated via session cookie from /login, even with external auth like LDAP.
|
||||||
|
However Domain Access Control restriction still applies.
|
BIN
docs/wiki/images/readme_screenshots/fullscreen-dashboard.png
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
docs/wiki/images/readme_screenshots/fullscreen-domaincreate.png
Normal file
After Width: | Height: | Size: 113 KiB |
BIN
docs/wiki/images/readme_screenshots/fullscreen-domainmanage.png
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
docs/wiki/images/readme_screenshots/fullscreen-login.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
docs/wiki/images/webui/create.jpg
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
docs/wiki/images/webui/index.jpg
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
docs/wiki/images/webui/login.jpg
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
docs/wiki/install/Architecture.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
32
docs/wiki/install/General.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# General installation
|
||||||
|
|
||||||
|
## PowerDNS-Admin Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A PowerDNS-Admin installation includes four main components:
|
||||||
|
- PowerDNS-Admin Database
|
||||||
|
- PowerDNS-Admin Application Server
|
||||||
|
- PowerDNS-Admin Frontend Web server
|
||||||
|
- PowerDNS server that
|
||||||
|
|
||||||
|
All 3 components can be installed on one server or if your installation is large enough or for security reasons can be split across multiple servers.
|
||||||
|
|
||||||
|
## Requirements for PowerDNS-Admin:
|
||||||
|
- A linux based system. Others (Arch-based for example) may work but are currently not tested.
|
||||||
|
- Ubuntu versions tested:
|
||||||
|
- To fill in
|
||||||
|
- Red hat versions tested:
|
||||||
|
- To fill in
|
||||||
|
- Python versions tested:
|
||||||
|
- 3.6
|
||||||
|
- 3.7
|
||||||
|
- 3.8
|
||||||
|
- 3.9
|
||||||
|
- 3.10
|
||||||
|
- 3.11 - Failing due to issue with python3-saml later than 1.12.0
|
||||||
|
- A database for PowerDNS-Admin, if you are using a database for PowerDNS itself this must be separate to that database. The currently supported databases are:
|
||||||
|
- MySQL
|
||||||
|
- PostgreSQL
|
||||||
|
- SQLite
|
||||||
|
- A PowerDNS server that PowerDNS-Admin will manage.
|
@ -0,0 +1,72 @@
|
|||||||
|
***
|
||||||
|
**WARNING**
|
||||||
|
This just uses the development server for testing purposes. For production environments you should probably go with a more robust solution, like [gunicorn](web-server/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx.md) or a WSGI server.
|
||||||
|
***
|
||||||
|
|
||||||
|
### Following example shows a systemd unit file that can run PowerDNS-Admin
|
||||||
|
|
||||||
|
You shouldn't run PowerDNS-Admin as _root_, so let's start of with the user/group creation that will later run PowerDNS-Admin:
|
||||||
|
|
||||||
|
Create a new group for PowerDNS-Admin:
|
||||||
|
|
||||||
|
> sudo groupadd powerdnsadmin
|
||||||
|
|
||||||
|
Create a user for PowerDNS-Admin:
|
||||||
|
|
||||||
|
> sudo useradd --system -g powerdnsadmin powerdnsadmin
|
||||||
|
|
||||||
|
_`--system` creates a user without login-shell and password, suitable for running system services._
|
||||||
|
|
||||||
|
Create new systemd service file:
|
||||||
|
|
||||||
|
> sudo vim /etc/systemd/system/powerdns-admin.service
|
||||||
|
|
||||||
|
General example:
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS-Admin
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=powerdnsadmin
|
||||||
|
Group=powerdnsadmin
|
||||||
|
ExecStart=/opt/web/powerdns-admin/flask/bin/python ./run.py
|
||||||
|
WorkingDirectory=/opt/web/powerdns-admin
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Debian example:
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS-Admin
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=powerdnsadmin
|
||||||
|
Group=powerdnsadmin
|
||||||
|
Environment=PATH=/opt/web/powerdns-admin/flask/bin
|
||||||
|
ExecStart=/opt/web/powerdns-admin/flask/bin/python /opt/web/powerdns-admin/run.py
|
||||||
|
WorkingDirectory=/opt/web/powerdns-admin
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
Before starting the service, we need to make sure that the new user can work on the files in the PowerDNS-Admin folder:
|
||||||
|
> chown -R powerdnsadmin:powerdnsadmin /opt/web/powerdns-admin
|
||||||
|
|
||||||
|
After saving the file, we need to reload the systemd daemon:
|
||||||
|
> sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
We can now try to start the service:
|
||||||
|
> sudo systemctl start powerdns-admin
|
||||||
|
|
||||||
|
If you would like to start PowerDNS-Admin automagically at startup enable the service:
|
||||||
|
> systemctl enable powerdns-admin
|
||||||
|
|
||||||
|
Should the service not be up by now, consult your syslog. Generally this will be a file permission issue, or python not finding it's modules. See the Debian unit example to see how you can use systemd in a python `virtualenv`
|
83
docs/wiki/install/Running-PowerDNS-Admin-on-Centos-7.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Installing PowerDNS-Admin on CentOS 7
|
||||||
|
|
||||||
|
```
|
||||||
|
NOTE: If you are logged in as User and not root, add "sudo", or get root by sudo -i.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install required packages:
|
||||||
|
### Install needed repositories:
|
||||||
|
|
||||||
|
```
|
||||||
|
yum install epel-release
|
||||||
|
yum install https://repo.ius.io/ius-release-el7.rpm https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Python 3.6 and tools:
|
||||||
|
First remove python 3.4 if installed
|
||||||
|
```
|
||||||
|
yum remove python34*
|
||||||
|
yum autoremove
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
yum install python3 python3-devel python3-pip
|
||||||
|
pip3.6 install -U pip
|
||||||
|
pip install -U virtualenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install required packages for building python libraries from requirements.txt file:
|
||||||
|
```
|
||||||
|
yum install gcc openldap-devel xmlsec1-devel xmlsec1-openssl libtool-ltdl-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install yarn to build asset files + Nodejs 14:
|
||||||
|
```
|
||||||
|
curl -sL https://rpm.nodesource.com/setup_14.x | bash -
|
||||||
|
curl -sL https://dl.yarnpkg.com/rpm/yarn.repo -o /etc/yum.repos.d/yarn.repo
|
||||||
|
yum install yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkout source code and create virtualenv:
|
||||||
|
NOTE: Please adjust `/opt/web/powerdns-admin` to your local web application directory
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/PowerDNS-Admin/PowerDNS-Admin.git /opt/web/powerdns-admin
|
||||||
|
cd /opt/web/powerdns-admin
|
||||||
|
virtualenv -p python3 flask
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate your python3 environment and install libraries:
|
||||||
|
```
|
||||||
|
. ./flask/bin/activate
|
||||||
|
pip install python-dotenv
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running PowerDNS-Admin:
|
||||||
|
NOTE: The default config file is located at `./powerdnsadmin/default_config.py`. If you want to load another one, please set the `FLASK_CONF` environment variable. E.g.
|
||||||
|
```bash
|
||||||
|
export FLASK_CONF=../configs/development.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the database schema:
|
||||||
|
```
|
||||||
|
export FLASK_APP=powerdnsadmin/__init__.py
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also, we should generate asset files:**
|
||||||
|
```
|
||||||
|
yarn install --pure-lockfile
|
||||||
|
flask assets build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Now you can run PowerDNS-Admin by command:**
|
||||||
|
```
|
||||||
|
./run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your web browser and access to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register an user. The first user will be in Administrator role.
|
||||||
|
|
||||||
|
At the first time you login into the PDA UI, you will be redirected to setting page to configure the PDNS API information.
|
||||||
|
|
||||||
|
_**Note:**_ For production environment, i would recommend you to run PowerDNS-Admin with gunicorn or uwsgi instead of flask's built-in web server, take a look at WIKI page to see how to configure them.
|
14
docs/wiki/install/Running-PowerDNS-Admin-on-Docker.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Installation on docker
|
||||||
|
|
||||||
|
The Docker image is powerdnsadmin/pda-legacy available on [DockerHub](https://hub.docker.com/r/powerdnsadmin/pda-legacy)
|
||||||
|
|
||||||
|
The supported environment variables to configure the container are located [here](../configuration/Environment-variables.md).
|
||||||
|
|
||||||
|
You can run the container and expose the web server on port 9191 using:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-e SECRET_KEY='a-very-secret-key' \
|
||||||
|
-v pda-data:/data \
|
||||||
|
-p 9191:80 \
|
||||||
|
powerdnsadmin/pda-legacy:latest
|
||||||
|
```
|
1
docs/wiki/install/Running-PowerDNS-Admin-on-Fedora-23.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
Please refer to CentOS guide: [Running-PowerDNS-Admin-on-Centos-7](Running-PowerDNS-Admin-on-Centos-7.md)
|
82
docs/wiki/install/Running-PowerDNS-Admin-on-Fedora-30.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
```
|
||||||
|
NOTE: If you are logged in as User and not root, add "sudo", or get root by sudo -i.
|
||||||
|
Normally under centos you are anyway mostly root.
|
||||||
|
```
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## Install required packages
|
||||||
|
|
||||||
|
**Install Python and requirements**
|
||||||
|
```bash
|
||||||
|
dnf install python37 python3-devel python3-pip
|
||||||
|
```
|
||||||
|
**Install Backend and Environment prerequisites**
|
||||||
|
```bash
|
||||||
|
dnf install mariadb-devel mariadb-common openldap-devel xmlsec1-devel xmlsec1-openssl libtool-ltdl-devel
|
||||||
|
```
|
||||||
|
**Install Development tools**
|
||||||
|
```bash
|
||||||
|
dnf install gcc gc make
|
||||||
|
```
|
||||||
|
**Install PIP**
|
||||||
|
```bash
|
||||||
|
pip3.7 install -U pip
|
||||||
|
```
|
||||||
|
**Install Virtual Environment**
|
||||||
|
```bash
|
||||||
|
pip install -U virtualenv
|
||||||
|
```
|
||||||
|
**Install Yarn for building NodeJS asset files:**
|
||||||
|
```bash
|
||||||
|
dnf install npm
|
||||||
|
npm install yarn -g
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clone the PowerDNS-Admin repository to the installation path:
|
||||||
|
```bash
|
||||||
|
cd /opt/web/
|
||||||
|
git clone https://github.com/PowerDNS-Admin/PowerDNS-Admin.git powerdns-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prepare the Virtual Environment:**
|
||||||
|
```bash
|
||||||
|
cd /opt/web/powerdns-admin
|
||||||
|
virtualenv -p python3 flask
|
||||||
|
```
|
||||||
|
**Activate the Python Environment and install libraries**
|
||||||
|
```bash
|
||||||
|
. ./flask/bin/activate
|
||||||
|
pip install python-dotenv
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running PowerDNS-Admin
|
||||||
|
|
||||||
|
NOTE: The default config file is located at `./powerdnsadmin/default_config.py`. If you want to load another one, please set the `FLASK_CONF` environment variable. E.g.
|
||||||
|
```bash
|
||||||
|
export FLASK_CONF=../configs/development.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then create the database schema by running:**
|
||||||
|
```
|
||||||
|
(flask) [khanh@localhost powerdns-admin] export FLASK_APP=powerdnsadmin/__init__.py
|
||||||
|
(flask) [khanh@localhost powerdns-admin] flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also, we should generate asset files:**
|
||||||
|
```
|
||||||
|
(flask) [khanh@localhost powerdns-admin] yarn install --pure-lockfile
|
||||||
|
(flask) [khanh@localhost powerdns-admin] flask assets build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Now you can run PowerDNS-Admin by command:**
|
||||||
|
```
|
||||||
|
(flask) [khanh@localhost powerdns-admin] ./run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your web browser and access to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register an user. The first user will be in Administrator role.
|
||||||
|
|
||||||
|
At the first time you login into the PDA UI, you will be redirected to setting page to configure the PDNS API information.
|
||||||
|
|
||||||
|
_**Note:**_ For production environment, i recommend to run PowerDNS-Admin with WSGI over Apache instead of flask's built-in web server...
|
||||||
|
Take a look at [WSGI Apache Example](web-server/WSGI-Apache-example#fedora) WIKI page to see how to configure it.
|
@ -0,0 +1,79 @@
|
|||||||
|
# Installing PowerDNS-Admin on Ubuntu or Debian based systems
|
||||||
|
|
||||||
|
First setup your database accordingly:
|
||||||
|
[Database Setup](../database-setup/README.md)
|
||||||
|
|
||||||
|
## Install required packages:
|
||||||
|
|
||||||
|
### Install required packages for building python libraries from requirements.txt file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3-dev git libsasl2-dev libldap2-dev libssl-dev libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev pkg-config apt-transport-https virtualenv build-essential curl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install NodeJs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sL https://deb.nodesource.com/setup_14.x | bash -
|
||||||
|
apt install -y nodejs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install yarn to build asset files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||||
|
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||||
|
sudo apt update -y
|
||||||
|
sudo apt install -y yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkout source code and create virtualenv
|
||||||
|
_**Note:**_ Please adjust `/opt/web/powerdns-admin` to your local web application directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/PowerDNS-Admin/PowerDNS-Admin.git /opt/web/powerdns-admin
|
||||||
|
cd /opt/web/powerdns-admin
|
||||||
|
python3 -mvenv ./venv
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate your python3 environment and install libraries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
## Running PowerDNS-Admin
|
||||||
|
|
||||||
|
Create PowerDNS-Admin config file and make the changes necessary for your use case. Make sure to change `SECRET_KEY` to a long random string that you generated yourself ([see Flask docs](https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)), do not use the pre-defined one. E.g.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /opt/web/powerdns-admin/configs/development.py /opt/web/powerdns-admin/configs/production.py
|
||||||
|
vim /opt/web/powerdns-admin/configs/production.py
|
||||||
|
export FLASK_CONF=../configs/production.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Do the DB migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FLASK_APP=powerdnsadmin/__init__.py
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
Then generate asset files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install --pure-lockfile
|
||||||
|
flask assets build
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can run PowerDNS-Admin by command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This is good for testing, but for production usage, you should use gunicorn or uwsgi. See [Running PowerDNS Admin with Systemd, Gunicorn and Nginx](../web-server/Running-PowerDNS-Admin-with-Systemd-Gunicorn-and-Nginx.md) for instructions.
|
||||||
|
|
||||||
|
|
||||||
|
From here you can now follow the [Getting started guide](../configuration/Getting-started.md).
|
102
docs/wiki/install/Running-on-FreeBSD.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
On [FreeBSD](https://www.freebsd.org/), most software is installed using `pkg`. You can always build from source with the Ports system. This method uses as many binary ports as possible, and builds some python packages from source. It installs all the required runtimes in the global system (e.g., python, node, yarn) and then builds a virtual python environment in `/opt/python`. Likewise, it installs powerdns-admin in `/opt/powerdns-admin`.
|
||||||
|
|
||||||
|
### Build an area to host files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/python
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install prerequisite runtimes: python, node, yarn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pkg install git python3 curl node12 yarn-node12
|
||||||
|
sudo pkg install libxml2 libxslt pkgconf py37-xmlsec py37-cffi py37-ldap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Check Out Source Code
|
||||||
|
_**Note:**_ Please adjust `/opt/powerdns-admin` to your local web application directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/PowerDNS-Admin/PowerDNS-Admin.git /opt/powerdns-admin
|
||||||
|
cd /opt/powerdns-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make Virtual Python Environment
|
||||||
|
|
||||||
|
Make a virtual environment for python. Activate your python3 environment and install libraries. It's easier to install some python libraries as system packages, so we add the `--system-site-packages` option to pull those in.
|
||||||
|
|
||||||
|
> Note: I couldn't get `python-ldap` to install correctly, and I don't need it. I commented out the `python-ldap` line in `requirements.txt` and it all built and installed correctly. If you don't intend to use LDAP authentication, you'll be fine. If you need LDAP authentication, it probably won't work.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv /web/python --system-site-packages
|
||||||
|
source /web/python/bin/activate
|
||||||
|
/web/python/bin/python3 -m pip install --upgrade pip wheel
|
||||||
|
# this command comments out python-ldap
|
||||||
|
perl -pi -e 's,^python-ldap,\# python-ldap,' requirements.txt
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuring PowerDNS-Admin
|
||||||
|
|
||||||
|
NOTE: The default config file is located at `./powerdnsadmin/default_config.py`. If you want to load another one, please set the `FLASK_CONF` environment variable. E.g.
|
||||||
|
```bash
|
||||||
|
cp configs/development.py /opt/powerdns-admin/production.py
|
||||||
|
export FLASK_CONF=/opt/powerdns-admin/production.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update the Flask config
|
||||||
|
|
||||||
|
Edit your flask python configuration. Insert values for the database server, user name, password, etc.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vim $FLASK_CONF
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the values below to something sensible
|
||||||
|
```python
|
||||||
|
### BASIC APP CONFIG
|
||||||
|
SALT = '[something]'
|
||||||
|
SECRET_KEY = '[something]'
|
||||||
|
BIND_ADDRESS = '0.0.0.0'
|
||||||
|
PORT = 9191
|
||||||
|
OFFLINE_MODE = False
|
||||||
|
|
||||||
|
### DATABASE CONFIG
|
||||||
|
SQLA_DB_USER = 'pda'
|
||||||
|
SQLA_DB_PASSWORD = 'changeme'
|
||||||
|
SQLA_DB_HOST = '127.0.0.1'
|
||||||
|
SQLA_DB_NAME = 'pda'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||||
|
```
|
||||||
|
|
||||||
|
Be sure to uncomment one of the lines like `SQLALCHEMY_DATABASE_URI`.
|
||||||
|
|
||||||
|
### Initialise the database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FLASK_APP=powerdnsadmin/__init__.py
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build web assets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install --pure-lockfile
|
||||||
|
flask assets build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running PowerDNS-Admin
|
||||||
|
|
||||||
|
Now you can run PowerDNS-Admin by command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your web browser and go to `http://localhost:9191` to visit PowerDNS-Admin web interface. Register a user. The first user will be in the Administrator role.
|
||||||
|
|
||||||
|
### Running at startup
|
||||||
|
|
||||||
|
This is good for testing, but for production usage, you should use gunicorn or uwsgi. See [Running PowerDNS Admin with Systemd, Gunicorn and Nginx](../web-server/Running-PowerDNS-Admin-with-Systemd,-Gunicorn--and--Nginx.md) for instructions.
|
||||||
|
|
||||||
|
The right approach long-term is to create a startup script in `/usr/local/etc/rc.d` and enable it through `/etc/rc.conf`.
|
@ -0,0 +1,97 @@
|
|||||||
|
Following is an example showing how to run PowerDNS-Admin with systemd, gunicorn and Apache:
|
||||||
|
|
||||||
|
The systemd and gunicorn setup are the same as for with nginx. This set of configurations assumes you have installed your PowerDNS-Admin under /opt/powerdns-admin and are running with a package-installed gunicorn.
|
||||||
|
|
||||||
|
## Configure systemd service
|
||||||
|
|
||||||
|
`$ sudo vim /etc/systemd/system/powerdns-admin.service`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS web administration service
|
||||||
|
Requires=powerdns-admin.socket
|
||||||
|
Wants=network.target
|
||||||
|
After=network.target mysqld.service postgresql.service slapd.service mariadb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
PIDFile=/run/powerdns-admin/pid
|
||||||
|
User=pdnsa
|
||||||
|
Group=pdnsa
|
||||||
|
WorkingDirectory=/opt/powerdns-admin
|
||||||
|
ExecStart=/usr/bin/gunicorn-3.6 --workers 4 --log-level info --pid /run/powerdns-admin/pid --bind unix:/run/powerdns-admin/socket "powerdnsadmin:create_app(config='config.py')"
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
ExecStop=/bin/kill -s TERM $MAINPID
|
||||||
|
PrivateTmp=true
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StartLimitInterval=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo vim /etc/systemd/system/powerdns-admin.socket`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS-Admin socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/powerdns-admin/socket
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo vim /etc/tmpfiles.d/powerdns-admin.conf`
|
||||||
|
|
||||||
|
```
|
||||||
|
d /run/powerdns-admin 0755 pdnsa pdnsa -
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `sudo systemctl daemon-reload; sudo systemctl start powerdns-admin.socket; sudo systemctl enable powerdns-admin.socket` to start the Powerdns-Admin service and make it run on boot.
|
||||||
|
|
||||||
|
## Sample Apache configuration
|
||||||
|
|
||||||
|
This includes SSL redirect.
|
||||||
|
|
||||||
|
```
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName dnsadmin.company.com
|
||||||
|
DocumentRoot "/opt/powerdns-admin"
|
||||||
|
<Directory "/opt/powerdns-admin">
|
||||||
|
Options Indexes FollowSymLinks MultiViews
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
Redirect permanent / https://dnsadmin.company.com/
|
||||||
|
</VirtualHost>
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName dnsadmin.company.com
|
||||||
|
DocumentRoot "/opt/powerdns-admin/powerdnsadmin"
|
||||||
|
## Alias declarations for resources outside the DocumentRoot
|
||||||
|
Alias /static/ "/opt/powerdns-admin/powerdnsadmin/static/"
|
||||||
|
Alias /favicon.ico "/opt/powerdns-admin/powerdnsadmin/static/favicon.ico"
|
||||||
|
<Directory "/opt/powerdns-admin">
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
## Proxy rules
|
||||||
|
ProxyRequests Off
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass /static/ !
|
||||||
|
ProxyPass /favicon.ico !
|
||||||
|
ProxyPass / unix:/var/run/powerdns-admin/socket|http://%{HTTP_HOST}/
|
||||||
|
ProxyPassReverse / unix:/var/run/powerdns-admin/socket|http://%{HTTP_HOST}/
|
||||||
|
## SSL directives
|
||||||
|
SSLEngine on
|
||||||
|
SSLCertificateFile "/etc/pki/tls/certs/dnsadmin.company.com.crt"
|
||||||
|
SSLCertificateKeyFile "/etc/pki/tls/private/dnsadmin.company.com.key"
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
* The above assumes your installation is under /opt/powerdns-admin
|
||||||
|
* The hostname is assumed as dnsadmin.company.com
|
||||||
|
* gunicorn is installed in /usr/bin via a package (as in the case with CentOS/Redhat 7) and you have Python 3.6 installed. If you prefer to use flask then see the systemd configuration for nginx.
|
||||||
|
* On Ubuntu / Debian systems, you may need to enable the "proxy_http" module with `a2enmod proxy_http`
|
@ -0,0 +1,181 @@
|
|||||||
|
Following is an example showing how to run PowerDNS-Admin with systemd, gunicorn and nginx:
|
||||||
|
|
||||||
|
## Configure PowerDNS-Admin
|
||||||
|
|
||||||
|
Create PowerDNS-Admin config file and make the changes necessary for your use case. Make sure to change `SECRET_KEY` to a long random string that you generated yourself ([see Flask docs](https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)), do not use the pre-defined one.
|
||||||
|
```
|
||||||
|
$ cp /opt/web/powerdns-admin/configs/development.py /opt/web/powerdns-admin/configs/production.py
|
||||||
|
$ vim /opt/web/powerdns-admin/configs/production.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure systemd service
|
||||||
|
|
||||||
|
`$ sudo vim /etc/systemd/system/powerdns-admin.service`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS-Admin
|
||||||
|
Requires=powerdns-admin.socket
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
PIDFile=/run/powerdns-admin/pid
|
||||||
|
User=pdns
|
||||||
|
Group=pdns
|
||||||
|
WorkingDirectory=/opt/web/powerdns-admin
|
||||||
|
ExecStartPre=+mkdir -p /run/powerdns-admin/
|
||||||
|
ExecStartPre=+chown pdns:pdns -R /run/powerdns-admin/
|
||||||
|
ExecStart=/usr/local/bin/gunicorn --pid /run/powerdns-admin/pid --bind unix:/run/powerdns-admin/socket 'powerdnsadmin:create_app()'
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
ExecStop=/bin/kill -s TERM $MAINPID
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo systemctl edit powerdns-admin.service`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Service]
|
||||||
|
Environment="FLASK_CONF=../configs/production.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo vim /etc/systemd/system/powerdns-admin.socket`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS-Admin socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/powerdns-admin/socket
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo vim /etc/tmpfiles.d/powerdns-admin.conf`
|
||||||
|
|
||||||
|
```
|
||||||
|
d /run/powerdns-admin 0755 pdns pdns -
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `sudo systemctl daemon-reload; sudo systemctl start powerdns-admin.socket; sudo systemctl enable powerdns-admin.socket` to start the Powerdns-Admin service and make it run on boot.
|
||||||
|
|
||||||
|
## Sample nginx configuration
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
listen *:80;
|
||||||
|
server_name powerdns-admin.local www.powerdns-admin.local;
|
||||||
|
|
||||||
|
index index.html index.htm index.php;
|
||||||
|
root /opt/web/powerdns-admin;
|
||||||
|
access_log /var/log/nginx/powerdns-admin.local.access.log combined;
|
||||||
|
error_log /var/log/nginx/powerdns-admin.local.error.log;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
client_body_buffer_size 128k;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_connect_timeout 90;
|
||||||
|
proxy_send_timeout 90;
|
||||||
|
proxy_read_timeout 90;
|
||||||
|
proxy_buffers 32 4k;
|
||||||
|
proxy_buffer_size 8k;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_headers_hash_bucket_size 64;
|
||||||
|
|
||||||
|
location ~ ^/static/ {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
root /opt/web/powerdns-admin/powerdnsadmin;
|
||||||
|
|
||||||
|
location ~* \.(jpg|jpeg|png|gif)$ {
|
||||||
|
expires 365d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* ^.+.(css|js)$ {
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/powerdns-admin/socket;
|
||||||
|
proxy_read_timeout 120;
|
||||||
|
proxy_connect_timeout 120;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Sample Nginx-Configuration for SSL</summary>
|
||||||
|
|
||||||
|
* Im binding this config to every dns-name with default_server...
|
||||||
|
* but you can remove it and set your server_name.
|
||||||
|
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name "";
|
||||||
|
return 301 https://$http_host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2 default_server;
|
||||||
|
server_name _;
|
||||||
|
index index.html index.htm;
|
||||||
|
error_log /var/log/nginx/error_powerdnsadmin.log error;
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
ssl_certificate path_to_your_fullchain_or_cert;
|
||||||
|
ssl_certificate_key path_to_your_key;
|
||||||
|
ssl_dhparam path_to_your_dhparam.pem;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
|
||||||
|
client_max_body_size 10m;
|
||||||
|
client_body_buffer_size 128k;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_connect_timeout 90;
|
||||||
|
proxy_send_timeout 90;
|
||||||
|
proxy_read_timeout 90;
|
||||||
|
proxy_buffers 32 4k;
|
||||||
|
proxy_buffer_size 8k;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_headers_hash_bucket_size 64;
|
||||||
|
|
||||||
|
location ~ ^/static/ {
|
||||||
|
include mime.types;
|
||||||
|
root /opt/web/powerdns-admin/powerdnsadmin;
|
||||||
|
location ~* \.(jpg|jpeg|png|gif)$ { expires 365d; }
|
||||||
|
location ~* ^.+.(css|js)$ { expires 7d; }
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/upload/ {
|
||||||
|
include mime.types;
|
||||||
|
root /opt/web/powerdns-admin;
|
||||||
|
location ~* \.(jpg|jpeg|png|gif)$ { expires 365d; }
|
||||||
|
location ~* ^.+.(css|js)$ { expires 7d; }
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/powerdns-admin/socket;
|
||||||
|
proxy_read_timeout 120;
|
||||||
|
proxy_connect_timeout 120;
|
||||||
|
proxy_redirect http:// $scheme://;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Note
|
||||||
|
* `/opt/web/powerdns-admin` is the path to your powerdns-admin web directory
|
||||||
|
* Make sure you have installed gunicorn in flask virtualenv already.
|
||||||
|
* `powerdns-admin.local` just an example of your web domain name.
|
18
docs/wiki/web-server/Supervisord-example.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
Following is an example showing how to run PowerDNS-Admin with supervisord
|
||||||
|
|
||||||
|
Create supervisord program config file
|
||||||
|
```
|
||||||
|
$ sudo vim /etc/supervisor.d/powerdnsadmin.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[program:powerdnsadmin]
|
||||||
|
command=/opt/web/powerdns-admin/flask/bin/python ./run.py
|
||||||
|
stdout_logfile=/var/log/supervisor/program_powerdnsadmin.log
|
||||||
|
stderr_logfile=/var/log/supervisor/program_powerdnsadmin.error
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
directory=/opt/web/powerdns-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `sudo supervisorctl start powerdnsadmin` to start the Powerdns-Admin service.
|
50
docs/wiki/web-server/Systemd-example.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
## Configure systemd service
|
||||||
|
|
||||||
|
This example uses package-installed gunicorn (instead of flask-installed) and PowerDNS-Admin installed under /opt/powerdns-admin
|
||||||
|
|
||||||
|
`$ sudo vim /etc/systemd/system/powerdns-admin.service`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS web administration service
|
||||||
|
Requires=powerdns-admin.socket
|
||||||
|
Wants=network.target
|
||||||
|
After=network.target mysqld.service postgresql.service slapd.service mariadb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
PIDFile=/run/powerdns-admin/pid
|
||||||
|
User=pdnsa
|
||||||
|
Group=pdnsa
|
||||||
|
WorkingDirectory=/opt/powerdns-admin
|
||||||
|
ExecStart=/usr/bin/gunicorn-3.6 --workers 4 --log-level info --pid /run/powerdns-admin/pid --bind unix:/run/powerdns-admin/socket "powerdnsadmin:create_app(config='config.py')"
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
ExecStop=/bin/kill -s TERM $MAINPID
|
||||||
|
PrivateTmp=true
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StartLimitInterval=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo vim /etc/systemd/system/powerdns-admin.socket`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=PowerDNS-Admin socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/powerdns-admin/socket
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ sudo vim /etc/tmpfiles.d/powerdns-admin.conf`
|
||||||
|
|
||||||
|
```
|
||||||
|
d /run/powerdns-admin 0755 pdns pdns -
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `sudo systemctl daemon-reload; sudo systemctl start powerdns-admin.socket; sudo systemctl enable powerdns-admin.socket` to start the Powerdns-Admin service and make it run on boot.
|
100
docs/wiki/web-server/WSGI-Apache-example.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
How to run PowerDNS-Admin via WSGI and Apache2.4 using mod_wsgi.
|
||||||
|
|
||||||
|
**Note**: You must install mod_wsgi by using pip3 instead of system default mod_wsgi!!!
|
||||||
|
|
||||||
|
### Ubuntu/Debian
|
||||||
|
```shell
|
||||||
|
# apt install apache2-dev
|
||||||
|
# virtualenv -p python3 flask
|
||||||
|
# source ./flask/bin/activate
|
||||||
|
(flask) # pip3 install mod-wsgi
|
||||||
|
(flask) # mod_wsgi-express install-module > /etc/apache2/mods-available/wsgi.load
|
||||||
|
(flask) # a2enmod wsgi
|
||||||
|
(flask) # systemctl restart apache2
|
||||||
|
```
|
||||||
|
### CentOS
|
||||||
|
```shell
|
||||||
|
# yum install httpd-devel
|
||||||
|
# virtualenv -p python3 flask
|
||||||
|
# source ./flask/bin/activate
|
||||||
|
(flask) # pip3 install mod-wsgi
|
||||||
|
(flask) # mod_wsgi-express install-module > /etc/httpd/conf.modules.d/02-wsgi.conf
|
||||||
|
(flask) # systemctl restart httpd
|
||||||
|
```
|
||||||
|
### Fedora
|
||||||
|
```bash
|
||||||
|
# Install Apache's Development interfaces and package requirements
|
||||||
|
dnf install httpd-devel gcc gc make
|
||||||
|
virtualenv -p python3 flask
|
||||||
|
source ./flask/bin/activate
|
||||||
|
# Install WSGI for HTTPD
|
||||||
|
pip install mod_wsgi-httpd
|
||||||
|
# Install WSGI
|
||||||
|
pip install mod-wsgi
|
||||||
|
# Enable the module in Apache:
|
||||||
|
mod_wsgi-express install-module > /etc/httpd/conf.modules.d/02-wsgi.conf
|
||||||
|
systemctl restart httpd
|
||||||
|
```
|
||||||
|
|
||||||
|
Apache vhost configuration;
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName superawesomedns.foo.bar
|
||||||
|
ServerAlias [fe80::1]
|
||||||
|
ServerAdmin webmaster@foo.bar
|
||||||
|
|
||||||
|
SSLEngine On
|
||||||
|
SSLCertificateFile /some/path/ssl/certs/cert.pem
|
||||||
|
SSLCertificateKeyFile /some/path/ssl/private/cert.key
|
||||||
|
|
||||||
|
ErrorLog /var/log/apache2/error-superawesomedns.foo.bar.log
|
||||||
|
CustomLog /var/log/apache2/access-superawesomedns.foo.bar.log combined
|
||||||
|
|
||||||
|
DocumentRoot /srv/vhosts/superawesomedns.foo.bar/
|
||||||
|
|
||||||
|
WSGIDaemonProcess pdnsadmin user=pdnsadmin group=pdnsadmin threads=5
|
||||||
|
WSGIScriptAlias / /srv/vhosts/superawesomedns.foo.bar/powerdnsadmin.wsgi
|
||||||
|
|
||||||
|
# pass BasicAuth on to the WSGI process
|
||||||
|
WSGIPassAuthorization On
|
||||||
|
|
||||||
|
<Directory "/srv/vhosts/superawesomedns.foo.bar/">
|
||||||
|
WSGIProcessGroup pdnsadmin
|
||||||
|
WSGIApplicationGroup %{GLOBAL}
|
||||||
|
|
||||||
|
AllowOverride None
|
||||||
|
Options +ExecCGI +FollowSymLinks
|
||||||
|
SSLRequireSSL
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
**In Fedora, you might want to change the following line:**
|
||||||
|
```apache
|
||||||
|
WSGIDaemonProcess pdnsadmin socket-user=apache user=pdnsadmin group=pdnsadmin threads=5
|
||||||
|
```
|
||||||
|
**And you should add the following line to `/etc/httpd/conf/httpd.conf`:**
|
||||||
|
```apache
|
||||||
|
WSGISocketPrefix /var/run/wsgi
|
||||||
|
```
|
||||||
|
|
||||||
|
Content of `/srv/vhosts/superawesomedns.foo.bar/powerdnsadmin.wsgi`;
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/srv/vhosts/superawesomedns.foo.bar')
|
||||||
|
|
||||||
|
from app import app as application
|
||||||
|
```
|
||||||
|
Starting from 0.2 version, the `powerdnsadmin.wsgi` file is slighty different :
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/srv/vhosts/superawesomedns.foo.bar')
|
||||||
|
|
||||||
|
from powerdnsadmin import create_app
|
||||||
|
application = create_app()
|
||||||
|
```
|
||||||
|
|
||||||
|
(this implies that the pdnsadmin user/group exists, and that you have mod_wsgi loaded)
|
56
docs/wiki/web-server/uWSGI-example.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# uWSGI Example
|
||||||
|
|
||||||
|
This guide will show you how to run PowerDNS-Admin via uWSGI and nginx. This guide was written using Debian 8 with the following software versions:
|
||||||
|
- nginx 1.6.2
|
||||||
|
- uwsgi 2.0.7-debian
|
||||||
|
- python 2.7.9
|
||||||
|
|
||||||
|
## Software installation:
|
||||||
|
|
||||||
|
1. apt install the following packages:
|
||||||
|
- `uwsgi`
|
||||||
|
- `uwsgi-plugin-python`
|
||||||
|
- `nginx`
|
||||||
|
|
||||||
|
## Step-by-step instructions
|
||||||
|
1. Create a uWSGI .ini in `/etc/uwsgi/apps-enabled` with the following contents, making sure to replace the chdir, pythonpath and virtualenv directories with where you've installed PowerDNS-Admin:
|
||||||
|
```ini
|
||||||
|
[uwsgi]
|
||||||
|
plugins = python27
|
||||||
|
|
||||||
|
uid=www-data
|
||||||
|
gid=www-data
|
||||||
|
|
||||||
|
chdir = /opt/pdns-admin/PowerDNS-Admin/
|
||||||
|
pythonpath = /opt/pdns-admin/PowerDNS-Admin/
|
||||||
|
virtualenv = /opt/pdns-admin/PowerDNS-Admin/flask
|
||||||
|
|
||||||
|
mount = /pdns=powerdnsadmin:create_app()
|
||||||
|
manage-script-name = true
|
||||||
|
|
||||||
|
vacuum = true
|
||||||
|
harakiri = 20
|
||||||
|
buffer-size = 32768
|
||||||
|
post-buffering = 8192
|
||||||
|
socket = /run/uwsgi/app/%n/%n.socket
|
||||||
|
chown-socket = www-data
|
||||||
|
pidfile = /run/uwsgi/app/%n/%n.pid
|
||||||
|
|
||||||
|
daemonize = /var/log/uwsgi/app/%n.log
|
||||||
|
enable-threads
|
||||||
|
```
|
||||||
|
2. Add the following configuration to your nginx config:
|
||||||
|
```nginx
|
||||||
|
location / { try_files $uri @pdns_admin; }
|
||||||
|
|
||||||
|
location @pdns_admin {
|
||||||
|
include uwsgi_params;
|
||||||
|
uwsgi_pass unix:/run/uwsgi/app/pdns-admin/pdns-admin.socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /pdns/static/ {
|
||||||
|
alias /opt/pdns-admin/PowerDNS-Admin/app/static/;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Restart nginx and uwsgi.
|
||||||
|
4. You're done and PowerDNS-Admin will now be available via nginx.
|
@ -19,7 +19,7 @@ logger = logging.getLogger('alembic.env')
|
|||||||
# target_metadata = mymodel.Base.metadata
|
# target_metadata = mymodel.Base.metadata
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
config.set_main_option('sqlalchemy.url',
|
config.set_main_option('sqlalchemy.url',
|
||||||
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
|
current_app.config.get('SQLALCHEMY_DATABASE_URI').replace("%","%%"))
|
||||||
target_metadata = current_app.extensions['migrate'].db.metadata
|
target_metadata = current_app.extensions['migrate'].db.metadata
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
"""Fix typo in history detail
|
||||||
|
|
||||||
|
Revision ID: 6ea7dc05f496
|
||||||
|
Revises: fbc7cf864b24
|
||||||
|
Create Date: 2022-05-10 10:16:58.784497
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6ea7dc05f496'
|
||||||
|
down_revision = 'fbc7cf864b24'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
history_table = sa.sql.table('history',
|
||||||
|
sa.Column('detail', sa.Text),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute(
|
||||||
|
history_table.update()
|
||||||
|
.where(history_table.c.detail.like('%"add_rrests":%'))
|
||||||
|
.values({
|
||||||
|
'detail': sa.func.replace(
|
||||||
|
sa.func.replace(history_table.c.detail, '"add_rrests":', '"add_rrsets":'),
|
||||||
|
'"del_rrests":', '"del_rrsets":'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.execute(
|
||||||
|
history_table.update()
|
||||||
|
.where(history_table.c.detail.like('%"add_rrsets":%'))
|
||||||
|
.values({
|
||||||
|
'detail': sa.func.replace(
|
||||||
|
sa.func.replace(history_table.c.detail, '"add_rrsets":', '"add_rrests":'),
|
||||||
|
'"del_rrsets":', '"del_rrests":'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
@ -0,0 +1,31 @@
|
|||||||
|
"""update domain type length
|
||||||
|
|
||||||
|
Revision ID: f41520e41cee
|
||||||
|
Revises: 6ea7dc05f496
|
||||||
|
Create Date: 2023-01-10 11:56:28.538485
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f41520e41cee'
|
||||||
|
down_revision = '6ea7dc05f496'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table('domain') as batch_op:
|
||||||
|
batch_op.alter_column('type',
|
||||||
|
existing_type=sa.String(length=6),
|
||||||
|
type_=sa.String(length=8))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table('domain') as batch_op:
|
||||||
|
batch_op.alter_column('type',
|
||||||
|
existing_type=sa.String(length=8),
|
||||||
|
type_=sa.String(length=6))
|
||||||
|
|
@ -0,0 +1,47 @@
|
|||||||
|
"""update history detail quotes
|
||||||
|
|
||||||
|
Revision ID: fbc7cf864b24
|
||||||
|
Revises: 0967658d9c0d
|
||||||
|
Create Date: 2022-05-04 19:49:54.054285
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fbc7cf864b24'
|
||||||
|
down_revision = '0967658d9c0d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
history_table = sa.sql.table(
|
||||||
|
'history',
|
||||||
|
sa.Column('id', sa.Integer),
|
||||||
|
sa.Column('msg', sa.String),
|
||||||
|
sa.Column('detail', sa.Text),
|
||||||
|
sa.Column('created_by', sa.String),
|
||||||
|
sa.Column('created_on', sa.DateTime),
|
||||||
|
sa.Column('domain_id', sa.Integer)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
history_table.update().where(
|
||||||
|
sa.and_(
|
||||||
|
history_table.c.detail.like("%'%"),
|
||||||
|
history_table.c.detail.notlike("%rrests%"),
|
||||||
|
history_table.c.detail.notlike("%rrsets%")
|
||||||
|
)
|
||||||
|
).values({
|
||||||
|
'detail': sa.func.replace(
|
||||||
|
history_table.c.detail,
|
||||||
|
"'",
|
||||||
|
'"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_seasurf import SeaSurf
|
|
||||||
from flask_mail import Mail
|
from flask_mail import Mail
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from flask_session import Session
|
from flask_session import Session
|
||||||
@ -33,31 +32,6 @@ def create_app(config=None):
|
|||||||
# Proxy
|
# Proxy
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
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
|
# Load config from env variables if using docker
|
||||||
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):
|
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):
|
||||||
app.config.from_object('powerdnsadmin.docker_config')
|
app.config.from_object('powerdnsadmin.docker_config')
|
||||||
@ -69,7 +43,7 @@ def create_app(config=None):
|
|||||||
if 'FLASK_CONF' in os.environ:
|
if 'FLASK_CONF' in os.environ:
|
||||||
app.config.from_envvar('FLASK_CONF')
|
app.config.from_envvar('FLASK_CONF')
|
||||||
|
|
||||||
# Load app sepecified configuration
|
# Load app specified configuration
|
||||||
if config is not None:
|
if config is not None:
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
app.config.update(config)
|
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_record_name'] = utils.display_record_name
|
||||||
app.jinja_env.filters['display_master_name'] = utils.display_master_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['display_second_to_time'] = utils.display_time
|
||||||
app.jinja_env.filters[
|
|
||||||
'email_to_gravatar_url'] = utils.email_to_gravatar_url
|
|
||||||
app.jinja_env.filters[
|
app.jinja_env.filters[
|
||||||
'display_setting_state'] = utils.display_setting_state
|
'display_setting_state'] = utils.display_setting_state
|
||||||
app.jinja_env.filters['pretty_domain_name'] = utils.pretty_domain_name
|
app.jinja_env.filters['pretty_domain_name'] = utils.pretty_domain_name
|
||||||
@ -119,9 +91,4 @@ def create_app(config=None):
|
|||||||
setting = Setting()
|
setting = Setting()
|
||||||
return dict(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
|
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',
|
js_login = Bundle('node_modules/jquery/dist/jquery.js',
|
||||||
'node_modules/bootstrap/dist/js/bootstrap.js',
|
'node_modules/bootstrap/dist/js/bootstrap.js',
|
||||||
'node_modules/icheck/icheck.js',
|
'node_modules/icheck/icheck.js',
|
||||||
filters=(ConcatFilter, 'jsmin'),
|
'custom/js/custom.js',
|
||||||
|
filters=(ConcatFilter, 'rjsmin'),
|
||||||
output='generated/login.js')
|
output='generated/login.js')
|
||||||
|
|
||||||
js_validation = Bundle('node_modules/bootstrap-validator/dist/validator.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',
|
'node_modules/jquery.quicksearch/src/jquery.quicksearch.js',
|
||||||
'custom/js/custom.js',
|
'custom/js/custom.js',
|
||||||
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
|
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
|
||||||
filters=(ConcatFilter, 'jsmin'),
|
filters=(ConcatFilter, 'rjsmin'),
|
||||||
output='generated/main.js')
|
output='generated/main.js')
|
||||||
|
|
||||||
assets = Environment()
|
assets = Environment()
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from functools import wraps
|
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 flask_login import current_user
|
||||||
|
|
||||||
from .models import User, ApiKey, Setting, Domain, Setting
|
from .models import User, ApiKey, Setting, Domain, Setting
|
||||||
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges
|
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges, RecordTTLNotAllowed, RecordTypeNotAllowed
|
||||||
from .lib.errors import DomainAccessForbidden
|
from .lib.errors import DomainAccessForbidden, DomainOverrideForbidden
|
||||||
|
|
||||||
|
|
||||||
def admin_role_required(f):
|
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"
|
msg = "User {0} does not have enough privileges to create domain"
|
||||||
current_app.logger.error(msg.format(current_user.username))
|
current_app.logger.error(msg.format(current_user.username))
|
||||||
raise NotEnoughPrivileges()
|
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 f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
@ -269,6 +277,9 @@ def apikey_can_create_domain(f):
|
|||||||
Grant access if:
|
Grant access if:
|
||||||
- user is in Operator role or higher, or
|
- user is in Operator role or higher, or
|
||||||
- allow_user_create_domain is on
|
- allow_user_create_domain is on
|
||||||
|
and
|
||||||
|
- deny_domain_override is off or
|
||||||
|
- override_domain is true (from request)
|
||||||
"""
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
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"
|
msg = "ApiKey #{0} does not have enough privileges to create domain"
|
||||||
current_app.logger.error(msg.format(g.apikey.id))
|
current_app.logger.error(msg.format(g.apikey.id))
|
||||||
raise NotEnoughPrivileges()
|
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 f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
@ -367,6 +385,60 @@ def apikey_can_configure_dnssec(http_methods=[]):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
return decorator
|
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):
|
def apikey_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@ -409,7 +481,7 @@ def dyndns_login_required(f):
|
|||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if current_user.is_authenticated is False:
|
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 f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
@ -8,8 +8,9 @@ SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
|
|||||||
BIND_ADDRESS = '0.0.0.0'
|
BIND_ADDRESS = '0.0.0.0'
|
||||||
PORT = 9191
|
PORT = 9191
|
||||||
HSTS_ENABLED = False
|
HSTS_ENABLED = False
|
||||||
OFFLINE_MODE = False
|
|
||||||
FILESYSTEM_SESSIONS_ENABLED = False
|
FILESYSTEM_SESSIONS_ENABLED = False
|
||||||
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
CSRF_COOKIE_HTTPONLY = True
|
||||||
|
|
||||||
### DATABASE CONFIG
|
### DATABASE CONFIG
|
||||||
SQLA_DB_USER = 'pda'
|
SQLA_DB_USER = 'pda'
|
||||||
|
@ -1,48 +1,58 @@
|
|||||||
from OpenSSL import crypto
|
import datetime
|
||||||
from datetime import datetime
|
|
||||||
import pytz
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
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__)) + "/../../")
|
CRYPT_PATH = os.path.abspath(os.path.dirname(os.path.realpath(__file__)) + "/../../")
|
||||||
CERT_FILE = CRYPT_PATH + "/saml_cert.crt"
|
CERT_FILE = CRYPT_PATH + "/saml_cert.crt"
|
||||||
KEY_FILE = CRYPT_PATH + "/saml_cert.key"
|
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():
|
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
|
# Write our key to disk for safe keeping
|
||||||
k = crypto.PKey()
|
with open(KEY_FILE, "wb") as key_file:
|
||||||
k.generate_key(crypto.TYPE_RSA, 2048)
|
key_file.write(key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
))
|
||||||
|
|
||||||
# create a self-signed cert
|
# Various details about who we are. For a self-signed certificate the
|
||||||
cert = crypto.X509()
|
# subject and issuer are always the same.
|
||||||
cert.get_subject().C = "DE"
|
subject = issuer = x509.Name([
|
||||||
cert.get_subject().ST = "NRW"
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "DE"),
|
||||||
cert.get_subject().L = "Dortmund"
|
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "NRW"),
|
||||||
cert.get_subject().O = "Dummy Company Ltd"
|
x509.NameAttribute(NameOID.LOCALITY_NAME, "Dortmund"),
|
||||||
cert.get_subject().OU = "Dummy Company Ltd"
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Dummy Company Ltd"),
|
||||||
cert.get_subject().CN = "PowerDNS-Admin"
|
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Dummy Company Ltd"),
|
||||||
cert.set_serial_number(1000)
|
x509.NameAttribute(NameOID.COMMON_NAME, "PowerDNS-Admin"),
|
||||||
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, "bw").write(
|
cert = x509.CertificateBuilder().subject_name(
|
||||||
crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
subject
|
||||||
open(KEY_FILE, "bw").write(
|
).issuer_name(
|
||||||
crypto.dump_privatekey(crypto.FILETYPE_PEM, k))
|
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.message = message
|
||||||
self.name = name
|
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):
|
class ApiKeyCreateFail(StructuredException):
|
||||||
status_code = 500
|
status_code = 500
|
||||||
@ -129,6 +136,13 @@ class AccountNotExists(StructuredException):
|
|||||||
self.message = message
|
self.message = message
|
||||||
self.name = name
|
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):
|
class UserCreateFail(StructuredException):
|
||||||
status_code = 500
|
status_code = 500
|
||||||
@ -138,7 +152,6 @@ class UserCreateFail(StructuredException):
|
|||||||
self.message = message
|
self.message = message
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
|
||||||
class UserCreateDuplicate(StructuredException):
|
class UserCreateDuplicate(StructuredException):
|
||||||
status_code = 409
|
status_code = 409
|
||||||
|
|
||||||
@ -163,7 +176,6 @@ class UserUpdateFailEmail(StructuredException):
|
|||||||
self.message = message
|
self.message = message
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
|
||||||
class UserDeleteFail(StructuredException):
|
class UserDeleteFail(StructuredException):
|
||||||
status_code = 500
|
status_code = 500
|
||||||
|
|
||||||
@ -171,3 +183,19 @@ class UserDeleteFail(StructuredException):
|
|||||||
StructuredException.__init__(self)
|
StructuredException.__init__(self)
|
||||||
self.message = message
|
self.message = message
|
||||||
self.name = name
|
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}"
|
msg_str = "Sending request to powerdns API {0}"
|
||||||
|
|
||||||
if request.method != 'GET' and request.method != 'DELETE':
|
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)
|
current_app.logger.debug(msg)
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True, silent=True)
|
||||||
|
|
||||||
verify = False
|
verify = False
|
||||||
|
|
||||||
|
@ -2,13 +2,12 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import hashlib
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import idna
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from distutils.version import StrictVersion
|
from distutils.version import StrictVersion
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
|
|
||||||
def auth_from_url(url):
|
def auth_from_url(url):
|
||||||
@ -185,17 +184,6 @@ def pdns_api_extended_uri(version):
|
|||||||
return ""
|
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):
|
def display_setting_state(value):
|
||||||
if value == 1:
|
if value == 1:
|
||||||
return "ON"
|
return "ON"
|
||||||
@ -237,21 +225,42 @@ class customBoxes:
|
|||||||
}
|
}
|
||||||
order = ["reverse", "ip6arpa", "inaddrarpa"]
|
order = ["reverse", "ip6arpa", "inaddrarpa"]
|
||||||
|
|
||||||
def pretty_domain_name(value):
|
def pretty_domain_name(domain_name):
|
||||||
"""
|
# Add a debugging statement to print out the domain name
|
||||||
Display domain name in original format.
|
print("Received domain name:", domain_name)
|
||||||
If it is IDN domain (Punycode starts with xn--), do the
|
|
||||||
idna decoding.
|
# Check if the domain name is encoded using Punycode
|
||||||
Note that any part of the domain name can be individually punycoded
|
if domain_name.endswith('.xn--'):
|
||||||
"""
|
|
||||||
if isinstance(value, str):
|
|
||||||
if value.startswith('xn--') \
|
|
||||||
or value.find('.xn--') != -1:
|
|
||||||
try:
|
try:
|
||||||
return value.encode().decode('idna')
|
# Decode the domain name using the idna library
|
||||||
except:
|
domain_name = idna.decode(domain_name)
|
||||||
raise Exception("Cannot decode IDN domain")
|
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:
|
||||||
|
# Try encoding to idna
|
||||||
|
if not split.startswith('_') and not split.startswith('-'):
|
||||||
|
result.append(idna.encode(split).decode())
|
||||||
else:
|
else:
|
||||||
return value
|
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:
|
else:
|
||||||
raise Exception("Require the Punycode in string format")
|
result.append(split)
|
||||||
|
else:
|
||||||
|
raise Exception('No valid action received')
|
||||||
|
return '.'.join(result)
|
||||||
|
@ -3,6 +3,7 @@ from flask import current_app
|
|||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from ..lib import utils
|
from ..lib import utils
|
||||||
|
from ..lib.errors import InvalidAccountNameException
|
||||||
from .base import db
|
from .base import db
|
||||||
from .setting import Setting
|
from .setting import Setting
|
||||||
from .user import User
|
from .user import User
|
||||||
@ -22,7 +23,7 @@ class Account(db.Model):
|
|||||||
back_populates="accounts")
|
back_populates="accounts")
|
||||||
|
|
||||||
def __init__(self, name=None, description=None, contact=None, mail=None):
|
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.description = description
|
||||||
self.contact = contact
|
self.contact = contact
|
||||||
self.mail = mail
|
self.mail = mail
|
||||||
@ -33,9 +34,30 @@ class Account(db.Model):
|
|||||||
self.PDNS_VERSION = Setting().get('pdns_version')
|
self.PDNS_VERSION = Setting().get('pdns_version')
|
||||||
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.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()
|
@staticmethod
|
||||||
if c in "abcdefghijklmnopqrstuvwxyz0123456789")
|
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):
|
def __repr__(self):
|
||||||
return '<Account {0}r>'.format(self.name)
|
return '<Account {0}r>'.format(self.name)
|
||||||
@ -68,11 +90,9 @@ class Account(db.Model):
|
|||||||
"""
|
"""
|
||||||
Create a new account
|
Create a new account
|
||||||
"""
|
"""
|
||||||
# Sanity check - account name
|
self.name = Account.sanitize_name(self.name)
|
||||||
if self.name == "":
|
|
||||||
return {'status': False, 'msg': 'No account name specified'}
|
|
||||||
|
|
||||||
# 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()
|
account = Account.query.filter(Account.name == self.name).first()
|
||||||
if account:
|
if account:
|
||||||
return {'status': False, 'msg': 'Account already exists'}
|
return {'status': False, 'msg': 'Account already exists'}
|
||||||
|
@ -83,7 +83,7 @@ class ApiKey(db.Model):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg_str = 'Update of apikey failed. Error: {0}'
|
msg_str = 'Update of apikey failed. Error: {0}'
|
||||||
current_app.logger.error(msg_str.format(e))
|
current_app.logger.error(msg_str.format(e))
|
||||||
db.session.rollback
|
db.session.rollback() # fixed line
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def get_hashed_password(self, plain_text_password=None):
|
def get_hashed_password(self, plain_text_password=None):
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
@ -19,7 +20,7 @@ class Domain(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(255), index=True, unique=True)
|
name = db.Column(db.String(255), index=True, unique=True)
|
||||||
master = db.Column(db.String(128))
|
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)
|
serial = db.Column(db.BigInteger)
|
||||||
notified_serial = db.Column(db.BigInteger)
|
notified_serial = db.Column(db.BigInteger)
|
||||||
last_check = db.Column(db.Integer)
|
last_check = db.Column(db.Integer)
|
||||||
@ -109,6 +110,22 @@ class Domain(db.Model):
|
|||||||
'Domain does not exist. ERROR: {0}'.format(e))
|
'Domain does not exist. ERROR: {0}'.format(e))
|
||||||
return None
|
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):
|
def update(self):
|
||||||
"""
|
"""
|
||||||
Fetch zones (domains) from PowerDNS and update into DB
|
Fetch zones (domains) from PowerDNS and update into DB
|
||||||
@ -142,9 +159,20 @@ class Domain(db.Model):
|
|||||||
current_app.logger.debug(traceback.format_exc())
|
current_app.logger.debug(traceback.format_exc())
|
||||||
|
|
||||||
# update/add new domain
|
# update/add new domain
|
||||||
|
account_cache = {}
|
||||||
for data in jdata:
|
for data in jdata:
|
||||||
if 'account' in data:
|
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:
|
else:
|
||||||
current_app.logger.debug(
|
current_app.logger.debug(
|
||||||
"No 'account' data found in API result - Unsupported PowerDNS version?"
|
"No 'account' data found in API result - Unsupported PowerDNS version?"
|
||||||
@ -208,7 +236,7 @@ class Domain(db.Model):
|
|||||||
Add a domain to power dns
|
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_name = domain_name + '.'
|
||||||
domain_ns = [ns + '.' for ns in domain_ns]
|
domain_ns = [ns + '.' for ns in domain_ns]
|
||||||
@ -311,7 +339,7 @@ class Domain(db.Model):
|
|||||||
if not domain:
|
if not domain:
|
||||||
return {'status': 'error', 'msg': 'Domain does not exist.'}
|
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"]:
|
if soa_edit_api not in ["DEFAULT", "INCREASE", "EPOCH", "OFF"]:
|
||||||
soa_edit_api = 'DEFAULT'
|
soa_edit_api = 'DEFAULT'
|
||||||
@ -361,7 +389,7 @@ class Domain(db.Model):
|
|||||||
if not domain:
|
if not domain:
|
||||||
return {'status': 'error', 'msg': 'Domain does not exist.'}
|
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}
|
post_data = {"kind": kind, "masters": masters}
|
||||||
|
|
||||||
@ -421,7 +449,7 @@ class Domain(db.Model):
|
|||||||
if result['status'] == 'ok':
|
if result['status'] == 'ok':
|
||||||
history = History(msg='Add reverse lookup domain {0}'.format(
|
history = History(msg='Add reverse lookup domain {0}'.format(
|
||||||
domain_reverse_name),
|
domain_reverse_name),
|
||||||
detail=str({
|
detail=json.dumps({
|
||||||
'domain_type': 'Master',
|
'domain_type': 'Master',
|
||||||
'domain_master_ips': ''
|
'domain_master_ips': ''
|
||||||
}),
|
}),
|
||||||
@ -681,7 +709,7 @@ class Domain(db.Model):
|
|||||||
"""
|
"""
|
||||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||||
if domain:
|
if domain:
|
||||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
|
||||||
try:
|
try:
|
||||||
# Enable API-RECTIFY for domain, BEFORE activating DNSSEC
|
# Enable API-RECTIFY for domain, BEFORE activating DNSSEC
|
||||||
post_data = {"api_rectify": True}
|
post_data = {"api_rectify": True}
|
||||||
@ -747,7 +775,7 @@ class Domain(db.Model):
|
|||||||
"""
|
"""
|
||||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||||
if domain:
|
if domain:
|
||||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
|
||||||
try:
|
try:
|
||||||
# Deactivate DNSSEC
|
# Deactivate DNSSEC
|
||||||
jdata = utils.fetch_json(
|
jdata = utils.fetch_json(
|
||||||
@ -806,7 +834,7 @@ class Domain(db.Model):
|
|||||||
else:
|
else:
|
||||||
return {'status': 'error', 'msg': 'This domain does not exist'}
|
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
|
Associate domain with a domain, specified by account id
|
||||||
"""
|
"""
|
||||||
@ -821,7 +849,7 @@ class Domain(db.Model):
|
|||||||
if not domain:
|
if not domain:
|
||||||
return {'status': False, 'msg': 'Domain does not exist'}
|
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)
|
account_name = Account().get_name_by_id(account_id)
|
||||||
|
|
||||||
@ -842,6 +870,7 @@ class Domain(db.Model):
|
|||||||
current_app.logger.error(jdata['error'])
|
current_app.logger.error(jdata['error'])
|
||||||
return {'status': 'error', 'msg': jdata['error']}
|
return {'status': 'error', 'msg': jdata['error']}
|
||||||
else:
|
else:
|
||||||
|
if update:
|
||||||
self.update()
|
self.update()
|
||||||
msg_str = 'Account changed for domain {0} successfully'
|
msg_str = 'Account changed for domain {0} successfully'
|
||||||
current_app.logger.info(msg_str.format(domain_name))
|
current_app.logger.info(msg_str.format(domain_name))
|
||||||
@ -879,3 +908,18 @@ class Domain(db.Model):
|
|||||||
DomainUser.user_id == user_id,
|
DomainUser.user_id == user_id,
|
||||||
AccountUser.user_id == user_id
|
AccountUser.user_id == user_id
|
||||||
)).filter(Domain.id == self.id).first()
|
)).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
|
# 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:
|
try:
|
||||||
jdata = utils.fetch_json(urljoin(
|
jdata = utils.fetch_json(urljoin(
|
||||||
@ -169,12 +169,12 @@ class Record(object):
|
|||||||
record['record_data'] = record['record_data'].replace('[ZONE]', domain_name)
|
record['record_data'] = record['record_data'].replace('[ZONE]', domain_name)
|
||||||
# Translate record name into punycode (IDN) as that's the only way
|
# Translate record name into punycode (IDN) as that's the only way
|
||||||
# to convey non-ascii records to the dns server
|
# 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
|
#TODO: error handling
|
||||||
# If the record is an alias (CNAME), we will also make sure that
|
# If the record is an alias (CNAME), we will also make sure that
|
||||||
# the target domain is properly converted to punycode (IDN)
|
# the target domain is properly converted to punycode (IDN)
|
||||||
if record["record_type"] == 'CNAME':
|
if record['record_type'] == 'CNAME' or record['record_type'] == 'SOA':
|
||||||
record['record_data'] = record['record_data'].encode('idna').decode()
|
record['record_data'] = utils.to_idna(record['record_data'], 'encode')
|
||||||
#TODO: error handling
|
#TODO: error handling
|
||||||
# If it is ipv6 reverse zone and PRETTY_IPV6_PTR is enabled,
|
# If it is ipv6 reverse zone and PRETTY_IPV6_PTR is enabled,
|
||||||
# We convert ipv6 address back to reverse record format
|
# We convert ipv6 address back to reverse record format
|
||||||
@ -293,7 +293,7 @@ class Record(object):
|
|||||||
return new_rrsets, del_rrsets
|
return new_rrsets, del_rrsets
|
||||||
|
|
||||||
def apply_rrsets(self, domain_name, 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(
|
jdata = utils.fetch_json(urljoin(
|
||||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||||
'/servers/localhost/zones/{0}'.format(domain_name)),
|
'/servers/localhost/zones/{0}'.format(domain_name)),
|
||||||
@ -303,10 +303,48 @@ class Record(object):
|
|||||||
data=rrsets)
|
data=rrsets)
|
||||||
return jdata
|
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):
|
def apply(self, domain_name, submitted_records):
|
||||||
"""
|
"""
|
||||||
Apply record changes to a domain. This function
|
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)
|
REPLACE records (rrsets)
|
||||||
"""
|
"""
|
||||||
current_app.logger.debug(
|
current_app.logger.debug(
|
||||||
@ -315,68 +353,24 @@ class Record(object):
|
|||||||
# Get the list of rrsets to be added and deleted
|
# Get the list of rrsets to be added and deleted
|
||||||
new_rrsets, del_rrsets = self.compare(domain_name, submitted_records)
|
new_rrsets, del_rrsets = self.compare(domain_name, submitted_records)
|
||||||
|
|
||||||
# Remove blank comments from rrsets for compatibility with some backends
|
# The history logic still needs *all* the deletes with full data to display a useful diff.
|
||||||
def remove_blank_comments(rrset):
|
# So create a "minified" copy for the api call, and return the original data back up
|
||||||
if not rrset['comments']:
|
api_payload = self.to_api_payload(new_rrsets['rrsets'], del_rrsets['rrsets'])
|
||||||
del rrset['comments']
|
current_app.logger.debug(f"api payload: \n{utils.pretty_json(api_payload)}")
|
||||||
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)
|
|
||||||
|
|
||||||
# Submit the changes to PDNS API
|
# Submit the changes to PDNS API
|
||||||
try:
|
try:
|
||||||
if del_rrsets["rrsets"]:
|
if api_payload["rrsets"]:
|
||||||
result = self.apply_rrsets(domain_name, del_rrsets)
|
result = self.apply_rrsets(domain_name, api_payload)
|
||||||
if 'error' in result.keys():
|
if 'error' in result.keys():
|
||||||
current_app.logger.error(
|
current_app.logger.error(
|
||||||
'Cannot apply record changes with deleting rrsets step. PDNS error: {}'
|
'Cannot apply record changes. PDNS error: {}'
|
||||||
.format(result['error']))
|
.format(result['error']))
|
||||||
return {
|
return {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'msg': result['error'].replace("'", "")
|
'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.auto_ptr(domain_name, new_rrsets, del_rrsets)
|
||||||
self.update_db_serial(domain_name)
|
self.update_db_serial(domain_name)
|
||||||
current_app.logger.info('Record was applied successfully.')
|
current_app.logger.info('Record was applied successfully.')
|
||||||
@ -500,7 +494,7 @@ class Record(object):
|
|||||||
"""
|
"""
|
||||||
Delete a record from domain
|
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 = {
|
data = {
|
||||||
"rrsets": [{
|
"rrsets": [{
|
||||||
"name": self.name.rstrip('.') + '.',
|
"name": self.name.rstrip('.') + '.',
|
||||||
@ -562,7 +556,7 @@ class Record(object):
|
|||||||
"""
|
"""
|
||||||
Update single record
|
Update single record
|
||||||
"""
|
"""
|
||||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"rrsets": [{
|
"rrsets": [{
|
||||||
|
@ -110,6 +110,7 @@ class Setting(db.Model):
|
|||||||
'oidc_oauth_email': 'email',
|
'oidc_oauth_email': 'email',
|
||||||
'oidc_oauth_account_name_property': '',
|
'oidc_oauth_account_name_property': '',
|
||||||
'oidc_oauth_account_description_property': '',
|
'oidc_oauth_account_description_property': '',
|
||||||
|
'enforce_api_ttl': False,
|
||||||
'forward_records_allow_edit': {
|
'forward_records_allow_edit': {
|
||||||
'A': True,
|
'A': True,
|
||||||
'AAAA': True,
|
'AAAA': True,
|
||||||
@ -189,7 +190,11 @@ class Setting(db.Model):
|
|||||||
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
|
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
|
||||||
'otp_field_enabled': True,
|
'otp_field_enabled': True,
|
||||||
'custom_css': '',
|
'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):
|
def __init__(self, id=None, name=None, value=None):
|
||||||
|
@ -8,6 +8,9 @@ import ldap.filter
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask_login import AnonymousUserMixin
|
from flask_login import AnonymousUserMixin
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
import qrcode as qrc
|
||||||
|
import qrcode.image.svg as qrc_svg
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from .base import db
|
from .base import db
|
||||||
from .role import Role
|
from .role import Role
|
||||||
@ -80,10 +83,7 @@ class User(db.Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
try:
|
return str(self.id)
|
||||||
return unicode(self.id) # python 2
|
|
||||||
except NameError:
|
|
||||||
return str(self.id) # python 3
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<User {0}>'.format(self.username)
|
return '<User {0}>'.format(self.username)
|
||||||
@ -94,7 +94,7 @@ class User(db.Model):
|
|||||||
|
|
||||||
def verify_totp(self, token):
|
def verify_totp(self, token):
|
||||||
totp = pyotp.TOTP(self.otp_secret)
|
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):
|
def get_hashed_password(self, plain_text_password=None):
|
||||||
# Hash a password for the first time
|
# Hash a password for the first time
|
||||||
@ -107,7 +107,8 @@ class User(db.Model):
|
|||||||
|
|
||||||
def check_password(self, hashed_password):
|
def check_password(self, hashed_password):
|
||||||
# Check hashed password. Using bcrypt, the salt is saved into the hash itself
|
# Check hashed password. Using bcrypt, the salt is saved into the hash itself
|
||||||
if (self.plain_text_password):
|
if hasattr(self, "plain_text_password"):
|
||||||
|
if self.plain_text_password != None:
|
||||||
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
|
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
|
||||||
hashed_password.encode('utf-8'))
|
hashed_password.encode('utf-8'))
|
||||||
return False
|
return False
|
||||||
@ -125,7 +126,6 @@ class User(db.Model):
|
|||||||
conn = ldap.initialize(Setting().get('ldap_uri'))
|
conn = ldap.initialize(Setting().get('ldap_uri'))
|
||||||
conn.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
|
conn.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
|
||||||
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
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_X_TLS_DEMAND, True)
|
||||||
conn.set_option(ldap.OPT_DEBUG_LEVEL, 255)
|
conn.set_option(ldap.OPT_DEBUG_LEVEL, 255)
|
||||||
conn.protocol_version = ldap.VERSION3
|
conn.protocol_version = ldap.VERSION3
|
||||||
@ -171,28 +171,6 @@ class User(db.Model):
|
|||||||
current_app.logger.error(e)
|
current_app.logger.error(e)
|
||||||
return False
|
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):
|
def is_validate(self, method, src_ip='', trust_user=False):
|
||||||
"""
|
"""
|
||||||
Validate user credential
|
Validate user credential
|
||||||
@ -304,7 +282,17 @@ class User(db.Model):
|
|||||||
LDAP_USER_GROUP))
|
LDAP_USER_GROUP))
|
||||||
return False
|
return False
|
||||||
elif LDAP_TYPE == 'ad':
|
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(
|
user_ad_member_of = ldap_result[0][0][1].get(
|
||||||
'memberOf')
|
'memberOf')
|
||||||
|
|
||||||
@ -314,26 +302,21 @@ class User(db.Model):
|
|||||||
.format(self.username))
|
.format(self.username))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for group in [
|
user_ad_member_of = [g.decode("utf-8") for g in user_ad_member_of]
|
||||||
g.decode("utf-8")
|
|
||||||
for g in user_ad_member_of
|
|
||||||
]:
|
|
||||||
user_ldap_groups += self.ad_recursive_groups(
|
|
||||||
group)
|
|
||||||
|
|
||||||
if (LDAP_ADMIN_GROUP in user_ldap_groups):
|
if (LDAP_ADMIN_GROUP in user_ad_member_of):
|
||||||
role_name = 'Administrator'
|
role_name = 'Administrator'
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin'
|
'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin'
|
||||||
.format(self.username,
|
.format(self.username,
|
||||||
LDAP_ADMIN_GROUP))
|
LDAP_ADMIN_GROUP))
|
||||||
elif (LDAP_OPERATOR_GROUP in user_ldap_groups):
|
elif (LDAP_OPERATOR_GROUP in user_ad_member_of):
|
||||||
role_name = 'Operator'
|
role_name = 'Operator'
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin'
|
'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin'
|
||||||
.format(self.username,
|
.format(self.username,
|
||||||
LDAP_OPERATOR_GROUP))
|
LDAP_OPERATOR_GROUP))
|
||||||
elif (LDAP_USER_GROUP in user_ldap_groups):
|
elif (LDAP_USER_GROUP in user_ad_member_of):
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin'
|
'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin'
|
||||||
.format(self.username,
|
.format(self.username,
|
||||||
@ -439,8 +422,12 @@ class User(db.Model):
|
|||||||
self.role_id = Role.query.filter_by(
|
self.role_id = Role.query.filter_by(
|
||||||
name='Administrator').first().id
|
name='Administrator').first().id
|
||||||
|
|
||||||
|
if hasattr(self, "plain_text_password"):
|
||||||
|
if self.plain_text_password != None:
|
||||||
self.password = self.get_hashed_password(
|
self.password = self.get_hashed_password(
|
||||||
self.plain_text_password) if self.plain_text_password else '*'
|
self.plain_text_password)
|
||||||
|
else:
|
||||||
|
self.password = '*'
|
||||||
|
|
||||||
if self.password and self.password != '*':
|
if self.password and self.password != '*':
|
||||||
self.password = self.password.decode("utf-8")
|
self.password = self.password.decode("utf-8")
|
||||||
@ -476,7 +463,8 @@ class User(db.Model):
|
|||||||
user.email = self.email
|
user.email = self.email
|
||||||
|
|
||||||
# store new password hash (only if changed)
|
# store new password hash (only if changed)
|
||||||
if self.plain_text_password:
|
if hasattr(self, "plain_text_password"):
|
||||||
|
if self.plain_text_password != None:
|
||||||
user.password = self.get_hashed_password(
|
user.password = self.get_hashed_password(
|
||||||
self.plain_text_password).decode("utf-8")
|
self.plain_text_password).decode("utf-8")
|
||||||
|
|
||||||
@ -493,9 +481,11 @@ class User(db.Model):
|
|||||||
|
|
||||||
user.firstname = self.firstname if self.firstname else user.firstname
|
user.firstname = self.firstname if self.firstname else user.firstname
|
||||||
user.lastname = self.lastname if self.lastname else user.lastname
|
user.lastname = self.lastname if self.lastname else user.lastname
|
||||||
|
|
||||||
|
if hasattr(self, "plain_text_password"):
|
||||||
|
if self.plain_text_password != None:
|
||||||
user.password = self.get_hashed_password(
|
user.password = self.get_hashed_password(
|
||||||
self.plain_text_password).decode(
|
self.plain_text_password).decode("utf-8")
|
||||||
"utf-8") if self.plain_text_password else user.password
|
|
||||||
|
|
||||||
if self.email:
|
if self.email:
|
||||||
# Can not update to a new email that
|
# Can not update to a new email that
|
||||||
@ -634,6 +624,13 @@ class User(db.Model):
|
|||||||
accounts.append(q[1])
|
accounts.append(q[1])
|
||||||
return accounts
|
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):
|
def read_entitlements(self, key):
|
||||||
"""
|
"""
|
||||||
@ -795,6 +792,3 @@ def getUserInfo(DomainsOrAccounts):
|
|||||||
for DomainOrAccount in DomainsOrAccounts:
|
for DomainOrAccount in DomainsOrAccounts:
|
||||||
current.append(DomainOrAccount.name)
|
current.append(DomainOrAccount.name)
|
||||||
return current
|
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 .index import index_bp
|
||||||
from .user import user_bp
|
from .user import user_bp
|
||||||
from .dashboard import dashboard_bp
|
from .dashboard import dashboard_bp
|
||||||
from .domain import domain_bp
|
from .domain import domain_bp
|
||||||
from .admin import admin_bp
|
from .admin import admin_bp
|
||||||
from .api import api_bp
|
from .api import api_bp, apilist_bp
|
||||||
|
|
||||||
|
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
app.register_blueprint(index_bp)
|
app.register_blueprint(index_bp)
|
||||||
app.register_blueprint(user_bp)
|
app.register_blueprint(user_bp)
|
||||||
@ -17,6 +21,7 @@ def init_app(app):
|
|||||||
app.register_blueprint(domain_bp)
|
app.register_blueprint(domain_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(apilist_bp)
|
||||||
|
|
||||||
app.register_error_handler(400, handle_bad_request)
|
app.register_error_handler(400, handle_bad_request)
|
||||||
app.register_error_handler(401, handle_unauthorized_access)
|
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.api_key import ApiKey
|
||||||
from ..models.base import db
|
from ..models.base import db
|
||||||
|
|
||||||
|
from ..lib.errors import ApiKeyCreateFail
|
||||||
from ..lib.schema import ApiPlainKeySchema
|
from ..lib.schema import ApiPlainKeySchema
|
||||||
|
|
||||||
apikey_plain_schema = ApiPlainKeySchema(many=True)
|
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,
|
Note: A change in "content", is considered a deletion and recreation of the same record,
|
||||||
holding the new content value.
|
holding the new content value.
|
||||||
"""
|
"""
|
||||||
def get_record_changes(del_rrest, add_rrest):
|
def get_record_changes(del_rrset, add_rrset):
|
||||||
changeSet = []
|
changeSet = []
|
||||||
delSet = del_rrest['records'] if 'records' in del_rrest else []
|
delSet = del_rrset['records'] if 'records' in del_rrset else []
|
||||||
addSet = add_rrest['records'] if 'records' in add_rrest else []
|
addSet = add_rrset['records'] if 'records' in add_rrset else []
|
||||||
for d in delSet: # get the deletions and status changes
|
for d in delSet: # get the deletions and status changes
|
||||||
exists = False
|
exists = False
|
||||||
for a in addSet:
|
for a in addSet:
|
||||||
@ -86,44 +87,44 @@ def get_record_changes(del_rrest, add_rrest):
|
|||||||
return changeSet
|
return changeSet
|
||||||
|
|
||||||
# out_changes is a list of HistoryRecordEntry objects in which we will append the new changes
|
# 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):
|
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:
|
if history_entry.detail is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if "add_rrests" in history_entry.detail:
|
if "add_rrsets" in history_entry.detail:
|
||||||
detail_dict = json.loads(history_entry.detail.replace("\'", ''))
|
detail_dict = json.loads(history_entry.detail)
|
||||||
else: # not a record entry
|
else: # not a record entry
|
||||||
return
|
return
|
||||||
|
|
||||||
add_rrests = detail_dict['add_rrests']
|
add_rrsets = detail_dict['add_rrsets']
|
||||||
del_rrests = detail_dict['del_rrests']
|
del_rrsets = detail_dict['del_rrsets']
|
||||||
|
|
||||||
|
|
||||||
for add_rrest in add_rrests:
|
for add_rrset in add_rrsets:
|
||||||
exists = False
|
exists = False
|
||||||
for del_rrest in del_rrests:
|
for del_rrset in del_rrsets:
|
||||||
if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
|
if del_rrset['name'] == add_rrset['name'] and del_rrset['type'] == add_rrset['type']:
|
||||||
exists = True
|
exists = True
|
||||||
if change_num not in out_changes:
|
if change_num not in out_changes:
|
||||||
out_changes[change_num] = []
|
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
|
break
|
||||||
if not exists: # this is a new record
|
if not exists: # this is a new record
|
||||||
if change_num not in out_changes:
|
if change_num not in out_changes:
|
||||||
out_changes[change_num] = []
|
out_changes[change_num] = []
|
||||||
out_changes[change_num].append(HistoryRecordEntry(history_entry, [], add_rrest, "+")) # (add_rrest, del_rrest, change_type)
|
out_changes[change_num].append(HistoryRecordEntry(history_entry, [], add_rrset, "+")) # (add_rrset, del_rrset, change_type)
|
||||||
for del_rrest in del_rrests:
|
for del_rrset in del_rrsets:
|
||||||
exists = False
|
exists = False
|
||||||
for add_rrest in add_rrests:
|
for add_rrset in add_rrsets:
|
||||||
if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
|
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
|
exists = True # no need to add in the out_changes set
|
||||||
break
|
break
|
||||||
if not exists: # this is a deletion
|
if not exists: # this is a deletion
|
||||||
if change_num not in out_changes:
|
if change_num not in out_changes:
|
||||||
out_changes[change_num] = []
|
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
|
# only used for changelog per record
|
||||||
@ -133,9 +134,9 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
for hre in changes_i: # for each history record entry in changes_i
|
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
|
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
|
continue
|
||||||
else:
|
else:
|
||||||
out_changes[change_num].remove(hre)
|
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
|
# 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
|
# history_entry is of type History - used to extract created_by and created_on
|
||||||
# add_rrest is a dictionary of replace
|
# add_rrset is a dictionary of replace
|
||||||
# del_rrest is a dictionary of remove
|
# del_rrset is a dictionary of remove
|
||||||
class HistoryRecordEntry:
|
class HistoryRecordEntry:
|
||||||
def __init__(self, history_entry, del_rrest, add_rrest, change_type):
|
def __init__(self, history_entry, del_rrset, add_rrset, change_type):
|
||||||
# search the add_rrest index into the add_rrest set for the key (name, type)
|
# search the add_rrset index into the add_rrset set for the key (name, type)
|
||||||
|
|
||||||
self.history_entry = history_entry
|
self.history_entry = history_entry
|
||||||
self.add_rrest = add_rrest
|
self.add_rrset = add_rrset
|
||||||
self.del_rrest = del_rrest
|
self.del_rrset = del_rrset
|
||||||
self.change_type = change_type # "*": edit or unchanged, "+" new tuple(name,type), "-" deleted (name,type) tuple
|
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.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
|
if change_type == "+": # addition
|
||||||
self.changed_fields.append("name")
|
self.changed_fields.append("name")
|
||||||
self.changed_fields.append("type")
|
self.changed_fields.append("type")
|
||||||
self.changed_fields.append("ttl")
|
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
|
elif change_type == "-": # removal
|
||||||
self.changed_fields.append("name")
|
self.changed_fields.append("name")
|
||||||
self.changed_fields.append("type")
|
self.changed_fields.append("type")
|
||||||
self.changed_fields.append("ttl")
|
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
|
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.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):
|
def toDict(self):
|
||||||
return {
|
return {
|
||||||
"add_rrest" : self.add_rrest,
|
"add_rrset" : self.add_rrset,
|
||||||
"del_rrest" : self.del_rrest,
|
"del_rrset" : self.del_rrset,
|
||||||
"changed_fields" : self.changed_fields,
|
"changed_fields" : self.changed_fields,
|
||||||
"created_on" : self.history_entry.created_on,
|
"created_on" : self.history_entry.created_on,
|
||||||
"created_by" : self.history_entry.created_by,
|
"created_by" : self.history_entry.created_by,
|
||||||
@ -362,7 +363,7 @@ def edit_key(key_id=None):
|
|||||||
current_app.logger.error('Error: {0}'.format(e))
|
current_app.logger.error('Error: {0}'.format(e))
|
||||||
|
|
||||||
history = History(msg=history_message,
|
history = History(msg=history_message,
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
'key': apikey.id,
|
'key': apikey.id,
|
||||||
'role': apikey.role.name,
|
'role': apikey.role.name,
|
||||||
'description': apikey.description,
|
'description': apikey.description,
|
||||||
@ -411,7 +412,7 @@ def manage_keys():
|
|||||||
|
|
||||||
current_app.logger.info('Delete API key {0}'.format(apikey.id))
|
current_app.logger.info('Delete API key {0}'.format(apikey.id))
|
||||||
history = History(msg='Delete API key {0}'.format(apikey.id),
|
history = History(msg='Delete API key {0}'.format(apikey.id),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
'key': history_apikey_id,
|
'key': history_apikey_id,
|
||||||
'role': history_apikey_role,
|
'role': history_apikey_role,
|
||||||
'description': history_apikey_description,
|
'description': history_apikey_description,
|
||||||
@ -763,10 +764,7 @@ class DetailedHistory():
|
|||||||
self.detailed_msg = ""
|
self.detailed_msg = ""
|
||||||
return
|
return
|
||||||
|
|
||||||
if 'add_rrest' in history.detail:
|
detail_dict = json.loads(history.detail)
|
||||||
detail_dict = json.loads(history.detail.replace("\'", ''))
|
|
||||||
else:
|
|
||||||
detail_dict = json.loads(history.detail.replace("'", '"'))
|
|
||||||
|
|
||||||
if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
|
if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
|
||||||
self.detailed_msg = render_template_string("""
|
self.detailed_msg = render_template_string("""
|
||||||
@ -806,7 +804,7 @@ class DetailedHistory():
|
|||||||
authenticator=detail_dict['authenticator'],
|
authenticator=detail_dict['authenticator'],
|
||||||
ip_address=detail_dict['ip_address'])
|
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 = []
|
# changes_set = []
|
||||||
self.detailed_msg = ""
|
self.detailed_msg = ""
|
||||||
# extract_changelogs_from_a_history_entry(changes_set, history, 0)
|
# 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_type=DetailedHistory.get_key_val(detail_dict, "domain_type"),
|
||||||
domain_master_ips=DetailedHistory.get_key_val(detail_dict, "domain_master_ips"))
|
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
|
# check for lower key as well for old databases
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_key_val(_dict, key):
|
def get_key_val(_dict, key):
|
||||||
@ -895,7 +903,7 @@ def convert_histories(histories):
|
|||||||
detailedHistories = []
|
detailedHistories = []
|
||||||
j = 0
|
j = 0
|
||||||
for i in range(len(histories)):
|
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)
|
extract_changelogs_from_a_history_entry(changes_set, histories[i], j)
|
||||||
if j in changes_set:
|
if j in changes_set:
|
||||||
detailedHistories.append(DetailedHistory(histories[i], changes_set[j]))
|
detailedHistories.append(DetailedHistory(histories[i], changes_set[j]))
|
||||||
@ -1251,17 +1259,38 @@ def history_table(): # ajax call data
|
|||||||
@login_required
|
@login_required
|
||||||
@operator_role_required
|
@operator_role_required
|
||||||
def setting_basic():
|
def setting_basic():
|
||||||
if request.method == 'GET':
|
|
||||||
settings = [
|
settings = [
|
||||||
'maintenance', 'fullscreen_layout', 'record_helper',
|
'account_name_extra_chars',
|
||||||
'login_ldap_first', 'default_record_table_size',
|
'allow_user_create_domain',
|
||||||
'default_domain_table_size', 'auto_ptr', 'record_quick_edit',
|
'allow_user_remove_domain',
|
||||||
'pretty_ipv6_ptr', 'dnssec_admins_only',
|
'allow_user_view_history',
|
||||||
'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name',
|
'auto_ptr',
|
||||||
'session_timeout', 'warn_session_timeout', 'ttl_options',
|
'bg_domain_updates',
|
||||||
'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email',
|
'custom_css',
|
||||||
'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records'
|
'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)
|
||||||
@ -1664,7 +1693,7 @@ def create_template():
|
|||||||
result = t.create()
|
result = t.create()
|
||||||
if result['status'] == 'ok':
|
if result['status'] == 'ok':
|
||||||
history = History(msg='Add domain template {0}'.format(name),
|
history = History(msg='Add domain template {0}'.format(name),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
'name': name,
|
'name': name,
|
||||||
'description': description
|
'description': description
|
||||||
}),
|
}),
|
||||||
@ -1711,7 +1740,7 @@ def create_template_from_zone():
|
|||||||
result = t.create()
|
result = t.create()
|
||||||
if result['status'] == 'ok':
|
if result['status'] == 'ok':
|
||||||
history = History(msg='Add domain template {0}'.format(name),
|
history = History(msg='Add domain template {0}'.format(name),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
'name': name,
|
'name': name,
|
||||||
'description': description
|
'description': description
|
||||||
}),
|
}),
|
||||||
@ -1844,7 +1873,7 @@ def apply_records(template):
|
|||||||
history = History(
|
history = History(
|
||||||
msg='Apply domain template record changes to domain template {0}'
|
msg='Apply domain template record changes to domain template {0}'
|
||||||
.format(template),
|
.format(template),
|
||||||
detail=str(json.dumps(jdata)),
|
detail = json.dumps(jdata),
|
||||||
created_by=current_user.username)
|
created_by=current_user.username)
|
||||||
history.add()
|
history.add()
|
||||||
return make_response(jsonify(result), 200)
|
return make_response(jsonify(result), 200)
|
||||||
@ -1874,7 +1903,7 @@ def delete_template(template):
|
|||||||
if result['status'] == 'ok':
|
if result['status'] == 'ok':
|
||||||
history = History(
|
history = History(
|
||||||
msg='Deleted domain template {0}'.format(template),
|
msg='Deleted domain template {0}'.format(template),
|
||||||
detail=str({'name': template}),
|
detail = json.dumps({'name': template}),
|
||||||
created_by=current_user.username)
|
created_by=current_user.username)
|
||||||
history.add()
|
history.add()
|
||||||
return redirect(url_for('admin.templates'))
|
return redirect(url_for('admin.templates'))
|
||||||
|
@ -6,6 +6,7 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from .base import csrf
|
||||||
from ..models.base import db
|
from ..models.base import db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey,
|
User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey,
|
||||||
@ -23,19 +24,20 @@ from ..lib.errors import (
|
|||||||
AccountCreateFail, AccountUpdateFail, AccountDeleteFail,
|
AccountCreateFail, AccountUpdateFail, AccountDeleteFail,
|
||||||
AccountCreateDuplicate, AccountNotExists,
|
AccountCreateDuplicate, AccountNotExists,
|
||||||
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
|
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
|
||||||
UserUpdateFailEmail,
|
UserUpdateFailEmail, InvalidAccountNameException
|
||||||
)
|
)
|
||||||
from ..decorators import (
|
from ..decorators import (
|
||||||
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
|
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
|
||||||
apikey_can_create_domain, apikey_can_remove_domain,
|
apikey_can_create_domain, apikey_can_remove_domain,
|
||||||
apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
|
apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
|
||||||
api_role_can, apikey_or_basic_auth,
|
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 secrets
|
||||||
import string
|
import string
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||||
|
apilist_bp = Blueprint('apilist', __name__, url_prefix='/')
|
||||||
|
|
||||||
apikey_schema = ApiKeySchema(many=True)
|
apikey_schema = ApiKeySchema(many=True)
|
||||||
apikey_single_schema = ApiKeySchema()
|
apikey_single_schema = ApiKeySchema()
|
||||||
@ -47,6 +49,7 @@ user_detailed_schema = UserDetailedSchema()
|
|||||||
account_schema = AccountSchema(many=True)
|
account_schema = AccountSchema(many=True)
|
||||||
account_single_schema = AccountSchema()
|
account_single_schema = AccountSchema()
|
||||||
|
|
||||||
|
|
||||||
def get_user_domains():
|
def get_user_domains():
|
||||||
domains = db.session.query(Domain) \
|
domains = db.session.query(Domain) \
|
||||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
.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_bp.route('/pdnsadmin/zones', methods=['POST'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_can_create_domain
|
@api_can_create_domain
|
||||||
|
@csrf.exempt
|
||||||
def api_login_create_zone():
|
def api_login_create_zone():
|
||||||
pdns_api_url = Setting().get('pdns_api_url')
|
pdns_api_url = Setting().get('pdns_api_url')
|
||||||
pdns_api_key = Setting().get('pdns_api_key')
|
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'
|
api_full_uri = api_uri_with_prefix + '/servers/localhost/zones'
|
||||||
headers = {}
|
headers = {}
|
||||||
headers['X-API-Key'] = pdns_api_key
|
headers['X-API-Key'] = pdns_api_key
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
msg_str = "Sending request to powerdns API {0}"
|
msg_str = "Sending request to powerdns API {0}"
|
||||||
msg = msg_str.format(request.get_json(force=True))
|
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_bp.route('/pdnsadmin/zones/<string:domain_name>', methods=['DELETE'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_can_create_domain
|
@api_can_create_domain
|
||||||
|
@csrf.exempt
|
||||||
def api_login_delete_zone(domain_name):
|
def api_login_delete_zone(domain_name):
|
||||||
pdns_api_url = Setting().get('pdns_api_url')
|
pdns_api_url = Setting().get('pdns_api_url')
|
||||||
pdns_api_key = Setting().get('pdns_api_key')
|
pdns_api_key = Setting().get('pdns_api_key')
|
||||||
@ -287,13 +298,12 @@ def api_login_delete_zone(domain_name):
|
|||||||
domain.update()
|
domain.update()
|
||||||
|
|
||||||
history = History(msg='Delete domain {0}'.format(
|
history = History(msg='Delete domain {0}'.format(
|
||||||
pretty_domain_name(domain_name)),
|
utils.pretty_domain_name(domain_name)),
|
||||||
detail='',
|
detail='',
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
domain_id=domain_id)
|
domain_id=domain_id)
|
||||||
history.add()
|
history.add()
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error('Error: {0}'.format(e))
|
current_app.logger.error('Error: {0}'.format(e))
|
||||||
abort(500)
|
abort(500)
|
||||||
@ -303,6 +313,7 @@ def api_login_delete_zone(domain_name):
|
|||||||
|
|
||||||
@api_bp.route('/pdnsadmin/apikeys', methods=['POST'])
|
@api_bp.route('/pdnsadmin/apikeys', methods=['POST'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
|
@csrf.exempt
|
||||||
def api_generate_apikey():
|
def api_generate_apikey():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
description = None
|
description = None
|
||||||
@ -459,6 +470,7 @@ def api_get_apikey(apikey_id):
|
|||||||
|
|
||||||
@api_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', methods=['DELETE'])
|
@api_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', methods=['DELETE'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
|
@csrf.exempt
|
||||||
def api_delete_apikey(apikey_id):
|
def api_delete_apikey(apikey_id):
|
||||||
apikey = ApiKey.query.get(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_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', methods=['PUT'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
|
@csrf.exempt
|
||||||
def api_update_apikey(apikey_id):
|
def api_update_apikey(apikey_id):
|
||||||
# if role different and user is allowed to change it, update
|
# if role different and user is allowed to change it, update
|
||||||
# if apikey domains are different and user is allowed to handle
|
# 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_bp.route('/pdnsadmin/users', methods=['POST'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('create users', allow_self=True)
|
@api_role_can('create users', allow_self=True)
|
||||||
|
@csrf.exempt
|
||||||
def api_create_user():
|
def api_create_user():
|
||||||
"""
|
"""
|
||||||
Create new user
|
Create new user
|
||||||
@ -730,6 +744,7 @@ def api_create_user():
|
|||||||
@api_bp.route('/pdnsadmin/users/<int:user_id>', methods=['PUT'])
|
@api_bp.route('/pdnsadmin/users/<int:user_id>', methods=['PUT'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('update users', allow_self=True)
|
@api_role_can('update users', allow_self=True)
|
||||||
|
@csrf.exempt
|
||||||
def api_update_user(user_id):
|
def api_update_user(user_id):
|
||||||
"""
|
"""
|
||||||
Update existing user
|
Update existing user
|
||||||
@ -802,6 +817,7 @@ def api_update_user(user_id):
|
|||||||
@api_bp.route('/pdnsadmin/users/<int:user_id>', methods=['DELETE'])
|
@api_bp.route('/pdnsadmin/users/<int:user_id>', methods=['DELETE'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('delete users')
|
@api_role_can('delete users')
|
||||||
|
@csrf.exempt
|
||||||
def api_delete_user(user_id):
|
def api_delete_user(user_id):
|
||||||
user = User.query.get(user_id)
|
user = User.query.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@ -853,6 +869,7 @@ def api_list_accounts(account_name):
|
|||||||
|
|
||||||
@api_bp.route('/pdnsadmin/accounts', methods=['POST'])
|
@api_bp.route('/pdnsadmin/accounts', methods=['POST'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
|
@csrf.exempt
|
||||||
def api_create_account():
|
def api_create_account():
|
||||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||||
msg = "{} role cannot create accounts".format(current_user.role.name)
|
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
|
contact = data['contact'] if 'contact' in data else None
|
||||||
mail = data['mail'] if 'mail' in data else None
|
mail = data['mail'] if 'mail' in data else None
|
||||||
if not name:
|
if not name:
|
||||||
current_app.logger.debug("Account name missing")
|
current_app.logger.debug("Account creation failed: name missing")
|
||||||
abort(400)
|
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:
|
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)
|
current_app.logger.debug(msg)
|
||||||
raise AccountCreateDuplicate(message=msg)
|
raise AccountCreateDuplicate(message=msg)
|
||||||
|
|
||||||
@ -894,6 +914,7 @@ def api_create_account():
|
|||||||
@api_bp.route('/pdnsadmin/accounts/<int:account_id>', methods=['PUT'])
|
@api_bp.route('/pdnsadmin/accounts/<int:account_id>', methods=['PUT'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('update accounts')
|
@api_role_can('update accounts')
|
||||||
|
@csrf.exempt
|
||||||
def api_update_account(account_id):
|
def api_update_account(account_id):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
name = data['name'] if 'name' in data else None
|
name = data['name'] if 'name' in data else None
|
||||||
@ -906,8 +927,9 @@ def api_update_account(account_id):
|
|||||||
if not account:
|
if not account:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if name and name != account.name:
|
if name and Account.sanitize_name(name) != account.name:
|
||||||
abort(400)
|
msg = "Account name is immutable"
|
||||||
|
raise AccountUpdateFail(message=msg)
|
||||||
|
|
||||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||||
msg = "User role update accounts"
|
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_bp.route('/pdnsadmin/accounts/<int:account_id>', methods=['DELETE'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('delete accounts')
|
@api_role_can('delete accounts')
|
||||||
|
@csrf.exempt
|
||||||
def api_delete_account(account_id):
|
def api_delete_account(account_id):
|
||||||
account_list = [] or Account.query.filter(Account.id == account_id).all()
|
account_list = [] or Account.query.filter(Account.id == account_id).all()
|
||||||
if len(account_list) == 1:
|
if len(account_list) == 1:
|
||||||
account = account_list[0]
|
account = account_list[0]
|
||||||
else:
|
else:
|
||||||
abort(404)
|
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(
|
current_app.logger.debug(
|
||||||
"Deleting account {} ({})".format(account_id, account.name))
|
"Deleting account {} ({})".format(account_id, account.name))
|
||||||
result = account.delete_account()
|
result = account.delete_account()
|
||||||
@ -973,6 +1008,7 @@ def api_list_account_users(account_id):
|
|||||||
methods=['PUT'])
|
methods=['PUT'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('add user to account')
|
@api_role_can('add user to account')
|
||||||
|
@csrf.exempt
|
||||||
def api_add_account_user(account_id, user_id):
|
def api_add_account_user(account_id, user_id):
|
||||||
account = Account.query.get(account_id)
|
account = Account.query.get(account_id)
|
||||||
if not account:
|
if not account:
|
||||||
@ -1000,6 +1036,7 @@ def api_add_account_user(account_id, user_id):
|
|||||||
methods=['DELETE'])
|
methods=['DELETE'])
|
||||||
@api_basic_auth
|
@api_basic_auth
|
||||||
@api_role_can('remove user from account')
|
@api_role_can('remove user from account')
|
||||||
|
@csrf.exempt
|
||||||
def api_remove_account_user(account_id, user_id):
|
def api_remove_account_user(account_id, user_id):
|
||||||
account = Account.query.get(account_id)
|
account = Account.query.get(account_id)
|
||||||
if not account:
|
if not account:
|
||||||
@ -1031,6 +1068,7 @@ def api_remove_account_user(account_id, user_id):
|
|||||||
@apikey_auth
|
@apikey_auth
|
||||||
@apikey_can_access_domain
|
@apikey_can_access_domain
|
||||||
@apikey_can_configure_dnssec(http_methods=['POST'])
|
@apikey_can_configure_dnssec(http_methods=['POST'])
|
||||||
|
@csrf.exempt
|
||||||
def api_zone_cryptokeys(server_id, zone_id):
|
def api_zone_cryptokeys(server_id, zone_id):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
return resp.content, resp.status_code, resp.headers.items()
|
return resp.content, resp.status_code, resp.headers.items()
|
||||||
@ -1042,6 +1080,7 @@ def api_zone_cryptokeys(server_id, zone_id):
|
|||||||
@apikey_auth
|
@apikey_auth
|
||||||
@apikey_can_access_domain
|
@apikey_can_access_domain
|
||||||
@apikey_can_configure_dnssec()
|
@apikey_can_configure_dnssec()
|
||||||
|
@csrf.exempt
|
||||||
def api_zone_cryptokey(server_id, zone_id, cryptokey_id):
|
def api_zone_cryptokey(server_id, zone_id, cryptokey_id):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
return resp.content, resp.status_code, resp.headers.items()
|
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'])
|
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
|
||||||
@apikey_auth
|
@apikey_auth
|
||||||
@apikey_can_access_domain
|
@apikey_can_access_domain
|
||||||
|
@csrf.exempt
|
||||||
def api_zone_subpath_forward(server_id, zone_id, subpath):
|
def api_zone_subpath_forward(server_id, zone_id, subpath):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
return resp.content, resp.status_code, resp.headers.items()
|
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>',
|
@api_bp.route('/servers/<string:server_id>/zones/<string:zone_id>',
|
||||||
methods=['GET', 'PUT', 'PATCH', 'DELETE'])
|
methods=['GET', 'PUT', 'PATCH', 'DELETE'])
|
||||||
@apikey_auth
|
@apikey_auth
|
||||||
|
@allowed_record_types
|
||||||
|
@allowed_record_ttl
|
||||||
@apikey_can_access_domain
|
@apikey_can_access_domain
|
||||||
@apikey_can_remove_domain(http_methods=['DELETE'])
|
@apikey_can_remove_domain(http_methods=['DELETE'])
|
||||||
@callback_if_request_body_contains_key(apikey_can_configure_dnssec()(),
|
@callback_if_request_body_contains_key(apikey_can_configure_dnssec()(),
|
||||||
http_methods=['PUT'],
|
http_methods=['PUT'],
|
||||||
keys=['dnssec', 'nsec3param'])
|
keys=['dnssec', 'nsec3param'])
|
||||||
|
@csrf.exempt
|
||||||
def api_zone_forward(server_id, zone_id):
|
def api_zone_forward(server_id, zone_id):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
if not Setting().get('bg_domain_updates'):
|
if not Setting().get('bg_domain_updates'):
|
||||||
@ -1102,6 +1145,7 @@ def api_zone_forward(server_id, zone_id):
|
|||||||
@api_bp.route('/servers/<path:subpath>', methods=['GET', 'PUT'])
|
@api_bp.route('/servers/<path:subpath>', methods=['GET', 'PUT'])
|
||||||
@apikey_auth
|
@apikey_auth
|
||||||
@apikey_is_admin
|
@apikey_is_admin
|
||||||
|
@csrf.exempt
|
||||||
def api_server_sub_forward(subpath):
|
def api_server_sub_forward(subpath):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
return resp.content, resp.status_code, resp.headers.items()
|
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'])
|
@api_bp.route('/servers/<string:server_id>/zones', methods=['POST'])
|
||||||
@apikey_auth
|
@apikey_auth
|
||||||
@apikey_can_create_domain
|
@apikey_can_create_domain
|
||||||
|
@csrf.exempt
|
||||||
def api_create_zone(server_id):
|
def api_create_zone(server_id):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
|
|
||||||
@ -1147,8 +1192,10 @@ def api_get_zones(server_id):
|
|||||||
return jsonify(domain_schema.dump(domain_obj_list)), 200
|
return jsonify(domain_schema.dump(domain_obj_list)), 200
|
||||||
else:
|
else:
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
if (g.apikey.role.name not in ['Administrator', 'Operator']
|
if (
|
||||||
and resp.status_code == 200):
|
g.apikey.role.name not in ['Administrator', 'Operator']
|
||||||
|
and resp.status_code == 200
|
||||||
|
):
|
||||||
domain_list = [d['name']
|
domain_list = [d['name']
|
||||||
for d in domain_schema.dump(g.apikey.domains)]
|
for d in domain_schema.dump(g.apikey.domains)]
|
||||||
|
|
||||||
@ -1169,16 +1216,35 @@ def api_server_forward():
|
|||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
return resp.content, resp.status_code, resp.headers.items()
|
return resp.content, resp.status_code, resp.headers.items()
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/servers/<string:server_id>', methods=['GET'])
|
@api_bp.route('/servers/<string:server_id>', methods=['GET'])
|
||||||
@apikey_auth
|
@apikey_auth
|
||||||
def api_server_config_forward(server_id):
|
def api_server_config_forward(server_id):
|
||||||
resp = helper.forward_request()
|
resp = helper.forward_request()
|
||||||
return resp.content, resp.status_code, resp.headers.items()
|
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'])
|
@api_bp.route('/sync_domains', methods=['GET'])
|
||||||
@apikey_or_basic_auth
|
@apikey_or_basic_auth
|
||||||
def sync_domains():
|
def sync_domains():
|
||||||
domain = Domain()
|
domain = Domain()
|
||||||
domain.update()
|
domain.update()
|
||||||
return 'Finished synchronization in background', 200
|
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
|
import base64
|
||||||
|
|
||||||
from flask import render_template, url_for, redirect, session, request, current_app
|
from flask import render_template, url_for, redirect, session, request, current_app
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
|
from flask_seasurf import SeaSurf
|
||||||
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
csrf = SeaSurf()
|
||||||
login_manager = LoginManager()
|
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_domain_name
|
||||||
from ..lib.utils import pretty_json
|
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 ..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.user import User, Anonymous
|
||||||
from ..models.account import Account
|
from ..models.account import Account
|
||||||
@ -31,7 +32,6 @@ domain_bp = Blueprint('domain',
|
|||||||
template_folder='templates',
|
template_folder='templates',
|
||||||
url_prefix='/domain')
|
url_prefix='/domain')
|
||||||
|
|
||||||
|
|
||||||
@domain_bp.before_request
|
@domain_bp.before_request
|
||||||
def before_request():
|
def before_request():
|
||||||
# Check if user is anonymous
|
# Check if user is anonymous
|
||||||
@ -63,7 +63,7 @@ def domain(domain_name):
|
|||||||
|
|
||||||
# Query domain's rrsets from PowerDNS API
|
# Query domain's rrsets from PowerDNS API
|
||||||
rrsets = Record().get_rrsets(domain.name)
|
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
|
# API server might be down, misconfigured
|
||||||
if not rrsets and domain.type != 'Slave':
|
if not rrsets and domain.type != 'Slave':
|
||||||
@ -202,7 +202,7 @@ def changelog(domain_name):
|
|||||||
|
|
||||||
# Query domain's rrsets from PowerDNS API
|
# Query domain's rrsets from PowerDNS API
|
||||||
rrsets = Record().get_rrsets(domain.name)
|
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
|
# API server might be down, misconfigured
|
||||||
if not rrsets and domain.type != 'Slave':
|
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)
|
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
|
@login_required
|
||||||
@can_access_domain
|
@can_access_domain
|
||||||
@history_access_required
|
@history_access_required
|
||||||
@ -290,7 +290,7 @@ def record_changelog(domain_name, record_name, record_type):
|
|||||||
abort(404)
|
abort(404)
|
||||||
# Query domain's rrsets from PowerDNS API
|
# Query domain's rrsets from PowerDNS API
|
||||||
rrsets = Record().get_rrsets(domain.name)
|
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
|
# API server might be down, misconfigured
|
||||||
if not rrsets and domain.type != 'Slave':
|
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:
|
for change_num in changes_set_of_record:
|
||||||
changes_i = changes_set_of_record[change_num]
|
changes_i = changes_set_of_record[change_num]
|
||||||
for hre in changes_i: # for each history record entry in changes_i
|
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
|
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
|
continue
|
||||||
else:
|
else:
|
||||||
changes_set_of_record[change_num].remove(hre)
|
changes_set_of_record[change_num].remove(hre)
|
||||||
@ -363,6 +363,9 @@ def add():
|
|||||||
'errors/400.html',
|
'errors/400.html',
|
||||||
msg="Please enter a valid domain name"), 400
|
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 User creates the domain, check some additional stuff
|
||||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||||
# Get all the account_ids of the user
|
# Get all the account_ids of the user
|
||||||
@ -379,7 +382,7 @@ def add():
|
|||||||
|
|
||||||
# Encode domain name into punycode (IDN)
|
# Encode domain name into punycode (IDN)
|
||||||
try:
|
try:
|
||||||
domain_name = domain_name.encode('idna').decode()
|
domain_name = to_idna(domain_name, 'encode')
|
||||||
except:
|
except:
|
||||||
current_app.logger.error("Cannot encode the domain name {}".format(domain_name))
|
current_app.logger.error("Cannot encode the domain name {}".format(domain_name))
|
||||||
current_app.logger.debug(traceback.format_exc())
|
current_app.logger.debug(traceback.format_exc())
|
||||||
@ -400,6 +403,38 @@ def add():
|
|||||||
account_name = Account().get_name_by_id(account_id)
|
account_name = Account().get_name_by_id(account_id)
|
||||||
|
|
||||||
d = Domain()
|
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,
|
result = d.add(domain_name=domain_name,
|
||||||
domain_type=domain_type,
|
domain_type=domain_type,
|
||||||
soa_edit_api=soa_edit_api,
|
soa_edit_api=soa_edit_api,
|
||||||
@ -409,7 +444,7 @@ def add():
|
|||||||
domain_id = Domain().get_id_by_name(domain_name)
|
domain_id = Domain().get_id_by_name(domain_name)
|
||||||
history = History(msg='Add domain {0}'.format(
|
history = History(msg='Add domain {0}'.format(
|
||||||
pretty_domain_name(domain_name)),
|
pretty_domain_name(domain_name)),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
'domain_type': domain_type,
|
'domain_type': domain_type,
|
||||||
'domain_master_ips': domain_master_ips,
|
'domain_master_ips': domain_master_ips,
|
||||||
'account_id': account_id
|
'account_id': account_id
|
||||||
@ -445,17 +480,16 @@ def add():
|
|||||||
history = History(
|
history = History(
|
||||||
msg='Applying template {0} to {1} successfully.'.
|
msg='Applying template {0} to {1} successfully.'.
|
||||||
format(template.name, domain_name),
|
format(template.name, domain_name),
|
||||||
detail=str(
|
detail = json.dumps({
|
||||||
json.dumps({
|
'domain':
|
||||||
"domain":
|
|
||||||
domain_name,
|
domain_name,
|
||||||
"template":
|
'template':
|
||||||
template.name,
|
template.name,
|
||||||
"add_rrests":
|
'add_rrsets':
|
||||||
result['data'][0]['rrsets'],
|
result['data'][0]['rrsets'],
|
||||||
"del_rrests":
|
'del_rrsets':
|
||||||
result['data'][1]['rrsets']
|
result['data'][1]['rrsets']
|
||||||
})),
|
}),
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
domain_id=domain_id)
|
domain_id=domain_id)
|
||||||
history.add()
|
history.add()
|
||||||
@ -464,7 +498,7 @@ def add():
|
|||||||
msg=
|
msg=
|
||||||
'Failed to apply template {0} to {1}.'
|
'Failed to apply template {0} to {1}.'
|
||||||
.format(template.name, domain_name),
|
.format(template.name, domain_name),
|
||||||
detail=str(result),
|
detail = json.dumps(result),
|
||||||
created_by=current_user.username)
|
created_by=current_user.username)
|
||||||
history.add()
|
history.add()
|
||||||
return redirect(url_for('dashboard.dashboard'))
|
return redirect(url_for('dashboard.dashboard'))
|
||||||
@ -478,14 +512,17 @@ def add():
|
|||||||
|
|
||||||
# Get
|
# Get
|
||||||
else:
|
else:
|
||||||
|
domain_override_toggle = False
|
||||||
# Admins and Operators can set to any account
|
# Admins and Operators can set to any account
|
||||||
if current_user.role.name in ['Administrator', 'Operator']:
|
if current_user.role.name in ['Administrator', 'Operator']:
|
||||||
accounts = Account.query.order_by(Account.name).all()
|
accounts = Account.query.order_by(Account.name).all()
|
||||||
|
domain_override_toggle = True
|
||||||
else:
|
else:
|
||||||
accounts = current_user.get_accounts()
|
accounts = current_user.get_accounts()
|
||||||
return render_template('domain_add.html',
|
return render_template('domain_add.html',
|
||||||
templates=templates,
|
templates=templates,
|
||||||
accounts=accounts)
|
accounts=accounts,
|
||||||
|
domain_override_toggle=domain_override_toggle)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -545,7 +582,7 @@ def setting(domain_name):
|
|||||||
history = History(
|
history = History(
|
||||||
msg='Change domain {0} access control'.format(
|
msg='Change domain {0} access control'.format(
|
||||||
pretty_domain_name(domain_name)),
|
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,
|
created_by=current_user.username,
|
||||||
domain_id=d.id)
|
domain_id=d.id)
|
||||||
history.add()
|
history.add()
|
||||||
@ -583,7 +620,7 @@ def change_type(domain_name):
|
|||||||
if status['status'] == 'ok':
|
if status['status'] == 'ok':
|
||||||
history = History(msg='Update type for domain {0}'.format(
|
history = History(msg='Update type for domain {0}'.format(
|
||||||
pretty_domain_name(domain_name)),
|
pretty_domain_name(domain_name)),
|
||||||
detail=str({
|
detail=json.dumps({
|
||||||
"domain": domain_name,
|
"domain": domain_name,
|
||||||
"type": domain_type,
|
"type": domain_type,
|
||||||
"masters": domain_master_ips
|
"masters": domain_master_ips
|
||||||
@ -617,9 +654,9 @@ def change_soa_edit_api(domain_name):
|
|||||||
history = History(
|
history = History(
|
||||||
msg='Update soa_edit_api for domain {0}'.format(
|
msg='Update soa_edit_api for domain {0}'.format(
|
||||||
pretty_domain_name(domain_name)),
|
pretty_domain_name(domain_name)),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
"domain": domain_name,
|
'domain': domain_name,
|
||||||
"soa_edit_api": new_setting
|
'soa_edit_api': new_setting
|
||||||
}),
|
}),
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
domain_id=d.get_id_by_name(domain_name))
|
domain_id=d.get_id_by_name(domain_name))
|
||||||
@ -684,12 +721,11 @@ def record_apply(domain_name):
|
|||||||
if result['status'] == 'ok':
|
if result['status'] == 'ok':
|
||||||
history = History(
|
history = History(
|
||||||
msg='Apply record changes to domain {0}'.format(pretty_domain_name(domain_name)),
|
msg='Apply record changes to domain {0}'.format(pretty_domain_name(domain_name)),
|
||||||
detail=str(
|
detail = json.dumps({
|
||||||
json.dumps({
|
'domain': domain_name,
|
||||||
"domain": domain_name,
|
'add_rrsets': result['data'][0]['rrsets'],
|
||||||
"add_rrests": result['data'][0]['rrsets'],
|
'del_rrsets': result['data'][1]['rrsets']
|
||||||
"del_rrests": result['data'][1]['rrsets']
|
}),
|
||||||
})),
|
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
domain_id=domain.id)
|
domain_id=domain.id)
|
||||||
history.add()
|
history.add()
|
||||||
@ -698,11 +734,10 @@ def record_apply(domain_name):
|
|||||||
history = History(
|
history = History(
|
||||||
msg='Failed to apply record changes to domain {0}'.format(
|
msg='Failed to apply record changes to domain {0}'.format(
|
||||||
pretty_domain_name(domain_name)),
|
pretty_domain_name(domain_name)),
|
||||||
detail=str(
|
detail = json.dumps({
|
||||||
json.dumps({
|
'domain': domain_name,
|
||||||
"domain": domain_name,
|
'msg': result['msg'],
|
||||||
"msg": result['msg'],
|
}),
|
||||||
})),
|
|
||||||
created_by=current_user.username)
|
created_by=current_user.username)
|
||||||
history.add()
|
history.add()
|
||||||
return make_response(jsonify(result), 400)
|
return make_response(jsonify(result), 400)
|
||||||
@ -780,6 +815,12 @@ def dnssec(domain_name):
|
|||||||
def dnssec_enable(domain_name):
|
def dnssec_enable(domain_name):
|
||||||
domain = Domain()
|
domain = Domain()
|
||||||
dnssec = domain.enable_domain_dnssec(domain_name)
|
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)
|
return make_response(jsonify(dnssec), 200)
|
||||||
|
|
||||||
|
|
||||||
@ -793,7 +834,12 @@ def dnssec_disable(domain_name):
|
|||||||
|
|
||||||
for key in dnssec['dnssec']:
|
for key in dnssec['dnssec']:
|
||||||
domain.delete_dnssec_key(domain_name, key['id'])
|
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.'}))
|
return make_response(jsonify({'status': 'ok', 'msg': 'DNSSEC removed.'}))
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,13 +4,13 @@ import json
|
|||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import base64
|
||||||
from distutils.util import strtobool
|
from distutils.util import strtobool
|
||||||
from yaml import Loader, load
|
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 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 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 ..lib import utils
|
||||||
from ..decorators import dyndns_login_required
|
from ..decorators import dyndns_login_required
|
||||||
from ..models.base import db
|
from ..models.base import db
|
||||||
@ -84,7 +84,6 @@ def index():
|
|||||||
|
|
||||||
|
|
||||||
@index_bp.route('/ping', methods=['GET'])
|
@index_bp.route('/ping', methods=['GET'])
|
||||||
@login_required
|
|
||||||
def ping():
|
def ping():
|
||||||
return make_response('ok')
|
return make_response('ok')
|
||||||
|
|
||||||
@ -167,10 +166,8 @@ def login():
|
|||||||
return redirect(url_for('index.login'))
|
return redirect(url_for('index.login'))
|
||||||
|
|
||||||
session['user_id'] = user.id
|
session['user_id'] = user.id
|
||||||
login_user(user, remember=False)
|
|
||||||
session['authentication_type'] = 'OAuth'
|
session['authentication_type'] = 'OAuth'
|
||||||
signin_history(user.username, 'Google OAuth', True)
|
return authenticate_user(user, 'Google OAuth')
|
||||||
return redirect(url_for('index.index'))
|
|
||||||
|
|
||||||
if 'github_token' in session:
|
if 'github_token' in session:
|
||||||
me = json.loads(github.get('user').text)
|
me = json.loads(github.get('user').text)
|
||||||
@ -195,9 +192,7 @@ def login():
|
|||||||
|
|
||||||
session['user_id'] = user.id
|
session['user_id'] = user.id
|
||||||
session['authentication_type'] = 'OAuth'
|
session['authentication_type'] = 'OAuth'
|
||||||
login_user(user, remember=False)
|
return authenticate_user(user, 'Github OAuth')
|
||||||
signin_history(user.username, 'Github OAuth', True)
|
|
||||||
return redirect(url_for('index.index'))
|
|
||||||
|
|
||||||
if 'azure_token' in session:
|
if 'azure_token' in session:
|
||||||
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
|
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
|
||||||
@ -329,7 +324,8 @@ def login():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
account = Account()
|
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:
|
if account_id:
|
||||||
account = Account.query.get(account_id)
|
account = Account.query.get(account_id)
|
||||||
@ -350,10 +346,12 @@ def login():
|
|||||||
current_app.logger.info('User {} added to Account {}'.format(
|
current_app.logger.info('User {} added to Account {}'.format(
|
||||||
user.username, account.name))
|
user.username, account.name))
|
||||||
else:
|
else:
|
||||||
account.name = group_name
|
account = Account(
|
||||||
account.description = group_description
|
name=sanitized_group_name,
|
||||||
account.contact = ''
|
description=group_description,
|
||||||
account.mail = ''
|
contact='',
|
||||||
|
mail=''
|
||||||
|
)
|
||||||
account.create_account()
|
account.create_account()
|
||||||
history = History(msg='Create account {0}'.format(
|
history = History(msg='Create account {0}'.format(
|
||||||
account.name),
|
account.name),
|
||||||
@ -366,10 +364,7 @@ def login():
|
|||||||
history.add()
|
history.add()
|
||||||
current_app.logger.warning('group info: {} '.format(account_id))
|
current_app.logger.warning('group info: {} '.format(account_id))
|
||||||
|
|
||||||
|
return authenticate_user(user, 'Azure OAuth')
|
||||||
login_user(user, remember=False)
|
|
||||||
signin_history(user.username, 'Azure OAuth', True)
|
|
||||||
return redirect(url_for('index.index'))
|
|
||||||
|
|
||||||
if 'oidc_token' in session:
|
if 'oidc_token' in session:
|
||||||
me = json.loads(oidc.get('userinfo').text)
|
me = json.loads(oidc.get('userinfo').text)
|
||||||
@ -433,9 +428,7 @@ def login():
|
|||||||
|
|
||||||
session['user_id'] = user.id
|
session['user_id'] = user.id
|
||||||
session['authentication_type'] = 'OAuth'
|
session['authentication_type'] = 'OAuth'
|
||||||
login_user(user, remember=False)
|
return authenticate_user(user, 'OIDC OAuth')
|
||||||
signin_history(user.username, 'OIDC OAuth', True)
|
|
||||||
return redirect(url_for('index.index'))
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return render_template('login.html', saml_enabled=SAML_ENABLED)
|
return render_template('login.html', saml_enabled=SAML_ENABLED)
|
||||||
@ -469,7 +462,7 @@ def login():
|
|||||||
auth = user.is_validate(method=auth_method,
|
auth = user.is_validate(method=auth_method,
|
||||||
src_ip=request.remote_addr)
|
src_ip=request.remote_addr)
|
||||||
if auth == False:
|
if auth == False:
|
||||||
signin_history(user.username, 'LOCAL', False)
|
signin_history(user.username, auth_method, False)
|
||||||
return render_template('login.html',
|
return render_template('login.html',
|
||||||
saml_enabled=SAML_ENABLED,
|
saml_enabled=SAML_ENABLED,
|
||||||
error='Invalid credentials')
|
error='Invalid credentials')
|
||||||
@ -486,7 +479,7 @@ def login():
|
|||||||
if otp_token and otp_token.isdigit():
|
if otp_token and otp_token.isdigit():
|
||||||
good_token = user.verify_totp(otp_token)
|
good_token = user.verify_totp(otp_token)
|
||||||
if not good_token:
|
if not good_token:
|
||||||
signin_history(user.username, 'LOCAL', False)
|
signin_history(user.username, auth_method, False)
|
||||||
return render_template('login.html',
|
return render_template('login.html',
|
||||||
saml_enabled=SAML_ENABLED,
|
saml_enabled=SAML_ENABLED,
|
||||||
error='Invalid credentials')
|
error='Invalid credentials')
|
||||||
@ -512,9 +505,7 @@ def login():
|
|||||||
user.revoke_privilege(True)
|
user.revoke_privilege(True)
|
||||||
current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' )
|
current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' )
|
||||||
|
|
||||||
login_user(user, remember=remember_me)
|
return authenticate_user(user, auth_method, remember_me)
|
||||||
signin_history(user.username, 'LOCAL', True)
|
|
||||||
return redirect(session.get('next', url_for('index.index')))
|
|
||||||
|
|
||||||
def checkForPDAEntries(Entitlements, urn_value):
|
def checkForPDAEntries(Entitlements, urn_value):
|
||||||
"""
|
"""
|
||||||
@ -561,11 +552,11 @@ def signin_history(username, authenticator, success):
|
|||||||
|
|
||||||
# Write history
|
# Write history
|
||||||
History(msg='User {} authentication {}'.format(username, str_success),
|
History(msg='User {} authentication {}'.format(username, str_success),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
"username": username,
|
'username': username,
|
||||||
"authenticator": authenticator,
|
'authenticator': authenticator,
|
||||||
"ip_address": request_ip,
|
'ip_address': request_ip,
|
||||||
"success": 1 if success else 0
|
'success': 1 if success else 0
|
||||||
}),
|
}),
|
||||||
created_by='System').add()
|
created_by='System').add()
|
||||||
|
|
||||||
@ -584,6 +575,23 @@ def get_azure_groups(uri):
|
|||||||
mygroups = []
|
mygroups = []
|
||||||
return 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')
|
@index_bp.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
@ -674,6 +682,11 @@ def register():
|
|||||||
if result and result['status']:
|
if result and result['status']:
|
||||||
if Setting().get('verify_user_email'):
|
if Setting().get('verify_user_email'):
|
||||||
send_account_verification(email)
|
send_account_verification(email)
|
||||||
|
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'))
|
return redirect(url_for('index.login'))
|
||||||
else:
|
else:
|
||||||
return render_template('register.html',
|
return render_template('register.html',
|
||||||
@ -684,6 +697,28 @@ def register():
|
|||||||
return render_template('errors/404.html'), 404
|
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'])
|
@index_bp.route('/confirm/<token>', methods=['GET'])
|
||||||
def confirm_email(token):
|
def confirm_email(token):
|
||||||
email = confirm_token(token)
|
email = confirm_token(token)
|
||||||
@ -729,6 +764,7 @@ def resend_confirmation_email():
|
|||||||
|
|
||||||
|
|
||||||
@index_bp.route('/nic/checkip.html', methods=['GET', 'POST'])
|
@index_bp.route('/nic/checkip.html', methods=['GET', 'POST'])
|
||||||
|
@csrf.exempt
|
||||||
def dyndns_checkip():
|
def dyndns_checkip():
|
||||||
# This route covers the default ddclient 'web' setting for the checkip service
|
# This route covers the default ddclient 'web' setting for the checkip service
|
||||||
return render_template('dyndns.html',
|
return render_template('dyndns.html',
|
||||||
@ -737,6 +773,7 @@ def dyndns_checkip():
|
|||||||
|
|
||||||
|
|
||||||
@index_bp.route('/nic/update', methods=['GET', 'POST'])
|
@index_bp.route('/nic/update', methods=['GET', 'POST'])
|
||||||
|
@csrf.exempt
|
||||||
@dyndns_login_required
|
@dyndns_login_required
|
||||||
def dyndns_update():
|
def dyndns_update():
|
||||||
# dyndns protocol response codes in use are:
|
# dyndns protocol response codes in use are:
|
||||||
@ -804,7 +841,7 @@ def dyndns_update():
|
|||||||
|
|
||||||
remote_addr = utils.validate_ipaddress(
|
remote_addr = utils.validate_ipaddress(
|
||||||
request.headers.get('X-Forwarded-For',
|
request.headers.get('X-Forwarded-For',
|
||||||
request.remote_addr).split(', ')[:1])
|
request.remote_addr).split(', ')[0])
|
||||||
|
|
||||||
response = 'nochg'
|
response = 'nochg'
|
||||||
for ip in myip_addr or remote_addr:
|
for ip in myip_addr or remote_addr:
|
||||||
@ -831,12 +868,12 @@ def dyndns_update():
|
|||||||
if result['status'] == 'ok':
|
if result['status'] == 'ok':
|
||||||
history = History(
|
history = History(
|
||||||
msg='DynDNS update: updated {} successfully'.format(hostname),
|
msg='DynDNS update: updated {} successfully'.format(hostname),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
"domain": domain.name,
|
'domain': domain.name,
|
||||||
"record": hostname,
|
'record': hostname,
|
||||||
"type": rtype,
|
'type': rtype,
|
||||||
"old_value": oldip,
|
'old_value': oldip,
|
||||||
"new_value": str(ip)
|
'new_value': str(ip)
|
||||||
}),
|
}),
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
domain_id=domain.id)
|
domain_id=domain.id)
|
||||||
@ -873,10 +910,10 @@ def dyndns_update():
|
|||||||
msg=
|
msg=
|
||||||
'DynDNS update: created record {0} in zone {1} successfully'
|
'DynDNS update: created record {0} in zone {1} successfully'
|
||||||
.format(hostname, domain.name, str(ip)),
|
.format(hostname, domain.name, str(ip)),
|
||||||
detail=str({
|
detail = json.dumps({
|
||||||
"domain": domain.name,
|
'domain': domain.name,
|
||||||
"record": hostname,
|
'record': hostname,
|
||||||
"value": str(ip)
|
'value': str(ip)
|
||||||
}),
|
}),
|
||||||
created_by=current_user.username,
|
created_by=current_user.username,
|
||||||
domain_id=domain.id)
|
domain_id=domain.id)
|
||||||
@ -898,6 +935,7 @@ def dyndns_update():
|
|||||||
def saml_login():
|
def saml_login():
|
||||||
if not current_app.config.get('SAML_ENABLED'):
|
if not current_app.config.get('SAML_ENABLED'):
|
||||||
abort(400)
|
abort(400)
|
||||||
|
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||||
req = saml.prepare_flask_request(request)
|
req = saml.prepare_flask_request(request)
|
||||||
auth = saml.init_saml_auth(req)
|
auth = saml.init_saml_auth(req)
|
||||||
redirect_url = OneLogin_Saml2_Utils.get_self_url(req) + url_for(
|
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'):
|
if not current_app.config.get('SAML_ENABLED'):
|
||||||
current_app.logger.error("SAML authentication is disabled.")
|
current_app.logger.error("SAML authentication is disabled.")
|
||||||
abort(400)
|
abort(400)
|
||||||
|
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||||
req = saml.prepare_flask_request(request)
|
req = saml.prepare_flask_request(request)
|
||||||
auth = saml.init_saml_auth(req)
|
auth = saml.init_saml_auth(req)
|
||||||
settings = auth.get_settings()
|
settings = auth.get_settings()
|
||||||
@ -926,11 +964,13 @@ def saml_metadata():
|
|||||||
|
|
||||||
|
|
||||||
@index_bp.route('/saml/authorized', methods=['GET', 'POST'])
|
@index_bp.route('/saml/authorized', methods=['GET', 'POST'])
|
||||||
|
@csrf.exempt
|
||||||
def saml_authorized():
|
def saml_authorized():
|
||||||
errors = []
|
errors = []
|
||||||
if not current_app.config.get('SAML_ENABLED'):
|
if not current_app.config.get('SAML_ENABLED'):
|
||||||
current_app.logger.error("SAML authentication is disabled.")
|
current_app.logger.error("SAML authentication is disabled.")
|
||||||
abort(400)
|
abort(400)
|
||||||
|
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||||
req = saml.prepare_flask_request(request)
|
req = saml.prepare_flask_request(request)
|
||||||
auth = saml.init_saml_auth(req)
|
auth = saml.init_saml_auth(req)
|
||||||
auth.process_response()
|
auth.process_response()
|
||||||
@ -974,6 +1014,8 @@ def saml_authorized():
|
|||||||
None)
|
None)
|
||||||
admin_group_name = current_app.config.get('SAML_GROUP_ADMIN_NAME',
|
admin_group_name = current_app.config.get('SAML_GROUP_ADMIN_NAME',
|
||||||
None)
|
None)
|
||||||
|
operator_group_name = current_app.config.get('SAML_GROUP_OPERATOR_NAME',
|
||||||
|
None)
|
||||||
group_to_account_mapping = create_group_to_account_mapping()
|
group_to_account_mapping = create_group_to_account_mapping()
|
||||||
|
|
||||||
if email_attribute_name in session['samlUserdata']:
|
if email_attribute_name in session['samlUserdata']:
|
||||||
@ -1027,6 +1069,8 @@ def saml_authorized():
|
|||||||
uplift_to_admin(user)
|
uplift_to_admin(user)
|
||||||
elif admin_group_name in user_groups:
|
elif admin_group_name in user_groups:
|
||||||
uplift_to_admin(user)
|
uplift_to_admin(user)
|
||||||
|
elif operator_group_name in user_groups:
|
||||||
|
uplift_to_operator(user)
|
||||||
elif admin_attribute_name or group_attribute_name:
|
elif admin_attribute_name or group_attribute_name:
|
||||||
if user.role.name != 'User':
|
if user.role.name != 'User':
|
||||||
user.role_id = Role.query.filter_by(name='User').first().id
|
user.role_id = Role.query.filter_by(name='User').first().id
|
||||||
@ -1037,9 +1081,7 @@ def saml_authorized():
|
|||||||
user.plain_text_password = None
|
user.plain_text_password = None
|
||||||
user.update_profile()
|
user.update_profile()
|
||||||
session['authentication_type'] = 'SAML'
|
session['authentication_type'] = 'SAML'
|
||||||
login_user(user, remember=False)
|
return authenticate_user(user, 'SAML')
|
||||||
signin_history(user.username, 'SAML', True)
|
|
||||||
return redirect(url_for('index.login'))
|
|
||||||
else:
|
else:
|
||||||
return render_template('errors/SAML.html', errors=errors)
|
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=""):
|
def handle_account(account_name, account_description=""):
|
||||||
clean_name = ''.join(c for c in account_name.lower()
|
clean_name = Account.sanitize_name(account_name)
|
||||||
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))
|
|
||||||
account = Account.query.filter_by(name=clean_name).first()
|
account = Account.query.filter_by(name=clean_name).first()
|
||||||
if not account:
|
if not account:
|
||||||
account = Account(name=clean_name.lower(),
|
account = Account(name=clean_name,
|
||||||
description=account_description,
|
description=account_description,
|
||||||
contact='',
|
contact='',
|
||||||
mail='')
|
mail='')
|
||||||
@ -1085,6 +1123,14 @@ def uplift_to_admin(user):
|
|||||||
created_by='SAML Assertion')
|
created_by='SAML Assertion')
|
||||||
history.add()
|
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')
|
@index_bp.route('/saml/sls')
|
||||||
def saml_logout():
|
def saml_logout():
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import qrcode as qrc
|
import hashlib
|
||||||
import qrcode.image.svg as qrc_svg
|
import imghdr
|
||||||
from io import BytesIO
|
import mimetypes
|
||||||
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
|
|
||||||
|
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 flask_login import current_user, login_required, login_manager
|
||||||
|
|
||||||
from ..models.user import User, Anonymous
|
from ..models.user import User, Anonymous
|
||||||
@ -94,13 +96,60 @@ def qrcode():
|
|||||||
if not current_user:
|
if not current_user:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
img = qrc.make(current_user.get_totp_uri(),
|
return current_user.get_qrcode_value(), 200, {
|
||||||
image_factory=qrc_svg.SvgPathImage)
|
|
||||||
stream = BytesIO()
|
|
||||||
img.save(stream)
|
|
||||||
return stream.getvalue(), 200, {
|
|
||||||
'Content-Type': 'image/svg+xml',
|
'Content-Type': 'image/svg+xml',
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
'Expires': '0'
|
'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):
|
def prepare_flask_request(self, request):
|
||||||
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
|
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
|
||||||
url_data = urlparse(request.url)
|
url_data = urlparse(request.url)
|
||||||
|
proto = request.headers.get('HTTP_X_FORWARDED_PROTO', request.scheme)
|
||||||
return {
|
return {
|
||||||
'https': 'on' if request.scheme == 'https' else 'off',
|
'https': 'on' if proto == 'https' else 'off',
|
||||||
'http_host': request.host,
|
'http_host': request.host,
|
||||||
'server_port': url_data.port,
|
'server_port': url_data.port,
|
||||||
'script_name': request.path,
|
'script_name': request.path,
|
||||||
@ -163,7 +164,8 @@ class SAML(object):
|
|||||||
'signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
|
'signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
|
||||||
settings['security']['wantAssertionsEncrypted'] = current_app.config.get(
|
settings['security']['wantAssertionsEncrypted'] = current_app.config.get(
|
||||||
'SAML_ASSERTION_ENCRYPTED', True)
|
'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']['wantNameId'] = True
|
||||||
settings['security']['authnRequestsSigned'] = current_app.config[
|
settings['security']['authnRequestsSigned'] = current_app.config[
|
||||||
'SAML_SIGN_REQUEST']
|
'SAML_SIGN_REQUEST']
|
||||||
|
@ -42,15 +42,6 @@ table td {
|
|||||||
background-position: center;
|
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 {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
@ -12,13 +12,7 @@ function applyChanges(data, url, showResult, refreshPage) {
|
|||||||
console.log("Applied changes successfully.");
|
console.log("Applied changes successfully.");
|
||||||
console.log(data);
|
console.log(data);
|
||||||
if (showResult) {
|
if (showResult) {
|
||||||
var modal = $("#modal_success");
|
showSuccessModal(data['msg'] || "Applied changes successfully");
|
||||||
if (data['msg']) {
|
|
||||||
modal.find('.modal-body p').text(data['msg']);
|
|
||||||
} else {
|
|
||||||
modal.find('.modal-body p').text("Applied changes successfully");
|
|
||||||
}
|
|
||||||
modal.modal('show');
|
|
||||||
}
|
}
|
||||||
if (refreshPage) {
|
if (refreshPage) {
|
||||||
location.reload(true);
|
location.reload(true);
|
||||||
@ -27,10 +21,8 @@ function applyChanges(data, url, showResult, refreshPage) {
|
|||||||
|
|
||||||
error : function(jqXHR, status) {
|
error : function(jqXHR, status) {
|
||||||
console.log(jqXHR);
|
console.log(jqXHR);
|
||||||
var modal = $("#modal_error");
|
|
||||||
var responseJson = jQuery.parseJSON(jqXHR.responseText);
|
var responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
modal.find('.modal-body p').text(responseJson['msg']);
|
showErrorModal(responseJson['msg']);
|
||||||
modal.modal('show');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -50,18 +42,14 @@ function applyRecordChanges(data, domain) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("Applied changes successfully.")
|
console.log("Applied changes successfully.")
|
||||||
var modal = $("#modal_success");
|
showSuccessModal("Applied changes successfully");
|
||||||
modal.find('.modal-body p').text("Applied changes successfully");
|
|
||||||
modal.modal('show');
|
|
||||||
setTimeout(() => {window.location.reload()}, 2000);
|
setTimeout(() => {window.location.reload()}, 2000);
|
||||||
},
|
},
|
||||||
|
|
||||||
error : function(jqXHR, status) {
|
error : function(jqXHR, status) {
|
||||||
console.log(jqXHR);
|
console.log(jqXHR);
|
||||||
var modal = $("#modal_error");
|
|
||||||
var responseJson = jQuery.parseJSON(jqXHR.responseText);
|
var responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
modal.find('.modal-body p').text(responseJson['msg']);
|
showErrorModal(responseJson['msg']);
|
||||||
modal.modal('show');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -76,13 +64,17 @@ function getTableData(table) {
|
|||||||
record["record_type"] = r[1].trim();
|
record["record_type"] = r[1].trim();
|
||||||
record["record_status"] = r[2].trim();
|
record["record_status"] = r[2].trim();
|
||||||
record["record_ttl"] = r[3].trim();
|
record["record_ttl"] = r[3].trim();
|
||||||
record["record_data"] = r[4].trim();
|
record["record_data"] = convertHTMLEntityToText(r[4].trim());
|
||||||
record["record_comment"] = r[5].trim();
|
record["record_comment"] = convertHTMLEntityToText(r[5].trim());
|
||||||
records.push(record);
|
records.push(record);
|
||||||
});
|
});
|
||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertHTMLEntityToText(htmlEntity) {
|
||||||
|
return $('<textarea />').html(htmlEntity).text();
|
||||||
|
}
|
||||||
|
|
||||||
function saveRow(oTable, nRow) {
|
function saveRow(oTable, nRow) {
|
||||||
|
|
||||||
var status = 'Disabled';
|
var status = 'Disabled';
|
||||||
@ -286,3 +278,13 @@ function timer(elToUpdate, maxTime) {
|
|||||||
|
|
||||||
return interval;
|
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);
|
||||||
|
}
|
Before Width: | Height: | Size: 26 KiB |
BIN
powerdnsadmin/static/img/user_image.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
@ -782,6 +782,32 @@ paths:
|
|||||||
'422':
|
'422':
|
||||||
description: 'Returned when something is wrong with the content of the request. Contains an error message'
|
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':
|
'/pdnsadmin/zones':
|
||||||
get:
|
get:
|
||||||
security:
|
security:
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
<span class="fa fa-cog form-control-feedback"></span>
|
<span class="fa fa-cog form-control-feedback"></span>
|
||||||
{% if invalid_accountname %}
|
{% if invalid_accountname %}
|
||||||
<span class="help-block">Cannot be blank and must only contain alphanumeric
|
<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 %}
|
{% elif duplicate_accountname %}
|
||||||
<span class="help-block">Account name already in use.</span>
|
<span class="help-block">Account name already in use.</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -112,8 +112,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>Fill in all the fields to the in the form to the left.</p>
|
<p>Fill in all the fields to the in the form to the left.</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Name</strong> is an account identifier. It will be stored as all lowercase letters (no
|
<strong>Name</strong> is an account identifier. It will be lowercased and can contain alphanumeric
|
||||||
spaces, special characters etc).<br />
|
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>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>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.
|
<strong>Mail Address</strong> is an e-mail address for the contact person.
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "admin_keys" %}
|
{% 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 %}
|
{% block title %}
|
||||||
<title>Edit Key - {{ SITE_NAME }}</title>
|
<title>Edit Key - {{ SITE_NAME }}</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -39,7 +39,9 @@
|
|||||||
<select class="key_role form-control" id="key_role" name="key_role">
|
<select class="key_role form-control" id="key_role" name="key_role">
|
||||||
{% for role in roles %}
|
{% for role in roles %}
|
||||||
<option value="{{ role.name }}"
|
<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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -446,9 +446,7 @@
|
|||||||
|
|
||||||
if(!canSearch)
|
if(!canSearch)
|
||||||
{
|
{
|
||||||
var modal = $("#modal_error");
|
showErrorModal("Please fill out the " + main_field + " field.");
|
||||||
modal.find('.modal-body p').text("Please fill out the " + main_field + " field.");
|
|
||||||
modal.modal('show');
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -24,19 +24,20 @@
|
|||||||
<td>{{ history.history.created_on }}</td>
|
<td>{{ history.history.created_on }}</td>
|
||||||
|
|
||||||
<td width="6%">
|
<td width="6%">
|
||||||
<button type="button" class="btn btn-flat btn-primary history-info-button"
|
<div id="history-info-div-{{ loop.index0 }}" style="display: none;">
|
||||||
{% if history.detailed_msg == "" and history.change_set == None %}
|
{{ history.detailed_msg | safe }}
|
||||||
style="visibility: hidden;"
|
{% if history.change_set %}
|
||||||
{% endif%}
|
|
||||||
value='{{ history.detailed_msg }}
|
|
||||||
{% if history.change_set != None %}
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div id="change_index_definition"></div>
|
<div id="change_index_definition"></div>
|
||||||
{% call applied_change_macro.applied_change_template(history.change_set) %}
|
{% call applied_change_macro.applied_change_template(history.change_set) %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
'>Info <i class="fa fa-info"></i>
|
</div>
|
||||||
|
<button type="button" class="btn btn-flat btn-primary history-info-button"
|
||||||
|
{% 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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -45,17 +46,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var table;
|
var table;
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
|
||||||
table = $('#tbl_history').DataTable({
|
table = $('#tbl_history').DataTable({
|
||||||
"order": [
|
"order": [
|
||||||
[2, "desc"]
|
[2, "desc"]
|
||||||
@ -74,22 +68,14 @@ $(document).ready(function () {
|
|||||||
fixedHeader: true
|
fixedHeader: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$(document.body).on('click', '.history-info-button', function () {
|
$(document.body).on('click', '.history-info-button', function () {
|
||||||
var modal = $("#modal_history_info");
|
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-info-content').html(info);
|
||||||
modal.modal('show');
|
modal.modal('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$(document.body).on("click", ".button-filter", function (e) {
|
$(document.body).on("click", ".button-filter", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
var nextRow = $("#filter-table")
|
var nextRow = $("#filter-table")
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for statistic in statistics %}
|
{% for statistic in statistics %}
|
||||||
<tr class="odd gradeX">
|
<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
|
target="_blank" class="btn btn-flat btn-xs blue"><i
|
||||||
class="fa fa-search"></i></a></td>
|
class="fa fa-search"></i></a></td>
|
||||||
<td>{{ statistic['name'] }}</td>
|
<td>{{ statistic['name'] }}</td>
|
||||||
@ -70,7 +70,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for config in configs %}
|
{% for config in configs %}
|
||||||
<tr class="odd gradeX">
|
<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
|
target="_blank" class="btn btn-flat btn-xs blue"><i
|
||||||
class="fa fa-search"></i></a></td>
|
class="fa fa-search"></i></a></td>
|
||||||
<td>{{ config['name'] }}</td>
|
<td>{{ config['name'] }}</td>
|
||||||
|
@ -735,15 +735,17 @@
|
|||||||
$('#ldap_admin_username').prop('required', true);
|
$('#ldap_admin_username').prop('required', true);
|
||||||
$('#ldap_admin_password').prop('required', true);
|
$('#ldap_admin_password').prop('required', true);
|
||||||
$('#ldap_domain').prop('required', false);
|
$('#ldap_domain').prop('required', false);
|
||||||
|
$('#ldap_filter_group').prop('required', true);
|
||||||
|
$('#ldap_filter_groupname').prop('required', true);
|
||||||
} else {
|
} else {
|
||||||
$('#ldap_admin_username').prop('required', false);
|
$('#ldap_admin_username').prop('required', false);
|
||||||
$('#ldap_admin_password').prop('required', false);
|
$('#ldap_admin_password').prop('required', false);
|
||||||
$('#ldap_domain').prop('required', true);
|
$('#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_basic').prop('required', true);
|
||||||
$('#ldap_filter_group').prop('required', true);
|
|
||||||
$('#ldap_filter_username').prop('required', true);
|
$('#ldap_filter_username').prop('required', true);
|
||||||
$('#ldap_filter_groupname').prop('required', true);
|
|
||||||
|
|
||||||
if ($('#ldap_sg_on').is(":checked")) {
|
if ($('#ldap_sg_on').is(":checked")) {
|
||||||
$('#ldap_admin_group').prop('required', true);
|
$('#ldap_admin_group').prop('required', true);
|
||||||
|
@ -7,28 +7,28 @@
|
|||||||
<th colspan="3">
|
<th colspan="3">
|
||||||
{% if hist_rec_entry.change_type == "+" %}
|
{% if hist_rec_entry.change_type == "+" %}
|
||||||
<span
|
<span
|
||||||
style="background-color: lightgreen">{{hist_rec_entry.add_rrest['name']}}
|
style="background-color: lightgreen">{{hist_rec_entry.add_rrset['name']}}
|
||||||
{{hist_rec_entry.add_rrest['type']}}</span>
|
{{hist_rec_entry.add_rrset['type']}}</span>
|
||||||
{% elif hist_rec_entry.change_type == "-" %}
|
{% elif hist_rec_entry.change_type == "-" %}
|
||||||
<s
|
<s
|
||||||
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
|
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
|
||||||
{{hist_rec_entry.del_rrest['name']}}
|
{{hist_rec_entry.del_rrset['name']}}
|
||||||
{{hist_rec_entry.del_rrest['type']}}
|
{{hist_rec_entry.del_rrset['type']}}
|
||||||
</s>
|
</s>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{hist_rec_entry.add_rrest['name']}}
|
{{hist_rec_entry.add_rrset['name']}}
|
||||||
{{hist_rec_entry.add_rrest['type']}}
|
{{hist_rec_entry.add_rrset['type']}}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
, TTL:
|
, TTL:
|
||||||
{% if "ttl" in hist_rec_entry.changed_fields %}
|
{% if "ttl" in hist_rec_entry.changed_fields %}
|
||||||
<s
|
<s
|
||||||
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
|
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
|
<span
|
||||||
style="background-color: lightgreen">{{hist_rec_entry.add_rrest['ttl']}}</span>
|
style="background-color: lightgreen">{{hist_rec_entry.add_rrset['ttl']}}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{hist_rec_entry.add_rrest['ttl']}}
|
{{hist_rec_entry.add_rrset['ttl']}}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</th>
|
</th>
|
||||||
@ -92,24 +92,24 @@
|
|||||||
{% for changes in hist_rec_entry.changeSet %}
|
{% for changes in hist_rec_entry.changeSet %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if changes[2] == "unchanged" %}
|
{% if changes[2] == "unchanged" %}
|
||||||
<td>
|
<td style="word-break: break-all">
|
||||||
{{changes[0]['content']}}
|
{{changes[0]['content']}}
|
||||||
</td>
|
</td>
|
||||||
{% elif changes[2] == "addition" %}
|
{% elif changes[2] == "addition" %}
|
||||||
<td>
|
<td style="word-break: break-all">
|
||||||
<span style="background-color: lightgreen">
|
<span style="background-color: lightgreen">
|
||||||
{{changes[1]['content']}}
|
{{changes[1]['content']}}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{% elif changes[2] == "deletion" %}
|
{% elif changes[2] == "deletion" %}
|
||||||
<td>
|
<td style="word-break: break-all">
|
||||||
<s
|
<s
|
||||||
style="text-decoration-color: rgba(194, 10, 10, 0.6); text-decoration-thickness: 2px;">
|
style="text-decoration-color: rgba(194, 10, 10, 0.6); text-decoration-thickness: 2px;">
|
||||||
{{changes[0]['content']}}
|
{{changes[0]['content']}}
|
||||||
</s>
|
</s>
|
||||||
</td>
|
</td>
|
||||||
{% elif changes[2] == "status" %}
|
{% elif changes[2] == "status" %}
|
||||||
<td>
|
<td style="word-break: break-all">
|
||||||
{{changes[0]['content']}}
|
{{changes[0]['content']}}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -119,8 +119,8 @@
|
|||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td style="word-break: break-all">
|
||||||
{% for comments in hist_rec_entry.add_rrest['comments'] %}
|
{% for comments in hist_rec_entry.add_rrset['comments'] %}
|
||||||
{{comments['content'] }}
|
{{comments['content'] }}
|
||||||
<br/>
|
<br/>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -8,13 +8,8 @@
|
|||||||
{% block title %}<title>{{ SITE_NAME }}</title>{% endblock %}
|
{% block title %}<title>{{ SITE_NAME }}</title>{% endblock %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/style.css') }}">
|
||||||
<!-- Get Google Fonts we like -->
|
<!-- 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/source_sans_pro.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/roboto_mono.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 -->
|
<!-- 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 content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
|
||||||
<!-- Tell Safari to not recognise telephone numbers -->
|
<!-- Tell Safari to not recognise telephone numbers -->
|
||||||
@ -25,20 +20,10 @@
|
|||||||
{% if SETTING.get('custom_css') %}
|
{% if SETTING.get('custom_css') %}
|
||||||
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
|
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="hold-transition skin-blue sidebar-mini {% if not SETTING.get('fullscreen_layout') %}layout-boxed{% endif %}">
|
<body class="hold-transition skin-blue sidebar-mini {% if not SETTING.get('fullscreen_layout') %}layout-boxed{% endif %}">
|
||||||
{% if OFFLINE_MODE %}
|
{% set user_image_url = url_for('user.image', username=current_user.username) %}
|
||||||
{% 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 %}
|
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
{% block pageheader %}
|
{% block pageheader %}
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
@ -47,7 +32,13 @@
|
|||||||
<!-- mini logo for sidebar mini 50x50 pixels -->
|
<!-- mini logo for sidebar mini 50x50 pixels -->
|
||||||
<span class="logo-mini"><b>PD</b>A</span>
|
<span class="logo-mini"><b>PD</b>A</span>
|
||||||
<!-- logo for regular state and mobile devices -->
|
<!-- 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>
|
</a>
|
||||||
<!-- Header Navbar: style can be found in header.less -->
|
<!-- Header Navbar: style can be found in header.less -->
|
||||||
<nav class="navbar navbar-static-top">
|
<nav class="navbar navbar-static-top">
|
||||||
@ -62,14 +53,14 @@
|
|||||||
<!-- User Account: style can be found in dropdown.less -->
|
<!-- User Account: style can be found in dropdown.less -->
|
||||||
<li class="dropdown user user-menu">
|
<li class="dropdown user user-menu">
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
<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">
|
<span class="hidden-xs">
|
||||||
{{ current_user.firstname }}
|
{{ current_user.firstname }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li class="user-header">
|
<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>
|
<p>
|
||||||
{{ current_user.firstname }} {{ current_user.lastname }}
|
{{ current_user.firstname }} {{ current_user.lastname }}
|
||||||
<small>{{ current_user.role.name }}</small>
|
<small>{{ current_user.role.name }}</small>
|
||||||
@ -100,7 +91,7 @@
|
|||||||
{% if current_user.id is defined %}
|
{% if current_user.id is defined %}
|
||||||
<div class="user-panel">
|
<div class="user-panel">
|
||||||
<div class="pull-left image">
|
<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>
|
||||||
<div class="pull-left info">
|
<div class="pull-left info">
|
||||||
<p>{{ current_user.firstname }} {{ current_user.lastname }}</p>
|
<p>{{ current_user.firstname }} {{ current_user.lastname }}</p>
|
||||||
@ -194,7 +185,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- /.content-wrapper -->
|
<!-- /.content-wrapper -->
|
||||||
<footer class="main-footer">
|
<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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<!-- ./wrapper -->
|
<!-- ./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>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- endassets %}
|
{%- endassets %}
|
||||||
|
@ -114,20 +114,20 @@
|
|||||||
<td>{{ history.history.msg }}</td>
|
<td>{{ history.history.msg }}</td>
|
||||||
<td>{{ history.history.created_on }}</td>
|
<td>{{ history.history.created_on }}</td>
|
||||||
<td width="6%">
|
<td width="6%">
|
||||||
<button type="button" class="btn btn-flat btn-primary history-info-button"
|
<div id="history-info-div-{{ loop.index0 }}" style="display: none;">
|
||||||
{% if history.detailed_msg == "" and history.change_set == None %}
|
{{ history.detailed_msg | safe }}
|
||||||
style="visibility: hidden;"
|
{% if history.change_set %}
|
||||||
{% endif%}
|
|
||||||
value='{{ history.detailed_msg }}
|
|
||||||
{% if history.change_set != None %}
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div id="change_index_definition"></div>
|
<div id="change_index_definition"></div>
|
||||||
{% call applied_change_macro.applied_change_template(history.change_set) %}
|
{% call applied_change_macro.applied_change_template(history.change_set) %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
'>
|
</div>
|
||||||
Info <i class="fa fa-info"></i>
|
<button type="button" class="btn btn-flat btn-primary history-info-button"
|
||||||
|
{% 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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -243,7 +243,8 @@
|
|||||||
|
|
||||||
$(document.body).on('click', '.history-info-button', function () {
|
$(document.body).on('click', '.history-info-button', function () {
|
||||||
var modal = $("#modal_history_info");
|
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-info-content').html(info);
|
||||||
modal.modal('show');
|
modal.modal('show');
|
||||||
});
|
});
|
||||||
|
21
powerdnsadmin/templates/domain.html
Normal file → Executable file
@ -33,6 +33,11 @@
|
|||||||
Update from Master <i class="fa fa-download"></i>
|
Update from Master <i class="fa fa-download"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% 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') %}
|
{% 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 }}">
|
<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>
|
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') %}
|
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
|
||||||
<th >Changelog</th>
|
<th >Changelog</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<th>Invisible Sorting Column</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -99,7 +105,6 @@
|
|||||||
<button type="button" class="btn btn-flat btn-warning"> <i class="fa fa-exclamation-circle"></i> </button>
|
<button type="button" class="btn btn-flat btn-warning"> <i class="fa fa-exclamation-circle"></i> </button>
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
|
||||||
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
|
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
|
||||||
<td width="6%">
|
<td width="6%">
|
||||||
<button type="button" onclick="show_record_changelog('{{record.name}}','{{record.type}}',event)" class="btn btn-flat btn-primary">
|
<button type="button" onclick="show_record_changelog('{{record.name}}','{{record.type}}',event)" class="btn btn-flat btn-primary">
|
||||||
@ -156,6 +161,10 @@
|
|||||||
type: 'natural',
|
type: 'natural',
|
||||||
targets: [0, 4]
|
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
|
// hidden column so that we can add new records on top
|
||||||
// regardless of whatever sorting is done. See orderFixed
|
// regardless of whatever sorting is done. See orderFixed
|
||||||
@ -181,7 +190,7 @@
|
|||||||
|
|
||||||
function show_record_changelog(record_name, record_type, e) {
|
function show_record_changelog(record_name, record_type, e) {
|
||||||
e.stopPropagation();
|
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
|
// handle changelog button
|
||||||
$(document.body).on("click", ".button_changelog", function(e) {
|
$(document.body).on("click", ".button_changelog", function(e) {
|
||||||
@ -242,9 +251,7 @@
|
|||||||
// handle apply changes button
|
// handle apply changes button
|
||||||
$(document.body).on("click",".button_apply_changes", function() {
|
$(document.body).on("click",".button_apply_changes", function() {
|
||||||
if (nNew || nEditing) {
|
if (nNew || nEditing) {
|
||||||
var modal = $("#modal_error");
|
showErrorModal("Previous record not saved. Please save it before applying the changes.");
|
||||||
modal.find('.modal-body p').text("Previous record not saved. Please save it before applying the changes.");
|
|
||||||
modal.modal('show');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,9 +275,7 @@
|
|||||||
// handle add record button
|
// handle add record button
|
||||||
$(document.body).on("click", ".button_add_record", function (e) {
|
$(document.body).on("click", ".button_add_record", function (e) {
|
||||||
if (nNew || nEditing) {
|
if (nNew || nEditing) {
|
||||||
var modal = $("#modal_error");
|
showErrorModal("Previous record not saved. Please save it before adding more record.");
|
||||||
modal.find('.modal-body p').text("Previous record not saved. Please save it before adding more record.");
|
|
||||||
modal.modal('show');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// clear search first
|
// clear search first
|
||||||
|
@ -34,6 +34,13 @@
|
|||||||
<input type="text" class="form-control" name="domain_name" id="domain_name"
|
<input type="text" class="form-control" name="domain_name" id="domain_name"
|
||||||
placeholder="Enter a valid domain name (required)">
|
placeholder="Enter a valid domain name (required)">
|
||||||
</div>
|
</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;">
|
<select name="accountid" class="form-control" style="width:15em;">
|
||||||
<option value="0">- No Account -</option>
|
<option value="0">- No Account -</option>
|
||||||
{% for account in accounts %}
|
{% for account in accounts %}
|
||||||
@ -178,3 +185,37 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% 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) {
|
$(document.body).on("click", ".button_delete", function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if ( $("#domainid").val() == 0 ){
|
if ( $("#domainid").val() == 0 ){
|
||||||
var modal = $("#modal_error");
|
showErrorModal("Please select domain to remove.");
|
||||||
modal.find('.modal-body p').text("Please select domain to remove.");
|
|
||||||
modal.modal('show');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,7 +202,7 @@
|
|||||||
<td class="content-block powered-by"
|
<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;"
|
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">
|
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>.
|
style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">PowerDNS-Admin</a>.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -8,24 +8,25 @@
|
|||||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
|
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||||
<!-- Tell the browser to be responsive to screen width -->
|
<!-- 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 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" -%}
|
{% assets "css_login" -%}
|
||||||
<link rel="stylesheet" href="{{ ASSET_URL }}">
|
<link rel="stylesheet" href="{{ ASSET_URL }}">
|
||||||
{%- endassets %}
|
{%- endassets %}
|
||||||
{% if SETTING.get('custom_css') %}
|
{% if SETTING.get('custom_css') %}
|
||||||
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
|
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
|
||||||
{% endif %}
|
{% 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>
|
</head>
|
||||||
|
|
||||||
<body class="hold-transition login-page">
|
<body class="hold-transition login-page">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="login-logo">
|
<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>
|
</div>
|
||||||
<!-- /.login-logo -->
|
<!-- /.login-logo -->
|
||||||
<div class="login-box-body">
|
<div class="login-box-body">
|
||||||
@ -50,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if SETTING.get('otp_field_enabled') %}
|
{% if SETTING.get('otp_field_enabled') %}
|
||||||
<div class="form-group">
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
|
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
|
||||||
@ -138,7 +139,7 @@
|
|||||||
<!-- /.login-box-body -->
|
<!-- /.login-box-body -->
|
||||||
<div class="login-box-footer">
|
<div class="login-box-footer">
|
||||||
<center>
|
<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>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,13 +11,6 @@
|
|||||||
{% assets "css_login" -%}
|
{% assets "css_login" -%}
|
||||||
<link rel="stylesheet" href="{{ ASSET_URL }}">
|
<link rel="stylesheet" href="{{ ASSET_URL }}">
|
||||||
{%- endassets %}
|
{%- 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>
|
</head>
|
||||||
|
|
||||||
<body class="hold-transition register-page">
|
<body class="hold-transition register-page">
|
||||||
@ -85,7 +78,7 @@
|
|||||||
<!-- /.form-box -->
|
<!-- /.form-box -->
|
||||||
<div class="login-box-footer">
|
<div class="login-box-footer">
|
||||||
<center>
|
<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>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
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 -->
|
<!-- /.form-box -->
|
||||||
<div class="login-box-footer">
|
<div class="login-box-footer">
|
||||||
<center>
|
<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>
|
</center>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -192,9 +192,7 @@
|
|||||||
// handle apply changes button
|
// handle apply changes button
|
||||||
$(document.body).on("click",".button_apply_changes", function() {
|
$(document.body).on("click",".button_apply_changes", function() {
|
||||||
if (nNew || nEditing) {
|
if (nNew || nEditing) {
|
||||||
var modal = $("#modal_error");
|
showErrorModal("Previous record not saved. Please save it before applying the changes.");
|
||||||
modal.find('.modal-body p').text("Previous record not saved. Please save it before applying the changes.");
|
|
||||||
modal.modal('show');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,9 +215,7 @@
|
|||||||
// handle add record button
|
// handle add record button
|
||||||
$(document.body).on("click", ".button_add_record", function (e) {
|
$(document.body).on("click", ".button_add_record", function (e) {
|
||||||
if (nNew || nEditing) {
|
if (nNew || nEditing) {
|
||||||
var modal = $("#modal_error");
|
showErrorModal("Previous record not saved. Please save it before adding more record.");
|
||||||
modal.find('.modal-body p').text("Previous record not saved. Please save it before adding more record.");
|
|
||||||
modal.modal('show');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// clear search first
|
// clear search first
|
||||||
|
@ -93,6 +93,14 @@
|
|||||||
{% if current_user.otp_secret %}
|
{% if current_user.otp_secret %}
|
||||||
<div id="token_information">
|
<div id="token_information">
|
||||||
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
|
<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"
|
You can use Google Authenticator (<a target="_blank"
|
||||||
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
|
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
|
||||||
- <a target="_blank"
|
- <a target="_blank"
|
||||||
@ -103,8 +111,8 @@
|
|||||||
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
|
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
|
||||||
on your smartphone to scan the QR code.
|
on your smartphone to scan the QR code.
|
||||||
<br />
|
<br />
|
||||||
<font color="red"><strong><i>Make sure only you can see this QR Code and
|
<font color="red"><strong><i>Make sure only you can see this QR Code and secret key and
|
||||||
nobody can capture it.</i></strong></font>
|
nobody can capture them.</i></strong></font>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|