Compare commits
467 commits
Author | SHA1 | Date | |
---|---|---|---|
cd94b5c0ac | |||
0b2ad520b7 | |||
302e793665 | |||
328780e2d4 | |||
ca4c145a18 | |||
7808febad8 | |||
9ef0f2b8d6 | |||
94a923a965 | |||
0da9b2185e | |||
07f0d215a7 | |||
fc8367535b | |||
d2f35a4059 | |||
737e1fb93b | |||
f0008ce401 | |||
6f12b783a8 | |||
51a7f636b0 | |||
9f46188c7e | |||
caa48b7fe5 | |||
591055d4aa | |||
940551e99e | |||
f45ff2ce03 | |||
6c1dfd2408 | |||
701a442d12 | |||
a3b70a8f47 | |||
1332c8d29d | |||
b3f9b4a2b0 | |||
bfaf5655ae | |||
dd04a837bb | |||
5bb1a7ee29 | |||
c85a5dac24 | |||
3081036c2c | |||
c7b4aa3434 | |||
e7d5a3aba0 | |||
20b866a784 | |||
1662a812ba | |||
c49df09ac8 | |||
924537b468 | |||
4f8a547d47 | |||
ee9f568a8d | |||
d7ae34ed53 | |||
1c9ca60508 | |||
0e655c1357 | |||
ba2423d6f5 | |||
46e51f16cb | |||
b8ee91ab9a | |||
c246775ffe | |||
f96103db79 | |||
bf83662108 | |||
1f34dbf810 | |||
b7197948c1 | |||
ddf2d4788b | |||
1ec6b76f89 | |||
4ce1b71c57 | |||
79457bdc85 | |||
10dc2b0273 | |||
993e02b635 | |||
07c71fb0bf | |||
c4a9498898 | |||
6e04d0419b | |||
d6e64dce8e | |||
b069cea8d1 | |||
fd933f8dbc | |||
0505b934a1 | |||
083a023e57 | |||
054e0e6eba | |||
c13dd2d835 | |||
567f66fbde | |||
ff5270fbad | |||
92bad7b11c | |||
43a6e46e66 | |||
ee72fdf9c2 | |||
8f73512d2e | |||
700fa0d9ce | |||
00dc23f21b | |||
36fdb3733f | |||
ce60ca0b9d | |||
b197491a86 | |||
d23a57da50 | |||
4180882fb7 | |||
bbbcf271fe | |||
32983635c6 | |||
f3a98eb692 | |||
39cddd3b34 | |||
b66b37ecfd | |||
5f10f739ea | |||
98db953820 | |||
44c4531f02 | |||
86700f8fd7 | |||
46993e08c0 | |||
4c19f95928 | |||
3a4efebf95 | |||
7f86730909 | |||
8f6a800836 | |||
3cd98251b3 | |||
54b257768f | |||
718b41e3d1 | |||
dd0a5f6326 | |||
c3d438842f | |||
33e7ffb747 | |||
2c18e5c88f | |||
2917c47fd1 | |||
c6e0293177 | |||
942482b706 | |||
4d1db72699 | |||
680e4cf431 | |||
1604494f1d | |||
710cb75bfe | |||
70b1accaa0 | |||
7254a94497 | |||
3034630bc0 | |||
d72709e0f4 | |||
a1c1b35696 | |||
94eeae0cad | |||
75a30f14fb | |||
76562f8a46 | |||
6455189c32 | |||
7e6d5d2e4a | |||
372fdd7bd0 | |||
0dfced4968 | |||
33282ae4af | |||
078b0b2f4d | |||
55ad73d92e | |||
a679073928 | |||
b5fc9045f2 | |||
f3bcf1b834 | |||
b8ffb1dae9 | |||
b10a706e15 | |||
b12377796b | |||
58f3c241b4 | |||
9228128907 | |||
3167e50f65 | |||
810f773a5b | |||
cf62658e19 | |||
352e7d388a | |||
8735f1e273 | |||
74b89b1b7e | |||
70c2744f29 | |||
3c59ba6f84 | |||
b4d7f66e29 | |||
9632898b40 | |||
f9f966df75 | |||
27f5c89f70 | |||
7ef6f5db4e | |||
ab6480a4b4 | |||
0ef57b2f9f | |||
8377f08d3b | |||
22eabef06a | |||
e993422106 | |||
25db119d02 | |||
9946f72a85 | |||
5125c9764c | |||
0f9a5f8652 | |||
f3f91d56e2 | |||
1b4fe8935d | |||
4e63f8380b | |||
1f4580a27a | |||
5123d542e4 | |||
94da9198c0 | |||
a3fd856dd8 | |||
84ae753db2 | |||
5eb2edee2c | |||
4e39d5a461 | |||
cfc8567180 | |||
39db31b5ae | |||
eb730be8f9 | |||
831fbf3cb3 | |||
125883330e | |||
73c267848c | |||
5ac126f349 | |||
52298f8289 | |||
a598c52729 | |||
3476c8a9ec | |||
99f12df748 | |||
0ef132a7be | |||
fd0485d897 | |||
9f4b6ffcdb | |||
ab7e1eb71b | |||
d43c9a581f | |||
edb2a354d1 | |||
78245d339f | |||
f442fef3d6 | |||
9f562714f2 | |||
2044ce4737 | |||
33eff6313f | |||
978c0b6c62 | |||
5bd8990a54 | |||
483c767d26 | |||
b03cbdea65 | |||
b8442c4c5c | |||
bd1eb3cce9 | |||
27d1de3a34 | |||
9e8401ae46 | |||
02692c7759 | |||
de6d8c3dce | |||
bbd00865dc | |||
ccb27e21b3 | |||
06266846ec | |||
0e093d4eec | |||
452c4a02d8 | |||
def06bee41 | |||
b2d72d6603 | |||
403f063977 | |||
3ae8fce3b1 | |||
c8490146ed | |||
ee8ab848e3 | |||
cc26174a48 | |||
7e97bec07f | |||
a581aa3cf2 | |||
e550b0a109 | |||
4b4509d839 | |||
058d475f05 | |||
795896d968 | |||
6381d87ec3 | |||
bc2e7c9786 | |||
417338d826 | |||
f2704649f1 | |||
026dcead7f | |||
7024404f73 | |||
65a59d2e59 | |||
b421b9b38a | |||
7ac6529e0a | |||
2d55832729 | |||
a2d98d38e2 | |||
52eb62a911 | |||
d497fdee8e | |||
13aab7f6ee | |||
81bfde5a79 | |||
eabbcedf08 | |||
598397bec1 | |||
e2207fd8d7 | |||
4cfb6ef81f | |||
31d19b19ab | |||
faeb8d1f94 | |||
a069fdd8cb | |||
dbb181d575 | |||
23c73f6c52 | |||
2de8bf55f8 | |||
bb3829c8a1 | |||
4d391ccb34 | |||
0f6b7bdcf6 | |||
ccaf74646d | |||
68843d9664 | |||
737531d23f | |||
acef820c54 | |||
0ea188f8d6 | |||
66ff3426e0 | |||
b4b5673cf1 | |||
1cd423041c | |||
b1ba339a9c | |||
e1c0b4a1b4 | |||
cff4d0af53 | |||
765eab999a | |||
7f8ae003fc | |||
3bf6e6e9f1 | |||
462190a913 | |||
9533d8a5aa | |||
d09a645300 | |||
4bdd433079 | |||
7739bf7cfc | |||
49908b9039 | |||
451626f4af | |||
1ea460fc71 | |||
8723b7d56b | |||
3688cec91a | |||
5567886aa3 | |||
93cd64ac75 | |||
59110432a0 | |||
88991cdde9 | |||
567430790c | |||
33c1f64272 | |||
7205b4a01b | |||
9a1b92fbc2 | |||
ea8531dd3c | |||
103bf176ed | |||
4a3f121b02 | |||
691d3045ad | |||
3e961d7b21 | |||
f5b88ebc73 | |||
3196297f57 | |||
a891ed38e2 | |||
6880657367 | |||
452fde17bd | |||
4263bccbba | |||
de581e9e1d | |||
32236faae8 | |||
37f24f9fde | |||
894756ad96 | |||
cd3535dcd2 | |||
5ac58d5503 | |||
95fe2a8a85 | |||
14658d797e | |||
ad6b04bd78 | |||
c0594b2c0b | |||
d90a20f8da | |||
65d4acc6c5 | |||
81869f4cd8 | |||
242e31a490 | |||
1d973d1614 | |||
798b7abb5b | |||
c62f9f6936 | |||
2cc73abbe5 | |||
a65b940103 | |||
de69eaf825 | |||
67e6df6880 | |||
9904505867 | |||
7e3a722215 | |||
bca3c45e37 | |||
c1fae6f3dd | |||
354383f0ec | |||
19401699ca | |||
bbede0f7bd | |||
6af94df00a | |||
53a7545ccc | |||
f8aaf88fc0 | |||
80b6ca19ac | |||
0d2eeecce6 | |||
0234f21e1d | |||
8de6df4d3b | |||
aef9842d30 | |||
10ff312d95 | |||
1662944867 | |||
840e2a4750 | |||
8ea00b9484 | |||
0b2eb0fbf8 | |||
d31f95ca21 | |||
ad86f8ea2a | |||
89ac98acfb | |||
7af76297de | |||
16669e6bbf | |||
0318000429 | |||
a21d4d9cbd | |||
dfce7eb537 | |||
26db6b8c4b | |||
6e51ad866a | |||
798362c3c5 | |||
71a87dc38f | |||
7878ecda2a | |||
d8bb62900d | |||
8c85119f5c | |||
8e11686b7d | |||
5d23c71bcb | |||
ed77afcdd6 | |||
b77be8d158 | |||
e6e512efaf | |||
9c11510914 | |||
67972123b6 | |||
8f5a359101 | |||
8309bb053e | |||
477165c196 | |||
e5b324d74b | |||
53147188ef | |||
f6317eee33 | |||
5d8cc844aa | |||
4d1953bf07 | |||
0522c20ce1 | |||
1acd246110 | |||
b715e7738e | |||
6a498382eb | |||
0db3c625e8 | |||
ee73bb181f | |||
f95adf3ea3 | |||
00d92839fb | |||
ae7b41ece2 | |||
8698009c30 | |||
15aeaef2ea | |||
0ad5d46a4c | |||
9f29a8e154 | |||
6a606dbe2c | |||
ee2ed65ff9 | |||
334878d75d | |||
08de197cff | |||
292aaddaee | |||
697aba0990 | |||
0333f450f1 | |||
ff3b484d84 | |||
c4ab5893b4 | |||
1feb77e2f3 | |||
343190b684 | |||
3b168047c4 | |||
122b9e4dfe | |||
1f1f928a58 | |||
9bd5225774 | |||
b48809dbdb | |||
7ba9ad68f8 | |||
63b872f627 | |||
98f1e96d1a | |||
5b88ec58ec | |||
0c290073ae | |||
bc2b57e475 | |||
5ea38a9b6d | |||
287d171d83 | |||
829d556462 | |||
2b17469324 | |||
cdb35d27f9 | |||
ce1d511fb2 | |||
458826bc77 | |||
7da6bd5f99 | |||
1ca311e120 | |||
082969de72 | |||
c1f5d76c6c | |||
b56d1154cc | |||
1e6b668189 | |||
9e14c3eb39 | |||
017e0c7344 | |||
976742c54e | |||
29b110dd15 | |||
d16abb9f81 | |||
004b211fdc | |||
32c442e083 | |||
6837b1cead | |||
c456aa2e7a | |||
0b9c58971c | |||
94becb35c6 | |||
9e6822bcdb | |||
2a0be5b90d | |||
2f39512b65 | |||
6151225b07 | |||
10d915ef92 | |||
5f049debe5 | |||
cb726ae4f9 | |||
a1245dcded | |||
c7d43ce5cc | |||
e1e4771ddc | |||
59bdc62512 | |||
6395d81916 | |||
be96921864 | |||
679ada7a89 | |||
ea53ae340f | |||
e4c8f5e100 | |||
f28b501c7c | |||
add5fd3e52 | |||
d3972b659a | |||
3fea57fca1 | |||
b32a77fce1 | |||
1ffaf904b2 | |||
e273921195 | |||
a39f5c622c | |||
4a0d580e32 | |||
95b4de65dd | |||
8a20d3f2d8 | |||
396ce14b9f | |||
de3d1b3665 | |||
2e5013ae73 | |||
4540d9a293 | |||
4bcc0c3ac4 | |||
5158cf93db | |||
3fec02b335 | |||
e5c434ac33 | |||
50e219039b | |||
e81a44fbc1 | |||
3c7da371d2 | |||
aced23db97 | |||
c646185e1f | |||
83923ebf98 | |||
573291b4f5 | |||
2efb674230 | |||
d9d3add093 | |||
74d49add6d | |||
7875be3b78 | |||
5909de99e9 | |||
dabc324c52 | |||
3f2d14327c | |||
51043837f0 | |||
4cd422ee54 | |||
bee6d1560f | |||
9a4eebfd42 | |||
65b0c6e9b9 |
112
.dockerignore
Normal file
112
.dockerignore
Normal file
|
@ -0,0 +1,112 @@
|
|||
### OSX ###
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Flask stuff:
|
||||
flask/
|
||||
instance/settings.py
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule.*
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitattributes
|
||||
.gitignore
|
||||
|
||||
# Vscode
|
||||
.vscode
|
||||
*.code-workspace
|
||||
|
||||
# Others
|
||||
.lgtm.yml
|
||||
.travis.yml
|
15
.env
15
.env
|
@ -1,15 +0,0 @@
|
|||
ENVIRONMENT=development
|
||||
|
||||
PDA_DB_HOST=powerdns-admin-mysql
|
||||
PDA_DB_NAME=powerdns_admin
|
||||
PDA_DB_USER=powerdns_admin
|
||||
PDA_DB_PASSWORD=changeme
|
||||
|
||||
PDNS_DB_HOST=pdns-mysql
|
||||
PDNS_DB_NAME=pdns
|
||||
PDNS_DB_USER=pdns
|
||||
PDNS_DB_PASSWORD=changeme
|
||||
|
||||
PDNS_HOST=pdns-server
|
||||
PDNS_API_KEY=changeme
|
||||
PDNS_WEBSERVER_ALLOW_FROM=0.0.0.0
|
19
.github/stale.yml
vendored
Normal file
19
.github/stale.yml
vendored
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- enhancement
|
||||
- feature request
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: true
|
56
.github/workflows/build-and-publish.yml
vendored
Normal file
56
.github/workflows/build-and-publish.yml
vendored
Normal file
|
@ -0,0 +1,56 @@
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build-and-push-docker-image:
|
||||
name: Build Docker image and push to repositories
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: |
|
||||
ngoduykhanh/powerdns-admin
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build latest image
|
||||
uses: docker/build-push-action@v2
|
||||
if: github.ref == 'refs/heads/master'
|
||||
with:
|
||||
context: ./
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ngoduykhanh/powerdns-admin:latest
|
||||
|
||||
- name: Build release image
|
||||
uses: docker/build-push-action@v2
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
with:
|
||||
context: ./
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
20
.gitignore
vendored
20
.gitignore
vendored
|
@ -25,20 +25,18 @@ nosetests.xml
|
|||
|
||||
flask
|
||||
config.py
|
||||
configs/production.py
|
||||
logfile.log
|
||||
settings.json
|
||||
advanced_settings.json
|
||||
idp.crt
|
||||
log.txt
|
||||
|
||||
db_repository/*
|
||||
upload/avatar/*
|
||||
tmp/*
|
||||
.ropeproject
|
||||
.sonarlint/*
|
||||
pdns.db
|
||||
idp.crt
|
||||
*.bak
|
||||
db_repository/*
|
||||
tmp/*
|
||||
|
||||
node_modules
|
||||
|
||||
powerdnsadmin/static/generated
|
||||
.webassets-cache
|
||||
app/static/generated
|
||||
.venv*
|
||||
.pytest_cache
|
||||
.DS_Store
|
||||
|
|
24
.travis.yml
24
.travis.yml
|
@ -1,24 +0,0 @@
|
|||
language: python
|
||||
python:
|
||||
- "3.5.2"
|
||||
before_install:
|
||||
- sudo apt-key adv --fetch-keys http://dl.yarnpkg.com/debian/pubkey.gpg
|
||||
- echo "deb http://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||
- travis_retry sudo apt-get update
|
||||
- travis_retry sudo apt-get install python3-dev libxml2-dev libxmlsec1-dev yarn
|
||||
- mysql -e 'CREATE DATABASE pda';
|
||||
- mysql -e "GRANT ALL PRIVILEGES ON pda.* to pda@'%' IDENTIFIED BY 'changeme'";
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
before_script:
|
||||
- mv config_template.py config.py
|
||||
- export FLASK_APP=app/__init__.py
|
||||
- flask db upgrade
|
||||
- yarn install --pure-lockfile
|
||||
- flask assets build
|
||||
script:
|
||||
- sh run_travis.sh
|
||||
cache:
|
||||
yarn: true
|
||||
services:
|
||||
- mysql
|
12
.whitesource
Normal file
12
.whitesource
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"scanSettings": {
|
||||
"baseBranches": []
|
||||
},
|
||||
"checkRunSettings": {
|
||||
"vulnerableCheckRunConclusionLevel": "failure",
|
||||
"displayMode": "diff"
|
||||
},
|
||||
"issueSettings": {
|
||||
"minSeverityLevel": "LOW"
|
||||
}
|
||||
}
|
2
.yarnrc
2
.yarnrc
|
@ -1 +1 @@
|
|||
--*.modules-folder "./app/static/node_modules"
|
||||
--*.modules-folder "./powerdnsadmin/static/node_modules"
|
||||
|
|
53
README.md
53
README.md
|
@ -1,7 +1,6 @@
|
|||
# PowerDNS-Admin
|
||||
A PowerDNS web interface with advanced features.
|
||||
|
||||
[![Build Status](https://travis-ci.org/ngoduykhanh/PowerDNS-Admin.svg?branch=master)](https://travis-ci.org/ngoduykhanh/PowerDNS-Admin)
|
||||
[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/ngoduykhanh/PowerDNS-Admin.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/ngoduykhanh/PowerDNS-Admin/context:python)
|
||||
[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/ngoduykhanh/PowerDNS-Admin.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/ngoduykhanh/PowerDNS-Admin/context:javascript)
|
||||
|
||||
|
@ -11,33 +10,51 @@ A PowerDNS web interface with advanced features.
|
|||
- User management
|
||||
- User access management based on domain
|
||||
- User activity logging
|
||||
- Local DB / SAML / LDAP / Active Directory user authentication
|
||||
- Google oauth authentication
|
||||
- Github oauth authentication
|
||||
- Support Local DB / SAML / LDAP / Active Directory user authentication
|
||||
- Support Google / Github / Azure / OpenID OAuth
|
||||
- Support Two-factor authentication (TOTP)
|
||||
- Dashboard and pdns service statistics
|
||||
- DynDNS 2 protocol support
|
||||
- Edit IPv6 PTRs using IPv6 addresses directly (no more editing of literal addresses!)
|
||||
- Limited API for manipulating zones and records
|
||||
- Full IDN/Punycode support
|
||||
|
||||
### Running PowerDNS-Admin
|
||||
There are several ways to run PowerDNS-Admin. Following is a simple way to start PowerDNS-Admin with docker in development environment which has PowerDNS-Admin, PowerDNS server and MySQL Back-End Database.
|
||||
## Running PowerDNS-Admin
|
||||
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.
|
||||
|
||||
Step 1: Changing configuration
|
||||
### Docker
|
||||
This 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.
|
||||
|
||||
The configuration file for developement environment is located at `configs/development.py`, you can override some configs by editing `.env` file.
|
||||
#### Option 1: From Docker Hub
|
||||
The easiest is to just run the latest Docker image from Docker Hub:
|
||||
```
|
||||
$ docker run -d \
|
||||
-e SECRET_KEY='a-very-secret-key' \
|
||||
-v pda-data:/data \
|
||||
-p 9191:80 \
|
||||
ngoduykhanh/powerdns-admin:latest
|
||||
```
|
||||
This creates a volume called `pda-data` to persist the SQLite database with the configuration.
|
||||
|
||||
Step 2: Build docker images
|
||||
#### Option 2: Using docker-compose
|
||||
1. Update the configuration
|
||||
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).
|
||||
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)
|
||||
|
||||
```$ docker-compose build```
|
||||
2. Start docker container
|
||||
```
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
Step 3: Start docker containers
|
||||
You can then access PowerDNS-Admin by pointing your browser to http://localhost:9191.
|
||||
|
||||
```$ docker-compose up```
|
||||
|
||||
You can now access PowerDNS-Admin at url http://localhost:9191
|
||||
|
||||
**NOTE:** For other methods to run PowerDNS-Admin, please take look at WIKI pages.
|
||||
|
||||
### Screenshots
|
||||
## Screenshots
|
||||
![dashboard](https://user-images.githubusercontent.com/6447444/44068603-0d2d81f6-9fa5-11e8-83af-14e2ad79e370.png)
|
||||
|
||||
## LICENSE
|
||||
MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE)
|
||||
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
from werkzeug.contrib.fixers import ProxyFix
|
||||
from flask import Flask, request, session, redirect, url_for
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy as SA
|
||||
from flask_migrate import Migrate
|
||||
from flask_oauthlib.client import OAuth
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
# subclass SQLAlchemy to enable pool_pre_ping
|
||||
class SQLAlchemy(SA):
|
||||
def apply_pool_defaults(self, app, options):
|
||||
SA.apply_pool_defaults(self, app, options)
|
||||
options["pool_pre_ping"] = True
|
||||
|
||||
|
||||
from app.assets import assets
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('config')
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||
|
||||
assets.init_app(app)
|
||||
|
||||
#### CONFIGURE LOGGER ####
|
||||
from app.lib.log import logger
|
||||
logging = logger('powerdns-admin', app.config['LOG_LEVEL'], app.config['LOG_FILE']).config()
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
db = SQLAlchemy(app) # database
|
||||
migrate = Migrate(app, db) # flask-migrate
|
||||
oauth_client = OAuth(app) # oauth
|
||||
|
||||
if app.config.get('SAML_ENABLED') and app.config.get('SAML_ENCRYPT'):
|
||||
from app.lib import certutil
|
||||
if not certutil.check_certificate():
|
||||
certutil.create_self_signed_cert()
|
||||
|
||||
from app import models
|
||||
from app import views
|
|
@ -1,73 +0,0 @@
|
|||
from flask_assets import Bundle, Environment, Filter
|
||||
|
||||
class ConcatFilter(Filter):
|
||||
"""
|
||||
Filter that merges files, placing a semicolon between them.
|
||||
|
||||
Fixes issues caused by missing semicolons at end of JS assets, for example
|
||||
with last statement of jquery.pjax.js.
|
||||
"""
|
||||
def concat(self, out, hunks, **kw):
|
||||
out.write(';'.join([h.data() for h, info in hunks]))
|
||||
|
||||
css_login = Bundle(
|
||||
'node_modules/bootstrap/dist/css/bootstrap.css',
|
||||
'node_modules/font-awesome/css/font-awesome.css',
|
||||
'node_modules/ionicons/dist/css/ionicons.css',
|
||||
'node_modules/icheck/skins/square/blue.css',
|
||||
'node_modules/admin-lte/dist/css/AdminLTE.css',
|
||||
filters=('cssmin','cssrewrite'),
|
||||
output='generated/login.css'
|
||||
)
|
||||
|
||||
js_login = Bundle(
|
||||
'node_modules/jquery/dist/jquery.js',
|
||||
'node_modules/bootstrap/dist/js/bootstrap.js',
|
||||
'node_modules/icheck/icheck.js',
|
||||
filters=(ConcatFilter, 'jsmin'),
|
||||
output='generated/login.js'
|
||||
)
|
||||
|
||||
js_validation = Bundle(
|
||||
'node_modules/bootstrap-validator/dist/validator.js',
|
||||
output='generated/validation.js'
|
||||
)
|
||||
|
||||
css_main = Bundle(
|
||||
'node_modules/bootstrap/dist/css/bootstrap.css',
|
||||
'node_modules/font-awesome/css/font-awesome.css',
|
||||
'node_modules/ionicons/dist/css/ionicons.css',
|
||||
'node_modules/datatables.net-bs/css/dataTables.bootstrap.css',
|
||||
'node_modules/icheck/skins/square/blue.css',
|
||||
'node_modules/multiselect/css/multi-select.css',
|
||||
'node_modules/admin-lte/dist/css/AdminLTE.css',
|
||||
'node_modules/admin-lte/dist/css/skins/_all-skins.css',
|
||||
'custom/css/custom.css',
|
||||
filters=('cssmin','cssrewrite'),
|
||||
output='generated/main.css'
|
||||
)
|
||||
|
||||
js_main = Bundle(
|
||||
'node_modules/jquery/dist/jquery.js',
|
||||
'node_modules/jquery-ui-dist/jquery-ui.js',
|
||||
'node_modules/bootstrap/dist/js/bootstrap.js',
|
||||
'node_modules/datatables.net/js/jquery.dataTables.js',
|
||||
'node_modules/datatables.net-bs/js/dataTables.bootstrap.js',
|
||||
'node_modules/jquery-sparkline/jquery.sparkline.js',
|
||||
'node_modules/jquery-slimscroll/jquery.slimscroll.js',
|
||||
'node_modules/icheck/icheck.js',
|
||||
'node_modules/fastclick/lib/fastclick.js',
|
||||
'node_modules/moment/moment.js',
|
||||
'node_modules/admin-lte/dist/js/adminlte.js',
|
||||
'node_modules/multiselect/js/jquery.multi-select.js',
|
||||
'custom/js/custom.js',
|
||||
filters=(ConcatFilter, 'jsmin'),
|
||||
output='generated/main.js'
|
||||
)
|
||||
|
||||
assets = Environment()
|
||||
assets.register('js_login', js_login)
|
||||
assets.register('js_validation', js_validation)
|
||||
assets.register('css_login', css_login)
|
||||
assets.register('js_main', js_main)
|
||||
assets.register('css_main', css_main)
|
|
@ -1,78 +0,0 @@
|
|||
from functools import wraps
|
||||
from flask import g, redirect, url_for
|
||||
|
||||
from app.models import Setting
|
||||
|
||||
|
||||
def admin_role_required(f):
|
||||
"""
|
||||
Grant access if user is in Administrator role
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.user.role.name != 'Administrator':
|
||||
return redirect(url_for('error', code=401))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def operator_role_required(f):
|
||||
"""
|
||||
Grant access if user is in Operator role or higher
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.user.role.name not in ['Administrator', 'Operator']:
|
||||
return redirect(url_for('error', code=401))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_access_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- user is in granted Account, or
|
||||
- user is in granted Domain
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.user.role.name not in ['Administrator', 'Operator']:
|
||||
domain_name = kwargs.get('domain_name')
|
||||
user_domain = [d.name for d in g.user.get_domain()]
|
||||
|
||||
if domain_name not in user_domain:
|
||||
return redirect(url_for('error', code=401))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_configure_dnssec(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- dnssec_admins_only is off
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.user.role.name not in ['Administrator', 'Operator'] and Setting().get('dnssec_admins_only'):
|
||||
return redirect(url_for('error', code=401))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_create_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_create_domain is on
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.user.role.name not in ['Administrator', 'Operator'] and not Setting().get('allow_user_create_domain'):
|
||||
return redirect(url_for('error', code=401))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
|
@ -1,46 +0,0 @@
|
|||
import logging
|
||||
|
||||
class logger(object):
|
||||
def __init__(self, name, level, logfile):
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.logfile = logfile
|
||||
|
||||
def config(self):
|
||||
# define logger and set logging level
|
||||
logger = logging.getLogger()
|
||||
|
||||
if self.level == 'CRITICAL':
|
||||
level = logging.CRITICAL
|
||||
elif self.level == 'ERROR':
|
||||
level = logging.ERROR
|
||||
elif self.level == 'WARNING':
|
||||
level = logging.WARNING
|
||||
elif self.level == 'DEBUG':
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
|
||||
logger.setLevel(level)
|
||||
|
||||
# set request requests module log level
|
||||
logging.getLogger("requests").setLevel(logging.CRITICAL)
|
||||
|
||||
if self.logfile:
|
||||
# define handler to log into file
|
||||
file_log_handler = logging.FileHandler(self.logfile)
|
||||
logger.addHandler(file_log_handler)
|
||||
|
||||
# define logging format for file
|
||||
file_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_log_handler.setFormatter(file_formatter)
|
||||
|
||||
# define handler to log into console
|
||||
stderr_log_handler = logging.StreamHandler()
|
||||
logger.addHandler(stderr_log_handler)
|
||||
|
||||
# define logging format for console
|
||||
console_formatter = logging.Formatter('[%(levelname)s] %(message)s')
|
||||
stderr_log_handler.setFormatter(console_formatter)
|
||||
|
||||
return logging.getLogger(self.name)
|
293
app/lib/utils.py
293
app/lib/utils.py
|
@ -1,293 +0,0 @@
|
|||
import re
|
||||
import json
|
||||
import requests
|
||||
import hashlib
|
||||
|
||||
from app import app
|
||||
from distutils.version import StrictVersion
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Thread
|
||||
|
||||
from .certutil import KEY_FILE, CERT_FILE
|
||||
|
||||
if app.config['SAML_ENABLED']:
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
|
||||
idp_timestamp = datetime(1970, 1, 1)
|
||||
idp_data = None
|
||||
if 'SAML_IDP_ENTITY_ID' in app.config:
|
||||
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'], entity_id=app.config.get('SAML_IDP_ENTITY_ID', None), required_sso_binding=app.config['SAML_IDP_SSO_BINDING'])
|
||||
else:
|
||||
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'], entity_id=app.config.get('SAML_IDP_ENTITY_ID', None))
|
||||
if idp_data is None:
|
||||
print('SAML: IDP Metadata initial load failed')
|
||||
exit(-1)
|
||||
idp_timestamp = datetime.now()
|
||||
|
||||
|
||||
def get_idp_data():
|
||||
global idp_data, idp_timestamp
|
||||
lifetime = timedelta(minutes=app.config['SAML_METADATA_CACHE_LIFETIME'])
|
||||
if idp_timestamp+lifetime < datetime.now():
|
||||
background_thread = Thread(target=retreive_idp_data)
|
||||
background_thread.start()
|
||||
return idp_data
|
||||
|
||||
|
||||
def retreive_idp_data():
|
||||
global idp_data, idp_timestamp
|
||||
if 'SAML_IDP_SSO_BINDING' in app.config:
|
||||
new_idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'], entity_id=app.config.get('SAML_IDP_ENTITY_ID', None), required_sso_binding=app.config['SAML_IDP_SSO_BINDING'])
|
||||
else:
|
||||
new_idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'], entity_id=app.config.get('SAML_IDP_ENTITY_ID', None))
|
||||
if new_idp_data is not None:
|
||||
idp_data = new_idp_data
|
||||
idp_timestamp = datetime.now()
|
||||
print("SAML: IDP Metadata successfully retreived from: " + app.config['SAML_METADATA_URL'])
|
||||
else:
|
||||
print("SAML: IDP Metadata could not be retreived")
|
||||
|
||||
|
||||
if 'TIMEOUT' in app.config.keys():
|
||||
TIMEOUT = app.config['TIMEOUT']
|
||||
else:
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
def auth_from_url(url):
|
||||
auth = None
|
||||
parsed_url = urlparse(url).netloc
|
||||
if '@' in parsed_url:
|
||||
auth = parsed_url.split('@')[0].split(':')
|
||||
auth = requests.auth.HTTPBasicAuth(auth[0], auth[1])
|
||||
return auth
|
||||
|
||||
|
||||
def fetch_remote(remote_url, method='GET', data=None, accept=None, params=None, timeout=None, headers=None):
|
||||
if data is not None and type(data) != str:
|
||||
data = json.dumps(data)
|
||||
|
||||
if timeout is None:
|
||||
timeout = TIMEOUT
|
||||
|
||||
verify = False
|
||||
|
||||
our_headers = {
|
||||
'user-agent': 'powerdnsadmin/0',
|
||||
'pragma': 'no-cache',
|
||||
'cache-control': 'no-cache'
|
||||
}
|
||||
if accept is not None:
|
||||
our_headers['accept'] = accept
|
||||
if headers is not None:
|
||||
our_headers.update(headers)
|
||||
|
||||
r = requests.request(
|
||||
method,
|
||||
remote_url,
|
||||
headers=headers,
|
||||
verify=verify,
|
||||
auth=auth_from_url(remote_url),
|
||||
timeout=timeout,
|
||||
data=data,
|
||||
params=params
|
||||
)
|
||||
try:
|
||||
if r.status_code not in (200, 400, 422):
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
raise RuntimeError('Error while fetching {0}'.format(remote_url)) from e
|
||||
|
||||
return r
|
||||
|
||||
|
||||
def fetch_json(remote_url, method='GET', data=None, params=None, headers=None):
|
||||
r = fetch_remote(remote_url, method=method, data=data, params=params, headers=headers,
|
||||
accept='application/json; q=1')
|
||||
|
||||
if method == "DELETE":
|
||||
return True
|
||||
|
||||
if r.status_code == 204:
|
||||
return {}
|
||||
|
||||
try:
|
||||
assert('json' in r.headers['content-type'])
|
||||
except Exception as e:
|
||||
raise RuntimeError('Error while fetching {0}'.format(remote_url)) from e
|
||||
|
||||
# don't use r.json here, as it will read from r.text, which will trigger
|
||||
# content encoding auto-detection in almost all cases, WHICH IS EXTREMELY
|
||||
# SLOOOOOOOOOOOOOOOOOOOOOOW. just don't.
|
||||
data = None
|
||||
try:
|
||||
data = json.loads(r.content.decode('utf-8'))
|
||||
except Exception as e:
|
||||
raise RuntimeError('Error while loading JSON data from {0}'.format(remote_url)) from e
|
||||
return data
|
||||
|
||||
|
||||
def display_record_name(data):
|
||||
record_name, domain_name = data
|
||||
if record_name == domain_name:
|
||||
return '@'
|
||||
else:
|
||||
return re.sub('\.{}$'.format(domain_name), '', record_name)
|
||||
|
||||
|
||||
def display_master_name(data):
|
||||
"""
|
||||
input data: "[u'127.0.0.1', u'8.8.8.8']"
|
||||
"""
|
||||
matches = re.findall(r'\'(.+?)\'', data)
|
||||
return ", ".join(matches)
|
||||
|
||||
|
||||
def display_time(amount, units='s', remove_seconds=True):
|
||||
"""
|
||||
Convert timestamp to normal time format
|
||||
"""
|
||||
amount = int(amount)
|
||||
INTERVALS = [(lambda mlsec:divmod(mlsec, 1000), 'ms'),
|
||||
(lambda seconds:divmod(seconds, 60), 's'),
|
||||
(lambda minutes:divmod(minutes, 60), 'm'),
|
||||
(lambda hours:divmod(hours, 24), 'h'),
|
||||
(lambda days:divmod(days, 7), 'D'),
|
||||
(lambda weeks:divmod(weeks, 4), 'W'),
|
||||
(lambda years:divmod(years, 12), 'M'),
|
||||
(lambda decades:divmod(decades, 10), 'Y')]
|
||||
|
||||
for index_start, (interval, unit) in enumerate(INTERVALS):
|
||||
if unit == units:
|
||||
break
|
||||
|
||||
amount_abrev = []
|
||||
last_index = 0
|
||||
amount_temp = amount
|
||||
for index, (formula, abrev) in enumerate(INTERVALS[index_start: len(INTERVALS)]):
|
||||
divmod_result = formula(amount_temp)
|
||||
amount_temp = divmod_result[0]
|
||||
amount_abrev.append((divmod_result[1], abrev))
|
||||
if divmod_result[1] > 0:
|
||||
last_index = index
|
||||
amount_abrev_partial = amount_abrev[0: last_index + 1]
|
||||
amount_abrev_partial.reverse()
|
||||
|
||||
final_string = ''
|
||||
for amount, abrev in amount_abrev_partial:
|
||||
final_string += str(amount) + abrev + ' '
|
||||
|
||||
if remove_seconds and 'm' in final_string:
|
||||
final_string = final_string[:final_string.rfind(' ')]
|
||||
return final_string[:final_string.rfind(' ')]
|
||||
|
||||
return final_string
|
||||
|
||||
|
||||
def pdns_api_extended_uri(version):
|
||||
"""
|
||||
Check the pdns version
|
||||
"""
|
||||
if StrictVersion(version) >= StrictVersion('4.0.0'):
|
||||
return "/api/v1"
|
||||
else:
|
||||
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 prepare_flask_request(request):
|
||||
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
|
||||
url_data = urlparse(request.url)
|
||||
return {
|
||||
'https': 'on' if request.scheme == 'https' else 'off',
|
||||
'http_host': request.host,
|
||||
'server_port': url_data.port,
|
||||
'script_name': request.path,
|
||||
'get_data': request.args.copy(),
|
||||
'post_data': request.form.copy(),
|
||||
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
|
||||
'lowercase_urlencoding': True,
|
||||
'query_string': request.query_string
|
||||
}
|
||||
|
||||
|
||||
def init_saml_auth(req):
|
||||
own_url = ''
|
||||
if req['https'] == 'on':
|
||||
own_url = 'https://'
|
||||
else:
|
||||
own_url = 'http://'
|
||||
own_url += req['http_host']
|
||||
metadata = get_idp_data()
|
||||
settings = {}
|
||||
settings['sp'] = {}
|
||||
if 'SAML_NAMEID_FORMAT' in app.config:
|
||||
settings['sp']['NameIDFormat'] = app.config['SAML_NAMEID_FORMAT']
|
||||
else:
|
||||
settings['sp']['NameIDFormat'] = idp_data.get('sp', {}).get('NameIDFormat', 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified')
|
||||
settings['sp']['entityId'] = app.config['SAML_SP_ENTITY_ID']
|
||||
cert = open(CERT_FILE, "r").readlines()
|
||||
key = open(KEY_FILE, "r").readlines()
|
||||
settings['sp']['privateKey'] = "".join(key)
|
||||
settings['sp']['x509cert'] = "".join(cert)
|
||||
settings['sp']['assertionConsumerService'] = {}
|
||||
settings['sp']['assertionConsumerService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
|
||||
settings['sp']['assertionConsumerService']['url'] = own_url+'/saml/authorized'
|
||||
settings['sp']['attributeConsumingService'] = {}
|
||||
settings['sp']['singleLogoutService'] = {}
|
||||
settings['sp']['singleLogoutService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
|
||||
settings['sp']['singleLogoutService']['url'] = own_url+'/saml/sls'
|
||||
settings['idp'] = metadata['idp']
|
||||
settings['strict'] = True
|
||||
settings['debug'] = app.config['SAML_DEBUG']
|
||||
settings['security'] = {}
|
||||
settings['security']['digestAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
|
||||
settings['security']['metadataCacheDuration'] = None
|
||||
settings['security']['metadataValidUntil'] = None
|
||||
settings['security']['requestedAuthnContext'] = True
|
||||
settings['security']['signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
|
||||
settings['security']['wantAssertionsEncrypted'] = False
|
||||
settings['security']['wantAttributeStatement'] = True
|
||||
settings['security']['wantNameId'] = True
|
||||
settings['security']['authnRequestsSigned'] = app.config['SAML_SIGN_REQUEST']
|
||||
settings['security']['logoutRequestSigned'] = app.config['SAML_SIGN_REQUEST']
|
||||
settings['security']['logoutResponseSigned'] = app.config['SAML_SIGN_REQUEST']
|
||||
settings['security']['nameIdEncrypted'] = False
|
||||
settings['security']['signMetadata'] = True
|
||||
settings['security']['wantAssertionsSigned'] = True
|
||||
settings['security']['wantMessagesSigned'] = True
|
||||
settings['security']['wantNameIdEncrypted'] = False
|
||||
settings['contactPerson'] = {}
|
||||
settings['contactPerson']['support'] = {}
|
||||
settings['contactPerson']['support']['emailAddress'] = app.config['SAML_SP_CONTACT_NAME']
|
||||
settings['contactPerson']['support']['givenName'] = app.config['SAML_SP_CONTACT_MAIL']
|
||||
settings['contactPerson']['technical'] = {}
|
||||
settings['contactPerson']['technical']['emailAddress'] = app.config['SAML_SP_CONTACT_NAME']
|
||||
settings['contactPerson']['technical']['givenName'] = app.config['SAML_SP_CONTACT_MAIL']
|
||||
settings['organization'] = {}
|
||||
settings['organization']['en-US'] = {}
|
||||
settings['organization']['en-US']['displayname'] = 'PowerDNS-Admin'
|
||||
settings['organization']['en-US']['name'] = 'PowerDNS-Admin'
|
||||
settings['organization']['en-US']['url'] = own_url
|
||||
auth = OneLogin_Saml2_Auth(req, settings)
|
||||
return auth
|
||||
|
||||
|
||||
def display_setting_state(value):
|
||||
if value == 1:
|
||||
return "ON"
|
||||
elif value == 0:
|
||||
return "OFF"
|
||||
else:
|
||||
return "UNKNOWN"
|
2027
app/models.py
2027
app/models.py
File diff suppressed because it is too large
Load diff
77
app/oauth.py
77
app/oauth.py
|
@ -1,77 +0,0 @@
|
|||
from ast import literal_eval
|
||||
from flask import request, session, redirect, url_for
|
||||
|
||||
from app import app, oauth_client
|
||||
from app.models import Setting
|
||||
|
||||
# TODO:
|
||||
# - Replace Flask-OAuthlib by authlib
|
||||
# - Fix github/google enabling (Currently need to reload the flask app)
|
||||
|
||||
def github_oauth():
|
||||
if not Setting().get('github_oauth_enabled'):
|
||||
return None
|
||||
|
||||
github = oauth_client.remote_app(
|
||||
'github',
|
||||
consumer_key = Setting().get('github_oauth_key'),
|
||||
consumer_secret = Setting().get('github_oauth_secret'),
|
||||
request_token_params = {'scope': Setting().get('github_oauth_scope')},
|
||||
base_url = Setting().get('github_oauth_api_url'),
|
||||
request_token_url = None,
|
||||
access_token_method = 'POST',
|
||||
access_token_url = Setting().get('github_oauth_token_url'),
|
||||
authorize_url = Setting().get('github_oauth_authorize_url')
|
||||
)
|
||||
|
||||
@app.route('/github/authorized')
|
||||
def github_authorized():
|
||||
session['github_oauthredir'] = url_for('.github_authorized', _external=True)
|
||||
resp = github.authorized_response()
|
||||
if resp is None:
|
||||
return 'Access denied: reason=%s error=%s' % (
|
||||
request.args['error'],
|
||||
request.args['error_description']
|
||||
)
|
||||
session['github_token'] = (resp['access_token'], '')
|
||||
return redirect(url_for('.login'))
|
||||
|
||||
@github.tokengetter
|
||||
def get_github_oauth_token():
|
||||
return session.get('github_token')
|
||||
|
||||
return github
|
||||
|
||||
|
||||
def google_oauth():
|
||||
if not Setting().get('google_oauth_enabled'):
|
||||
return None
|
||||
|
||||
google = oauth_client.remote_app(
|
||||
'google',
|
||||
consumer_key=Setting().get('google_oauth_client_id'),
|
||||
consumer_secret=Setting().get('google_oauth_client_secret'),
|
||||
request_token_params=literal_eval(Setting().get('google_token_params')),
|
||||
base_url=Setting().get('google_base_url'),
|
||||
request_token_url=None,
|
||||
access_token_method='POST',
|
||||
access_token_url=Setting().get('google_token_url'),
|
||||
authorize_url=Setting().get('google_authorize_url'),
|
||||
)
|
||||
|
||||
@app.route('/google/authorized')
|
||||
def google_authorized():
|
||||
resp = google.authorized_response()
|
||||
if resp is None:
|
||||
return 'Access denied: reason=%s error=%s' % (
|
||||
request.args['error_reason'],
|
||||
request.args['error_description']
|
||||
)
|
||||
session['google_token'] = (resp['access_token'], '')
|
||||
return redirect(url_for('.login'))
|
||||
|
||||
@google.tokengetter
|
||||
def get_google_oauth_token():
|
||||
return session.get('google_token')
|
||||
|
||||
return google
|
|
@ -1,119 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_accounts" %}
|
||||
{% block title %}<title>Edit Account - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
{% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Account
|
||||
<small>{% if create %}New account{% else %}{{ account.name }}{% endif %}</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i>Home</a></li>
|
||||
<li><a href="{{ url_for('admin_manageaccount') }}">Accounts</a></li>
|
||||
<li class="active">{% if create %}Add{% else %}Edit{% endif %} account</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">{% if create %}Add{% else %}Edit{% endif %} account</h3>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<!-- form start -->
|
||||
<form role="form" method="post" action="{% if create %}{{ url_for('admin_editaccount') }}{% else %}{{ url_for('admin_editaccount', account_name=account.name) }}{% endif %}">
|
||||
<input type="hidden" name="create" value="{{ create }}">
|
||||
<div class="box-body">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
<h4><i class="icon fa fa-ban"></i> Error!</h4>
|
||||
{{ error }}
|
||||
</div>
|
||||
<span class="help-block">{{ error }}</span>
|
||||
{% endif %}
|
||||
<div class="form-group has-feedback {% if invalid_accountname or duplicate_accountname %}has-error{% endif %}">
|
||||
<label class="control-label" for="accountname">Name</label>
|
||||
<input type="text" class="form-control" placeholder="Account Name (required)"
|
||||
name="accountname" {% if account %}value="{{ account.name }}"{% endif %} {% if not create %}disabled{% endif %}>
|
||||
<span class="fa fa-cog form-control-feedback"></span>
|
||||
{% if invalid_accountname %}
|
||||
<span class="help-block">Cannot be blank and must only contain alphanumeric characters.</span>
|
||||
{% elif duplicate_accountname %}
|
||||
<span class="help-block">Account name already in use.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="accountdescription">Description</label>
|
||||
<input type="text" class="form-control" placeholder="Account Description (optional)"
|
||||
name="accountdescription" {% if account %}value="{{ account.description }}"{% endif %}>
|
||||
<span class="fa fa-industry form-control-feedback"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="accountcontact">Contact Person</label>
|
||||
<input type="text" class="form-control" placeholder="Contact Person (optional)"
|
||||
name="accountcontact" {% if account %}value="{{ account.contact }}"{% endif %}>
|
||||
<span class="fa fa-user form-control-feedback"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="accountmail">Mail Address</label>
|
||||
<input type="email" class="form-control" placeholder="Mail Address (optional)"
|
||||
name="accountmail" {% if account %}value="{{ account.mail }}"{% endif %}>
|
||||
<span class="fa fa-envelope form-control-feedback"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Access Control</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>Users on the right have access to manage records in all domains
|
||||
associated with the account.</p>
|
||||
<p>Click on users to move between columns.</p>
|
||||
<div class="form-group col-xs-2">
|
||||
<select multiple="multiple" class="form-control" id="account_multi_user" name="account_multi_user">
|
||||
{% for user in users %}
|
||||
<option {% if user.id in account_user_ids %}selected{% endif %} value="{{ user.username }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-footer">
|
||||
<button type="submit" class="btn btn-flat btn-primary">{% if create %}Create{% else %}Update{% endif %} Account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Help with creating a new account</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>
|
||||
An account allows grouping of domains belonging to a particular entity, such as a customer or department.<br/>
|
||||
A domain can be assigned to an account upon domain creation or through the domain administration page.
|
||||
</p>
|
||||
<p>Fill in all the fields to the in the form to the left.</p>
|
||||
<p>
|
||||
<strong>Name</strong> is an account identifier. It will be stored as all lowercase letters (no spaces, special characters etc).<br/>
|
||||
<strong>Description</strong> is a user friendly name for this account.<br/>
|
||||
<strong>Contact person</strong> is the name of a contact person at the account.<br/>
|
||||
<strong>Mail Address</strong> is an e-mail address for the contact person.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
$("#account_multi_user").multiSelect();
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,154 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_users" %}
|
||||
{% block title %}<title>Edit Use - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
{% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
User
|
||||
<small>{% if create %}New user{% else %}{{ user.username }}{% endif %}</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i>Home</a></li>
|
||||
<li><a href="{{ url_for('dashboard') }}">Admin</a></li>
|
||||
<li class="active">{% if create %}Add{% else %}Edit{% endif %} user</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">{% if create %}Add{% else %}Edit{% endif %} user</h3>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<!-- form start -->
|
||||
<form role="form" method="post" action="{% if create %}{{ url_for('admin_edituser') }}{% else %}{{ url_for('admin_edituser', user_username=user.username) }}{% endif %}">
|
||||
<input type="hidden" name="create" value="{{ create }}">
|
||||
<div class="box-body">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
<h4><i class="icon fa fa-ban"></i> Error!</h4>
|
||||
{{ error }}
|
||||
</div>
|
||||
<span class="help-block">{{ error }}</span>
|
||||
{% endif %}
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="firstname">First Name</label>
|
||||
<input type="text" class="form-control" placeholder="First Name"
|
||||
name="firstname" {% if user %}value={{ user.firstname }}{% endif %}> <span
|
||||
class="glyphicon glyphicon-user form-control-feedback"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="lastname">Last Name</label>
|
||||
<input type="text" class="form-control" placeholder="Last name"
|
||||
name="lastname" {% if user %}value={{ user.lastname }}{% endif %}> <span
|
||||
class="glyphicon glyphicon-user form-control-feedback"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="email">E-mail address</label>
|
||||
<input type="email" class="form-control" placeholder="Email"
|
||||
name="email" id="email" {% if user %}value={{ user.email }}{% endif %}> <span
|
||||
class="glyphicon glyphicon-envelope form-control-feedback"></span>
|
||||
</div>
|
||||
<p class="login-box-msg">Enter the account details below</p>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="username">Username</label>
|
||||
<input type="text" class="form-control" placeholder="Username"
|
||||
name="username" {% if user %}value={{ user.username }}{% endif %} {% if not create %}disabled{% endif %}> <span
|
||||
class="glyphicon glyphicon-user form-control-feedback"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback {% if blank_password %}has-error{% endif %}">
|
||||
<label class="control-label" for="username">Password</label>
|
||||
<input type="password" class="form-control" placeholder="Password {% if create %}(Required){% else %}(Leave blank to keep unchanged){% endif %}"
|
||||
name="password"> <span
|
||||
class="glyphicon glyphicon-lock form-control-feedback"></span>
|
||||
{% if blank_password %}
|
||||
<span class="help-block">The password cannot be blank.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-footer">
|
||||
<button type="submit" class="btn btn-flat btn-primary">{% if create %}Create{% else %}Update{% endif %} User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if not create %}
|
||||
<div class="box box-secondary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Two Factor Authentication</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>If two factor authentication was configured and is causing problems due to a lost device or technical issue, it can be disabled here.</p>
|
||||
<p>The user will need to reconfigure two factor authentication, to re-enable it.</p>
|
||||
<p><strong>Beware: This could compromise security!</strong></p>
|
||||
</div>
|
||||
<div class="box-footer">
|
||||
<button type="button" class="btn btn-flat btn-warning button_otp_disable" id="{{ user.username }}" {% if not user.otp_secret %}disabled{% endif %}>Disable Two Factor Authentication</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Help with {% if create %}creating a new{% else%}updating a{% endif %} user</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>Fill in all the fields to the in the form to the left.</p>
|
||||
{% if create %}
|
||||
<p><strong>Newly created users do not have access to any domains.</strong> You will need to grant access to the user once it is created via the domain management buttons on the dashboard.</p>
|
||||
{% else %}
|
||||
<p><strong>Password</strong> can be left empty to keep the current password.</p>
|
||||
<p><strong>Username</strong> cannot be changed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
// handle disabling two factor authentication
|
||||
$(document.body).on('click', '.button_otp_disable', function() {
|
||||
var modal = $("#modal_otp_disable");
|
||||
var username = $(this).prop('id');
|
||||
var info = "Are you sure you want to disable two factor authentication for user " + username + "?";
|
||||
modal.find('.modal-body p').text(info);
|
||||
modal.find('#button_otp_disable_confirm').click(function() {
|
||||
var postdata = {'action': 'user_otp_disable', 'data': username}
|
||||
applyChanges(postdata, $SCRIPT_ROOT + '/admin/manageuser', false, true);
|
||||
})
|
||||
modal.modal('show');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block modals %}
|
||||
<div class="modal fade modal-warning" id="modal_otp_disable">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"
|
||||
aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">Confirmation</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-left"
|
||||
data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-flat btn-danger" id="button_otp_disable_confirm">Disable Two Factor Authentication</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,141 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_history" %}
|
||||
{% block title %}
|
||||
<title>History - {{ SITE_NAME }}</title>
|
||||
{% endblock %} {% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
History <small>Recent PowerDNS-Admin events</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i
|
||||
class="fa fa-dashboard"></i> Home</a></li>
|
||||
<li class="active">History</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %} {% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">History Management</h3>
|
||||
</div>
|
||||
<div class="box-body clearfix">
|
||||
<button type="button" class="btn btn-flat btn-danger pull-right" data-toggle="modal" data-target="#modal_clear_history" {% if current_user.role.name != 'Administrator' %}disabled{% endif %}>
|
||||
Clear History <i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<table id="tbl_history" class="table table-bordered table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Changed by</th>
|
||||
<th>Content</th>
|
||||
<th>Time</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for history in histories %}
|
||||
<tr class="odd gradeX">
|
||||
<td>{{ history.created_by }}</td>
|
||||
<td>{{ history.msg }}</td>
|
||||
<td>{{ history.created_on }}</td>
|
||||
<td width="6%">
|
||||
<button type="button" class="btn btn-flat btn-primary history-info-button" value='{{ history.detail }}'>Info <i class="fa fa-info"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
</div>
|
||||
<!-- /.col -->
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
// set up history data table
|
||||
$("#tbl_history").DataTable({
|
||||
"paging" : true,
|
||||
"lengthChange" : false,
|
||||
"searching" : true,
|
||||
"ordering" : true,
|
||||
"info" : true,
|
||||
"autoWidth" : false,
|
||||
"order": [[ 2, "desc" ]],
|
||||
"columnDefs": [
|
||||
{
|
||||
"type": "time",
|
||||
"render": function ( data, type, row ) {
|
||||
return moment.utc(data).local().format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
"targets": 2
|
||||
}
|
||||
]
|
||||
});
|
||||
$(document.body).on('click', '.history-info-button', function() {
|
||||
var modal = $("#modal_history_info");
|
||||
var info = $(this).val();
|
||||
$('#modal-code-content').html(json_library.prettyPrint(info));
|
||||
modal.modal('show');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block modals %}
|
||||
<!-- Clear History Confirmation Box -->
|
||||
<div class="modal fade modal-warning" id="modal_clear_history">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"
|
||||
aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">Confirmation</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to remove all history?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-left"
|
||||
data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-flat btn-danger" onclick="applyChanges('', $SCRIPT_ROOT + '/admin/history', false, true);">Clear History</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<div class="modal fade" id="modal_history_info">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"
|
||||
aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">History Details</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre><code id="modal-code-content"></code></pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-right"
|
||||
data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<!-- /.modal -->
|
||||
{% endblock %}
|
|
@ -1,503 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_settings" %}
|
||||
{% block title %}
|
||||
<title>Authentication Settings - {{ SITE_NAME }}</title>
|
||||
{% endblock %} {% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Settings <small>PowerDNS-Admin settings</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>
|
||||
<li><a href="#">Setting</a></li>
|
||||
<li class="active">Authentication</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Authentication Settings</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
{% if result %}
|
||||
<div class="alert {% if result['status'] %}alert-success{% else %}alert-danger{% endif%} alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
{{ result['msg'] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Custom Tabs -->
|
||||
<div class="nav-tabs-custom" id="tabs">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active"><a href="#tabs-general" data-toggle="tab">General</a></li>
|
||||
<li class="active"><a href="#tabs-ldap" data-toggle="tab">LDAP</a></li>
|
||||
<li><a href="#tabs-google" data-toggle="tab">Google OAuth</a></li>
|
||||
<li><a href="#tabs-github" data-toggle="tab">Github OAuth</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="tabs-general">
|
||||
<form role="form" method="post">
|
||||
<input type="hidden" value="general" name="config_tab" />
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="local_db_enabled" name="local_db_enabled" class="checkbox" {% if SETTING.get('local_db_enabled') %}checked{% endif %}>
|
||||
<label for="local_db_enabled">Local DB Authentication</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="signup_enabled" name="signup_enabled" class="checkbox" {% if SETTING.get('signup_enabled') %}checked{% endif %}>
|
||||
<label for="signup_enabled">Allow users to sign up</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane active" id="tabs-ldap">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<form role="form" method="post" data-toggle="validator">
|
||||
<input type="hidden" value="ldap" name="config_tab" />
|
||||
<fieldset>
|
||||
<legend>GENERAL</legend>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="ldap_enabled" name="ldap_enabled" class="checkbox" {% if SETTING.get('ldap_enabled') %}checked{% endif %}>
|
||||
<label for="ldap_enabled">Enable LDAP Authentication</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="ldap_type" id="ldap" value="ldap" {% if SETTING.get('ldap_type')=='ldap' %}checked{% endif %}> OpenLDAP
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="ldap_type" id="ad" value="ad" {% if SETTING.get('ldap_type')=='ad' %}checked{% endif %}> Active Directory
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>ADMINISTRATOR INFO</legend>
|
||||
<div class="form-group">
|
||||
<label for="ldap_uri">LDAP URI</label>
|
||||
<input type="text" class="form-control" name="ldap_uri" id="ldap_uri" placeholder="e.g. ldaps://your-ldap-server:636" data-error="Please input LDAP URI" value="{{ SETTING.get('ldap_uri') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_base_dn">LDAP Base DN</label>
|
||||
<input type="text" class="form-control" name="ldap_base_dn" id="ldap_base_dn" placeholder="e.g. dc=mydomain,dc=com" data-error="Please input LDAP Base DN" value="{{ SETTING.get('ldap_base_dn') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_username">LDAP admin username</label>
|
||||
<input type="text" class="form-control" name="ldap_admin_username" id="ldap_admin_username" placeholder="e.g. cn=admin,dc=mydomain,dc=com" data-error="Please input LDAP admin username" value="{{ SETTING.get('ldap_admin_username') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_password">LDAP admin password</label>
|
||||
<input type="password" class="form-control" name="ldap_admin_password" id="ldap_admin_password" placeholder="LDAP Admin password" data-error="Please input LDAP admin password" value="{{ SETTING.get('ldap_admin_password') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>FILTERS</legend>
|
||||
<div class="form-group">
|
||||
<label for="ldap_filter_basic">Basic filter</label>
|
||||
<input type="text" class="form-control" name="ldap_filter_basic" id="ldap_filter_basic" placeholder="e.g. (objectClass=inetorgperson)" data-error="Please input LDAP filter" value="{{ SETTING.get('ldap_filter_basic') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_filter_username">Username field</label>
|
||||
<input type="text" class="form-control" name="ldap_filter_username" id="ldap_filter_username" placeholder="e.g. uid" data-error="Please input field for username filtering" value="{{ SETTING.get('ldap_filter_username') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>GROUP SECURITY</legend>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="ldap_sg_enabled" id="ldap_sg_off" value="OFF" {% if not SETTING.get('ldap_sg_enabled') %}checked{% endif %}> OFF
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="ldap_sg_enabled" id="ldap_sg_on" value="ON" {% if SETTING.get('ldap_sg_enabled') %}checked{% endif %}> ON
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_group">Admin group</label>
|
||||
<input type="text" class="form-control" name="ldap_admin_group" id="ldap_admin_group" placeholder="e.g. cn=sysops,dc=mydomain,dc=com" data-error="Please input LDAP DN for Admin group" value="{{ SETTING.get('ldap_admin_group') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_operator_group">Operator group</label>
|
||||
<input type="text" class="form-control" name="ldap_operator_group" id="ldap_operator_group" placeholder="e.g. cn=operators,dc=mydomain,dc=com" data-error="Please input LDAP DN for Operator group" value="{{ SETTING.get('ldap_operator_group') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_user_group">User group</label>
|
||||
<input type="text" class="form-control" name="ldap_user_group" id="ldap_user_group" placeholder="e.g. cn=users,dc=mydomain,dc=com" data-error="Please input LDAP DN for User group" value="{{ SETTING.get('ldap_user_group') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<legend>Help</legend>
|
||||
<dl class="dl-horizontal">
|
||||
<dt>Enable LDAP Authentication</dt>
|
||||
<dd>Turn on / off the LDAP authentication.</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>Select your current directory service type.
|
||||
<ul>
|
||||
<li>
|
||||
OpenLDAP - Open source implementation of the Lightweight Directory Access Protocol.
|
||||
</li>
|
||||
<li>
|
||||
Active Directory - Active Directory is a directory service that Microsoft developed for the Windows domain networks.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt>ADMINISTRATOR INFO</dt>
|
||||
<dd>Your LDAP connection string and admin credential used by PDA to query user information.
|
||||
<ul>
|
||||
<li>
|
||||
LDAP URI - The fully qualified domain names of your directory servers. (e.g. ldap://127.0.0.1:389)
|
||||
</li>
|
||||
<li>
|
||||
LDAP Base DN - The point from where a PDA will search for users.
|
||||
</li>
|
||||
<li>
|
||||
LDAP admin username - Your LDAP administrator user which has permission to query information in the Base DN above.
|
||||
</li>
|
||||
<li>
|
||||
LDAP admin password - The password of LDAP administrator user.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt>FILTERS</dt>
|
||||
<dd>Define how you want to filter your user in LDAP query.
|
||||
<ul>
|
||||
<li>
|
||||
Basic filter - The filter that will be applied to all LDAP query by PDA. (e.g. <i>(objectClass=inetorgperson)</i> for OpenLDAP and <i>(objectClass=organizationalPerson)</i> for Active Directory)
|
||||
</li>
|
||||
<li>
|
||||
Username field - The field PDA will look for user's username. (e.g. <i>uid</i> for OpenLDAP and <i>sAMAccountName</i> or <i>userPrincipalName</i> for Active Directory)
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt>GROUP SECURITY</dt>
|
||||
<dd>User can be assigned to PDA's User or Admin group by matching following LDAP Group.
|
||||
<ul>
|
||||
<li>
|
||||
Status - Turn on / off group security feature.
|
||||
</li>
|
||||
<li>
|
||||
Admin group - Your LDAP admin group.
|
||||
</li>
|
||||
<li>
|
||||
Operator group - Your LDAP operator group.
|
||||
</li>
|
||||
<li>
|
||||
User group - Your LDAP user group.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane" id="tabs-google">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<form role="form" method="post" data-toggle="validator">
|
||||
<input type="hidden" value="google" name="config_tab" />
|
||||
<fieldset>
|
||||
<legend>GENERAL</legend>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="google_oauth_enabled" name="google_oauth_enabled" class="checkbox" {% if SETTING.get('google_oauth_enabled') %}checked{% endif %}>
|
||||
<label for="google_oauth_enabled">Enable Google OAuth</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="google_oauth_client_id">Client ID</label>
|
||||
<input type="text" class="form-control" name="google_oauth_client_id" id="google_oauth_client_id" placeholder="Google OAuth client ID" data-error="Please input Client ID" value="{{ SETTING.get('google_oauth_client_id') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="google_oauth_client_secret">Client secret</label>
|
||||
<input type="text" class="form-control" name="google_oauth_client_secret" id="google_oauth_client_secret" placeholder="Google OAuth client secret" data-error="Please input Client secret" value="{{ SETTING.get('google_oauth_client_secret') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>ADVANCE</legend>
|
||||
<div class="form-group">
|
||||
<label for="google_token_url">Token URL</label>
|
||||
<input type="text" class="form-control" name="google_token_url" id="google_token_url" placeholder="e.g. https://accounts.google.com/o/oauth2/token" data-error="Please input token URL" value="{{ SETTING.get('google_token_url') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="google_token_params">Token params</label>
|
||||
<input type="text" class="form-control" name="google_token_params" id="google_token_params" placeholder="e.g. {'scope': 'email profile'}" data-error="Please input token params" value="{{ SETTING.get('google_token_params') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="google_authorize_url">Authorize URL</label>
|
||||
<input type="text" class="form-control" name="google_authorize_url" id="google_authorize_url" placeholder="e.g. https://accounts.google.com/o/oauth2/auth" data-error="Please input Authorize URL" value="{{ SETTING.get('google_authorize_url') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="google_base_url">Base URL</label>
|
||||
<input type="text" class="form-control" name="google_base_url" id="google_base_url" placeholder="e.g. https://www.googleapis.com/oauth2/v1/" data-error="Please input base URL" value="{{ SETTING.get('google_base_url') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<legend>Help</legend>
|
||||
<p>Fill in all the fields in the left form.</p>
|
||||
<p>Make sure you add PDA redirection URI (e.g http://localhost:9191/google/authorized) to your Google App Credentials Restriction.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane" id="tabs-github">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<form role="form" method="post" data-toggle="validator">
|
||||
<input type="hidden" value="github" name="config_tab" />
|
||||
<fieldset>
|
||||
<legend>GENERAL</legend>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="github_oauth_enabled" name="github_oauth_enabled" class="checkbox" {% if SETTING.get('github_oauth_enabled') %}checked{% endif %}>
|
||||
<label for="github_oauth_enabled">Enable Github OAuth</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="github_oauth_key">Client key</label>
|
||||
<input type="text" class="form-control" name="github_oauth_key" id="github_oauth_key" placeholder="Google OAuth client ID" data-error="Please input Client key" value="{{ SETTING.get('github_oauth_key') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="github_oauth_secret">Client secret</label>
|
||||
<input type="text" class="form-control" name="github_oauth_secret" id="github_oauth_secret" placeholder="Google OAuth client secret" data-error="Please input Client secret" value="{{ SETTING.get('github_oauth_secret') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>ADVANCE</legend>
|
||||
<div class="form-group">
|
||||
<label for="github_oauth_scope">Scope</label>
|
||||
<input type="text" class="form-control" name="github_oauth_scope" id="github_oauth_scope" placeholder="e.g. email" data-error="Please input scope" value="{{ SETTING.get('github_oauth_scope') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="github_oauth_api_url">API URL</label>
|
||||
<input type="text" class="form-control" name="github_oauth_api_url" id="github_oauth_api_url" placeholder="e.g. https://api.github.com/user" data-error="Please input API URL" value="{{ SETTING.get('github_oauth_api_url') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="github_oauth_token_url">Token URL</label>
|
||||
<input type="text" class="form-control" name="github_oauth_token_url" id="github_oauth_token_url" placeholder="e.g. https://github.com/login/oauth/access_token" data-error="Please input Token URL" value="{{ SETTING.get('github_oauth_token_url') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="github_oauth_authorize_url">Authorize URL</label>
|
||||
<input type="text" class="form-control" name="github_oauth_authorize_url" id="github_oauth_authorize_url" placeholder="e.g. https://github.com/login/oauth/authorize" data-error="Plesae input Authorize URL" value="{{ SETTING.get('github_oauth_authorize_url') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<legend>Help</legend>
|
||||
<p>Fill in all the fields in the left form.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
|
||||
{% assets "js_validation" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
|
||||
<script>
|
||||
|
||||
$(function() {
|
||||
$('#tabs').tabs({
|
||||
// add url anchor tags
|
||||
activate: function(event, ui) {
|
||||
window.location.hash = ui.newPanel.attr('id');
|
||||
}
|
||||
});
|
||||
// re-set active tab (ui)
|
||||
var activeTabIdx = $('#tabs').tabs('option','active');
|
||||
$('#tabs li:eq('+activeTabIdx+')').tab('show')
|
||||
});
|
||||
|
||||
// START: General tab js
|
||||
$('#local_db_enabled').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
})
|
||||
|
||||
$('#signup_enabled').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
})
|
||||
// END: General tab js
|
||||
|
||||
// START: LDAP tab js
|
||||
// update validation requirement when checkbox is togged
|
||||
$('#ldap_enabled').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
}).on('ifChanged', function(e) {
|
||||
var is_enabled = e.currentTarget.checked;
|
||||
if (is_enabled){
|
||||
$('#ldap_uri').prop('required', true);
|
||||
$('#ldap_base_dn').prop('required', true);
|
||||
$('#ldap_admin_username').prop('required', true);
|
||||
$('#ldap_admin_password').prop('required', true);
|
||||
$('#ldap_filter_basic').prop('required', true);
|
||||
$('#ldap_filter_username').prop('required', true);
|
||||
|
||||
if ($('#ldap_sg_on').is(":checked")) {
|
||||
$('#ldap_admin_group').prop('required', true);
|
||||
$('#ldap_user_group').prop('required', true);
|
||||
}
|
||||
|
||||
} else {
|
||||
$('#ldap_uri').prop('required', false);
|
||||
$('#ldap_base_dn').prop('required', false);
|
||||
$('#ldap_admin_username').prop('required', false);
|
||||
$('#ldap_admin_password').prop('required', false);
|
||||
$('#ldap_filter_basic').prop('required', false);
|
||||
$('#ldap_filter_username').prop('required', false);
|
||||
|
||||
if ($('#ldap_sg_on').is(":checked")) {
|
||||
$('#ldap_admin_group').prop('required', false);
|
||||
$('#ldap_user_group').prop('required', false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$("input[name='ldap_sg_enabled']" ).change(function(){
|
||||
if ($('#ldap_sg_on').is(":checked") && $('#ldap_enabled').is(":checked")) {
|
||||
$('#ldap_admin_group').prop('required', true);
|
||||
$('#ldap_user_group').prop('required', true);
|
||||
} else {
|
||||
$('#ldap_admin_group').prop('required', false);
|
||||
$('#ldap_user_group').prop('required', false);
|
||||
}
|
||||
});
|
||||
|
||||
// init validation reqirement at first time page load
|
||||
{% if SETTING.get('ldap_enabled') %}
|
||||
$('#ldap_uri').prop('required', true);
|
||||
$('#ldap_base_dn').prop('required', true);
|
||||
$('#ldap_admin_username').prop('required', true);
|
||||
$('#ldap_admin_password').prop('required', true);
|
||||
$('#ldap_filter_basic').prop('required', true);
|
||||
$('#ldap_filter_username').prop('required', true);
|
||||
|
||||
if ($('#ldap_sg_on').is(":checked")) {
|
||||
$('#ldap_admin_group').prop('required', true);
|
||||
$('#ldap_user_group').prop('required', true);
|
||||
}
|
||||
{% endif %}
|
||||
// END: LDAP tab js
|
||||
|
||||
// START: Google tab js
|
||||
// update validation requirement when checkbox is togged
|
||||
$('#google_oauth_enabled').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
}).on('ifChanged', function(e) {
|
||||
var is_enabled = e.currentTarget.checked;
|
||||
if (is_enabled){
|
||||
$('#google_oauth_client_id').prop('required', true);
|
||||
$('#google_oauth_client_secret').prop('required', true);
|
||||
$('#google_token_url').prop('required', true);
|
||||
$('#google_token_params').prop('required', true);
|
||||
$('#google_authorize_url').prop('required', true);
|
||||
$('#google_base_url').prop('required', true);
|
||||
} else {
|
||||
$('#google_oauth_client_id').prop('required', false);
|
||||
$('#google_oauth_client_secret').prop('required', false);
|
||||
$('#google_token_url').prop('required', false);
|
||||
$('#google_token_params').prop('required', false);
|
||||
$('#google_authorize_url').prop('required', false);
|
||||
$('#google_base_url').prop('required', false);
|
||||
}
|
||||
});
|
||||
|
||||
// init validation reqirement at first time page load
|
||||
{% if SETTING.get('google_oauth_enabled') %}
|
||||
$('#google_oauth_client_id').prop('required', true);
|
||||
$('#google_oauth_client_secret').prop('required', true);
|
||||
$('#google_token_url').prop('required', true);
|
||||
$('#google_token_params').prop('required', true);
|
||||
$('#google_authorize_url').prop('required', true);
|
||||
$('#google_base_url').prop('required', true);
|
||||
{% endif %}
|
||||
// END: Google tab js
|
||||
|
||||
// START: Github tab js
|
||||
// update validation requirement when checkbox is togged
|
||||
$('#github_oauth_enabled').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
}).on('ifChanged', function(e) {
|
||||
var is_enabled = e.currentTarget.checked;
|
||||
if (is_enabled){
|
||||
$('#github_oauth_key').prop('required', true);
|
||||
$('#github_oauth_secret').prop('required', true);
|
||||
$('#github_oauth_scope').prop('required', true);
|
||||
$('#github_oauth_api_url').prop('required', true);
|
||||
$('#github_oauth_token_url').prop('required', true);
|
||||
$('#github_oauth_authorize_url').prop('required', true);
|
||||
} else {
|
||||
$('#github_oauth_key').prop('required', false);
|
||||
$('#github_oauth_secret').prop('required', false);
|
||||
$('#github_oauth_scope').prop('required', false);
|
||||
$('#github_oauth_api_url').prop('required', false);
|
||||
$('#github_oauth_token_url').prop('required', false);
|
||||
$('#github_oauth_authorize_url').prop('required', false);
|
||||
}
|
||||
});
|
||||
|
||||
// init validation reqirement at first time page load
|
||||
{% if SETTING.get('google_oauth_enabled') %}
|
||||
$('#github_oauth_key').prop('required', true);
|
||||
$('#github_oauth_secret').prop('required', true);
|
||||
$('#github_oauth_scope').prop('required', true);
|
||||
$('#github_oauth_api_url').prop('required', true);
|
||||
$('#github_oauth_token_url').prop('required', true);
|
||||
$('#github_oauth_authorize_url').prop('required', true);
|
||||
{% endif %}
|
||||
// END: Github tab js
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,85 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_settings" %}
|
||||
{% block title %}
|
||||
<title>PDNS Settings - {{ SITE_NAME }}</title>
|
||||
{% endblock %} {% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Settings <small>PowerDNS-Admin settings</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>
|
||||
<li><a href="#">Setting</a></li>
|
||||
<li class="active">PDNS</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">PDNS Settings</h3>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<!-- form start -->
|
||||
<form role="form" method="post" data-toggle="validator">
|
||||
<div class="box-body">
|
||||
{% if not SETTING.get('pdns_api_url') or not SETTING.get('pdns_api_key') or not SETTING.get('pdns_version') %}
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
<h4><i class="icon fa fa-ban"></i> Error!</h4>
|
||||
Please complete your PowerDNS API configuration before continuing
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="pdns_api_url">PDNS API URL</label>
|
||||
<input type="url" class="form-control" placeholder="PowerDNS API url" name="pdns_api_url" data-error="Please input a valid PowerDNS API URL" required value="{{ pdns_api_url }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="pdns_api_key">PDNS API KEY</label>
|
||||
<input type="password" class="form-control" placeholder="PowerDNS API key" name="pdns_api_key" data-error="Please input a valid PowerDNS API key" required value="{{ pdns_api_key }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<label class="control-label" for="pdns_version">PDNS VERSION</label>
|
||||
<input type="text" class="form-control" placeholder="PowerDNS version" name="pdns_version" data-error="Please input PowerDNS version" required value="{{ pdns_version }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-footer">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Help</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<dl class="dl-horizontal">
|
||||
<p>You must configure the API connection information before PowerDNS-Admiin can query your PowerDNS data. Following fields are required:</p>
|
||||
<dt>PDNS API URL</dt>
|
||||
<dd>Your PowerDNS API URL (eg. http://127.0.0.1:8081/).</dd>
|
||||
<dt>PDNS API KEY</dt>
|
||||
<dd>Your PowerDNS API key.</dd>
|
||||
<dt>PDNS VERSION</dt>
|
||||
<dd>Your PowerDNS version number (eg. 4.1.1).</dd>
|
||||
</dl>
|
||||
<p>Find more details at <a href="https://doc.powerdns.com/md/httpapi/README/">https://doc.powerdns.com/md/httpapi/README/</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
{% assets "js_validation" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
{% endblock %}
|
|
@ -1,78 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_settings" %}
|
||||
{% block title %}
|
||||
<title>DNS Records Settings - {{ SITE_NAME }}</title>
|
||||
{% endblock %} {% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Settings <small>PowerDNS-Admin settings</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>
|
||||
<li><a href="#">Setting</a></li>
|
||||
<li class="active">Records</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">DNS record Settings</h3>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<!-- form start -->
|
||||
<form role="form" method="post">
|
||||
<input type="hidden" name="create" value="{{ create }}">
|
||||
<div class="box-body">
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th style="width: 10px">#</th>
|
||||
<th style="width: 40px">Record</th>
|
||||
<th>Forward Zone</th>
|
||||
<th>Reverse Zone</th>
|
||||
</tr>
|
||||
{% for record in f_records %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ record }}</td>
|
||||
<td>
|
||||
<input type="checkbox" id="fr_{{ record|lower }}" name="fr_{{ record|lower }}" class="checkbox" {% if f_records[record] %}checked{% endif %}>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" id="rr_{{ record|lower }}" name="rr_{{ record|lower }}" class="checkbox" {% if r_records[record] %}checked{% endif %}>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="box-footer">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Help</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>Select record types you allow user to edit in the forward zone and reverse zone. Take a look at <a href="https://doc.powerdns.com/authoritative/appendices/types.html">PowerDNS docs</a> for full list of supported record types.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
$('.checkbox').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,241 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% block head %}
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
{% block title %}<title>{{ SITE_NAME }}</title>{% endblock %}
|
||||
<!-- Get Google Fonts we like -->
|
||||
<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">
|
||||
<!-- 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_main" -%}
|
||||
<link rel="stylesheet" href="{{ ASSET_URL }}">
|
||||
{%- endassets %}
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="hold-transition skin-blue sidebar-mini {% if not SETTING.get('fullscreen_layout') %}layout-boxed{% endif %}">
|
||||
<div class="wrapper">
|
||||
{% block pageheader %}
|
||||
<header class="main-header">
|
||||
<!-- Logo -->
|
||||
<a href="{{ url_for('index') }}" class="logo">
|
||||
<!-- mini logo for sidebar mini 50x50 pixels -->
|
||||
<span class="logo-mini"><b>PD</b>A</span>
|
||||
<!-- logo for regular state and mobile devices -->
|
||||
<span class="logo-lg"><b>PowerDNS</b>-Admin</span>
|
||||
</a>
|
||||
<!-- Header Navbar: style can be found in header.less -->
|
||||
<nav class="navbar navbar-static-top">
|
||||
<!-- Sidebar toggle button-->
|
||||
<a href="#" class="sidebar-toggle" data-toggle="push-menu" role="button">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
</a>
|
||||
|
||||
<div class="navbar-custom-menu">
|
||||
{% if current_user.id is defined %}
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- User Account: style can be found in dropdown.less -->
|
||||
<li class="dropdown user user-menu">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
{% if current_user.avatar %}
|
||||
<img src="{{ url_for('user_avatar', filename=current_user.avatar) }}" class="user-image" alt="User Image"/>
|
||||
{% else %}
|
||||
<img src="{{ current_user.email|email_to_gravatar_url(size=80) }}" class="user-image" alt="User Image"/>
|
||||
{% endif %}
|
||||
<span class="hidden-xs">
|
||||
{{ current_user.firstname }}
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="user-header">
|
||||
{% if current_user.avatar %}
|
||||
<img src="{{ url_for('user_avatar', filename=current_user.avatar) }}" class="img-circle" alt="User Image"/>
|
||||
{% else %}
|
||||
<img src="{{ current_user.email|email_to_gravatar_url(size=160) }}" class="img-circle" alt="User Image"/>
|
||||
{% endif %}
|
||||
<p>
|
||||
{{ current_user.firstname }} {{ current_user.lastname }}
|
||||
<small>{{ current_user.role.name }}</small>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<!-- Menu Footer-->
|
||||
<li class="user-footer">
|
||||
<div class="pull-left">
|
||||
<a href="{{ url_for('user_profile') }}" class="btn btn-flat btn-primary">My Profile</a>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-flat btn-warning">Log out</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
{% endblock %}
|
||||
<!-- Left side column. contains the logo and sidebar -->
|
||||
<aside class="main-sidebar">
|
||||
<!-- sidebar: style can be found in sidebar.less -->
|
||||
<section class="sidebar">
|
||||
{% if current_user.id is defined %}
|
||||
<div class="user-panel">
|
||||
<div class="pull-left image">
|
||||
{% if current_user.avatar %}
|
||||
<img src="{{ url_for('user_avatar', filename=current_user.avatar) }}" class="img-circle" alt="User Image"/>
|
||||
{% else %}
|
||||
<img src="{{ current_user.email|email_to_gravatar_url(size=100) }}" class="img-circle" alt="User Image"/>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pull-left info">
|
||||
<p>{{ current_user.firstname }} {{ current_user.lastname }}</p>
|
||||
<a href="#"><i class="fa fa-circle text-success"></i> Logged In</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- sidebar menu: : style can be found in sidebar.less -->
|
||||
<ul class="sidebar-menu" data-widget="tree">
|
||||
<li class="header">USER ACTIONS</li>
|
||||
<li class="{{ 'active' if active_page == 'dashboard' else '' }}">
|
||||
<a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i> Dashboard</a>
|
||||
</li>
|
||||
{% if SETTING.get('allow_user_create_domain') or current_user.role.name in ['Administrator', 'Operator'] %}
|
||||
<li class="{{ 'active' if active_page == 'new_domain' else '' }}">
|
||||
<a href="{{ url_for('domain_add') }}"><i class="fa fa-plus"></i> New Domain</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role.name in ['Administrator', 'Operator'] %}
|
||||
<li class="header">ADMINISTRATION</li>
|
||||
<li class="{{ 'active' if active_page == 'admin_console' else '' }}">
|
||||
<a href="{{ url_for('admin_pdns') }}"><i class="fa fa-info-circle"></i> PDNS</a>
|
||||
</li>
|
||||
<li class="{{ 'active' if active_page == 'admin_history' else '' }}">
|
||||
<a href="{{ url_for('admin_history') }}"><i class="fa fa-calendar"></i> History</a>
|
||||
</li>
|
||||
<li class="{{ 'active' if active_page == 'admin_domain_template' else '' }}">
|
||||
<a href="{{ url_for('templates') }}"><i class="fa fa-clone"></i> Domain Templates</a>
|
||||
</li>
|
||||
<li class="{{ 'active' if active_page == 'admin_accounts' else '' }}">
|
||||
<a href="{{ url_for('admin_manageaccount') }}"><i class="fa fa-industry"></i> Accounts</a>
|
||||
</li>
|
||||
<li class="{{ 'active' if active_page == 'admin_users' else '' }}">
|
||||
<a href="{{ url_for('admin_manageuser') }}"><i class="fa fa-users"></i> Users</a>
|
||||
</li>
|
||||
<li class="{{ 'treeview active' if active_page == 'admin_settings' else 'treeview' }}">
|
||||
<a href="#">
|
||||
<i class="fa fa-cog"></i> Settings
|
||||
<span class="pull-right-container">
|
||||
<i class="fa fa-angle-left pull-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<ul class="treeview-menu" {% if active_page == 'admin_settings' %}style="display: block;"{% endif %}>
|
||||
<li><a href="{{ url_for('admin_setting_basic') }}"><i class="fa fa-circle-o"></i></i> Basic</a></li>
|
||||
<li><a href="{{ url_for('admin_setting_records') }}"><i class="fa fa-circle-o"></i> Records</a></li>
|
||||
{% if current_user.role.name == 'Administrator' %}
|
||||
<li><a href="{{ url_for('admin_setting_pdns') }}"><i class="fa fa-circle-o"></i> PDNS</a></li>
|
||||
<li><a href="{{ url_for('admin_setting_authentication') }}"><i class="fa fa-circle-o"></i> Authentication</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
<!-- /.sidebar -->
|
||||
</aside>
|
||||
|
||||
<!-- Content Wrapper. Contains page content -->
|
||||
<div class="content-wrapper">
|
||||
{% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Dashboard
|
||||
<small>Control panel</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>
|
||||
<li class="active">Dashboard</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<!-- /.content-wrapper -->
|
||||
<footer class="main-footer">
|
||||
<strong><a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></strong> - A PowerDNS web interface with advanced features.
|
||||
</footer>
|
||||
</div>
|
||||
<!-- ./wrapper -->
|
||||
<script type="text/javascript">
|
||||
$SCRIPT_ROOT = {{ request.script_root|tojson|safe }};
|
||||
</script>
|
||||
{% block scripts %}
|
||||
{% assets "js_main" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
{% endblock %}
|
||||
{% block defaultmodals %}
|
||||
<div class="modal fade modal-danger" id="modal_error">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"
|
||||
aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">Error</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-right"
|
||||
data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<!-- /.modal -->
|
||||
<div class="modal fade modal-success" id="modal_success">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"
|
||||
aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">Success</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-right"
|
||||
data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<!-- /.modal -->
|
||||
{% endblock %}
|
||||
{% block modals %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -1,162 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "new_domain" %}
|
||||
{% block title %}<title>Add Domain - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
{% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Domain
|
||||
<small>Create new</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i class="fa fa-dashboard"></i>Home</a></li>
|
||||
<li><a href="{{ url_for('dashboard') }}">Domain</a></li>
|
||||
<li class="active">Add Domain</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Create new domain</h3>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<!-- form start -->
|
||||
<form role="form" method="post" action="{{ url_for('domain_add') }}">
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="domain_name" id="domain_name" placeholder="Enter a valid domain name (required)">
|
||||
</div>
|
||||
<select name="accountid" class="form-control" style="width:15em;">
|
||||
<option value="0">- No Account -</option>
|
||||
{% for account in accounts %}
|
||||
<option value="{{ account.id }}">{{ account.name }}</option>
|
||||
{% endfor %}
|
||||
</select><br/>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="radio_type" id="radio_type_native" value="native" checked> Native
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="radio_type" id="radio_type_master" value="master"> Master
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="radio_type" id="radio_type_slave" value="slave">Slave
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Select a template</label>
|
||||
<select class="form-control" id="domain_template" name="domain_template">
|
||||
<option value="0">No template</option>
|
||||
{% for template in templates %}
|
||||
<option value="{{ template.id }}">{{ template.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display: none;" id="domain_master_address_div">
|
||||
<input type="text" class="form-control" name="domain_master_address" id="domain_master_address" placeholder="Enter valid master ip addresses (separated by commas)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SOA-EDIT-API</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="radio_type_soa_edit_api" id="radio_default" value="DEFAULT" checked> DEFAULT
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="radio_type_soa_edit_api" id="radio_increase" value="INCREASE"> INCREASE
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="radio_type_soa_edit_api" id="radio_epoch" value="EPOCH"> EPOCH
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="radio_type_soa_edit_api" id="radio_off" value="OFF"> OFF
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
|
||||
<div class="box-footer">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
|
||||
<button type="button" class="btn btn-flat btn-default" onclick="window.location.href='{{ url_for('dashboard') }}'">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Help with creating a new domain</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>Domain name</dt>
|
||||
<dd>Enter your domain name in the format of name.tld (eg. powerdns-admin.com). You can also enter sub-domains to create a sub-root zone (eg. sub.powerdns-admin.com) in case you want to delegate sub-domain management to specific users.</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>The type decides how the domain will be replicated across multiple DNS servers.
|
||||
<ul>
|
||||
<li>
|
||||
Native - PowerDNS will not perform any replication. Use this if you only have one PowerDNS server or you handle replication via your backend (MySQL).
|
||||
</li>
|
||||
<li>
|
||||
Master - This PowerDNS server will serve as the master and will send zone transfers (AXFRs) to other servers configured as slaves.
|
||||
</li>
|
||||
<li>
|
||||
Slave - This PowerDNS server will serve as the slave and will request and receive zone transfers (AXFRs) from other servers configured as masters.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt>SOA-EDIT-API</dt>
|
||||
<dd>The SOA-EDIT-API setting defines how the SOA serial number will be updated after a change is made to the domain.
|
||||
<ul>
|
||||
<li>
|
||||
DEFAULT - Generate a soa serial of YYYYMMDD01. If the current serial is lower than the generated serial, use the generated serial. If the current serial is higher or equal to the generated serial, increase the current serial by 1.
|
||||
</li>
|
||||
<li>
|
||||
INCREASE - Increase the current serial by 1.
|
||||
</li>
|
||||
<li>
|
||||
EPOCH - Change the serial to the number of seconds since the EPOCH, aka unixtime.
|
||||
</li>
|
||||
<li>
|
||||
OFF - Disable automatic updates of the SOA serial.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<p>Find more details at <a href="https://docs.powerdns.com/md/">https://docs.powerdns.com/md/</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
$("input[name=radio_type]").change(function() {
|
||||
var type = $(this).val();
|
||||
if (type == "slave") {
|
||||
$("#domain_master_address_div").show();
|
||||
} else {
|
||||
$("#domain_master_address_div").hide();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,269 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}<title>Domain Management - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
{% block dashboard_stat %}
|
||||
{% if status %}
|
||||
{% if status.get('status') == 'ok' %}
|
||||
<div class="alert alert-success">
|
||||
<strong>Success!</strong> {{ status.get('msg') }}
|
||||
</div>
|
||||
{% elif status.get('status') == 'error' %}
|
||||
<div class="alert alert-danger">
|
||||
{% if status.get('msg') != None %}
|
||||
<strong>Error!</strong> {{ status.get('msg') }}
|
||||
{% else %}
|
||||
<strong>Error!</strong> An undefined error occurred.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Manage domain <small>{{ domain.name }}</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard') }}"><i
|
||||
class="fa fa-dashboard"></i> Home</a></li>
|
||||
<li class="active">Domain Management</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<form method="post" action="{{ url_for('domain_management', domain_name=domain.name) }}">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Domain Access Control</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="row">
|
||||
<div class="col-xs-2">
|
||||
<p>Users on the right have access to manage the records in
|
||||
the {{ domain.name }} domain.</p>
|
||||
<p>Click on users to move from between columns.</p>
|
||||
<p>
|
||||
Users in <font style="color: red;">red</font> are Administrators
|
||||
and already have access to <b>ALL</b> domains.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group col-xs-2">
|
||||
<select multiple="multiple" class="form-control" id="domain_multi_user" name="domain_multi_user[]">
|
||||
{% for user in users %}
|
||||
<option {% if user.id in
|
||||
domain_user_ids %}selected{% endif %} value="{{ user.username }}"
|
||||
{% if user.role.name== 'Administrator' %}style="color: red"{% endif %}>{{
|
||||
user.username}}</option> {% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="col-xs-offset-2">
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary"><i class="fa fa-check"></i> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Account</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="col-xs-12">
|
||||
<div class="form-group">
|
||||
<form method="post" action="{{ url_for('domain_change_account', domain_name=domain.name) }}">
|
||||
<select name="accountid" class="form-control" style="width:15em;">
|
||||
<option value="0">- No Account -</option>
|
||||
{% for account in accounts %}
|
||||
<option value="{{ account.id }}" {% if domain_account.id == account.id %}selected{% endif %}>{{ account.name }}</option>
|
||||
{% endfor %}
|
||||
</select><br/>
|
||||
<button type="submit" class="btn btn-flat btn-primary" id="change_soa_edit_api">
|
||||
<i class="fa fa-check"></i> Change account for {{ domain.name }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Auto PTR creation</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p><input type="checkbox" id="{{ domain.name }}" class="auto_ptr_toggle"
|
||||
{% for setting in domain.settings %}{% if setting.setting=='auto_ptr' and setting.value=='True' %}checked{% endif %}{% endfor %} {% if SETTING.get('auto_ptr') %}disabled="True"{% endif %}>
|
||||
Allow automatic reverse pointer creation on record updates?{% if
|
||||
SETTING.get('auto_ptr') %}</br><code>Auto-ptr is enabled globally on the PDA system!</code>{% endif %}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">DynDNS 2 Settings</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p><input type="checkbox" id="{{ domain.name }}" class="dyndns_on_demand_toggle"
|
||||
{% for setting in domain.settings %}{% if setting.setting=='create_via_dyndns' and setting.value=='True' %}checked{% endif %}{% endfor %}>
|
||||
Allow on-demand creation of records via DynDNS updates?</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Change SOA-EDIT-API</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>The SOA-EDIT-API setting defines how the SOA serial number will be updated after a change is made to the domain.</p>
|
||||
<ul>
|
||||
<li>
|
||||
DEFAULT - Generate a soa serial of YYYYMMDD01. If the current serial is lower than the generated serial, use the generated serial. If the current serial is higher or equal to the generated serial, increase the current serial by 1.
|
||||
</li>
|
||||
<li>
|
||||
INCREASE - Increase the current serial by 1.
|
||||
</li>
|
||||
<li>
|
||||
EPOCH - Change the serial to the number of seconds since the EPOCH, aka unixtime.
|
||||
</li>
|
||||
<li>
|
||||
OFF - Disable automatic updates of the SOA serial.
|
||||
</li>
|
||||
</ul>
|
||||
<b>New SOA-EDIT-API Setting:</b>
|
||||
<form method="post" action="{{ url_for('domain_change_soa_edit_api', domain_name=domain.name) }}">
|
||||
<select name="soa_edit_api" class="form-control" style="width:15em;">
|
||||
<option selected value="0">- Unchanged -</option>
|
||||
<option>DEFAULT</option>
|
||||
<option>INCREASE</option>
|
||||
<option>EPOCH</option>
|
||||
<option>OFF</option>
|
||||
</select><br/>
|
||||
<button type="submit" class="btn btn-flat btn-primary" id="change_soa_edit_api">
|
||||
<i class="fa fa-check"></i> Change SOA-EDIT-API setting for {{ domain.name }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Domain Deletion</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<p>This function is used to remove a domain from PowerDNS-Admin <b>AND</b> PowerDNS. All records and user privileges associated with this domain will also be removed. This change cannot be reverted.</p>
|
||||
<button type="button" class="btn btn-flat btn-danger pull-left delete_domain" id="{{ domain.name }}">
|
||||
<i class="fa fa-trash"></i> DELETE DOMAIN {{ domain.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
//initialize pretty checkboxes
|
||||
$('.dyndns_on_demand_toggle').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%' // optional
|
||||
});
|
||||
$('.auto_ptr_toggle').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%' // optional
|
||||
});
|
||||
|
||||
$("#domain_multi_user").multiSelect();
|
||||
|
||||
//handle checkbox toggling
|
||||
$('.dyndns_on_demand_toggle').on('ifToggled', function(event) {
|
||||
var is_checked = $(this).prop('checked');
|
||||
var domain = $(this).prop('id');
|
||||
postdata = {
|
||||
'action' : 'set_setting',
|
||||
'data' : {
|
||||
'setting' : 'create_via_dyndns',
|
||||
'value' : is_checked
|
||||
}
|
||||
};
|
||||
applyChanges(postdata, $SCRIPT_ROOT + '/domain/' + domain + '/managesetting', true);
|
||||
});
|
||||
$('.auto_ptr_toggle').on('ifToggled', function(event) {
|
||||
var is_checked = $(this).prop('checked');
|
||||
var domain = $(this).prop('id');
|
||||
postdata = {
|
||||
'action' : 'set_setting',
|
||||
'data' : {
|
||||
'setting' : 'auto_ptr',
|
||||
'value' : is_checked
|
||||
}
|
||||
};
|
||||
applyChanges(postdata, $SCRIPT_ROOT + '/domain/' + domain + '/managesetting', true);
|
||||
});
|
||||
|
||||
// handle deletion of domain
|
||||
$(document.body).on('click', '.delete_domain', function() {
|
||||
var modal = $("#modal_delete_domain");
|
||||
var domain = $(this).prop('id');
|
||||
var info = "Are you sure you want to delete " + domain + "?";
|
||||
modal.find('.modal-body p').text(info);
|
||||
modal.find('#button_delete_confirm').click(function() {
|
||||
$.get($SCRIPT_ROOT + '/admin/domain/' + domain + '/delete', function() {
|
||||
window.location.href = '{{ url_for('dashboard') }}';
|
||||
});
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.modal('show');
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block modals %}
|
||||
<div class="modal fade modal-warning" id="modal_delete_domain">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal"
|
||||
aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">Confirmation</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-left"
|
||||
data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-flat btn-danger" id="button_delete_confirm">
|
||||
Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,134 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Log In - {{ SITE_NAME }}</title>
|
||||
<!-- 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 %}
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="hold-transition login-page">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<a href="{{ url_for('index') }}"><b>PowerDNS</b>-Admin</a>
|
||||
</div>
|
||||
<!-- /.login-logo -->
|
||||
<div class="login-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 %}
|
||||
<form action="" method="post" data-toggle="validator">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" placeholder="Username" name="username" data-error="Please input your username" required {% if username %}value="{{ username }}"{% endif %}>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control" placeholder="Password" name="password" data-error="Please input your password" required {% if password %}value="{{ password }}"{% endif %}>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken">
|
||||
</div>
|
||||
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
|
||||
<div class="form-group">
|
||||
<select class="form-control" name="auth_method">
|
||||
<option value="LOCAL">LOCAL Authentication</option>
|
||||
{% if SETTING.get('login_ldap_first') %}
|
||||
<option value="LDAP" selected="selected">LDAP Authentication</option>
|
||||
{% else %}
|
||||
<option value="LDAP">LDAP Authentication</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
{% elif SETTING.get('ldap_enabled') and not SETTING.get('local_db_enabled') %}
|
||||
<div class="form-group">
|
||||
<input type="hidden" name="auth_method" value="LDAP">
|
||||
</div>
|
||||
{% elif SETTING.get('local_db_enabled') and not SETTING.get('ldap_enabled') %}
|
||||
<div class="form-group">
|
||||
<input type="hidden" name="auth_method" value="LOCAL">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group">
|
||||
<input type="hidden" name="auth_method" value="LOCAL">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-8">
|
||||
<div class="checkbox icheck">
|
||||
<label>
|
||||
<input type="checkbox"> Remember Me
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.col -->
|
||||
<div class="col-xs-4">
|
||||
<button type="submit" class="btn btn-flat btn-primary btn-block">Sign In</button>
|
||||
</div>
|
||||
<!-- /.col -->
|
||||
</div>
|
||||
</form>
|
||||
{% if SETTING.get('google_oauth_enabled') or SETTING.get('github_oauth_enabled') %}
|
||||
<div class="social-auth-links text-center">
|
||||
<p>- OR -</p>
|
||||
{% if SETTING.get('github_oauth_enabled') %}
|
||||
<a href="{{ url_for('github_login') }}" class="btn btn-block btn-social btn-github btn-flat"><i class="fa fa-github"></i> Sign in using
|
||||
Github</a>
|
||||
{% endif %}
|
||||
|
||||
{% if SETTING.get('google_oauth_enabled') %}
|
||||
<a href="{{ url_for('google_login') }}" class="btn btn-block btn-social btn-google btn-flat"><i class="fa fa-google-plus"></i> Sign in using
|
||||
Google</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if saml_enabled %}
|
||||
<a href="{{ url_for('saml_login') }}">SAML login</a>
|
||||
{% endif %}
|
||||
|
||||
{% if SETTING.get('signup_enabled') %}
|
||||
<br>
|
||||
<a href="{{ url_for('register') }}" class="text-center">Create an account </a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- /.login-box-body -->
|
||||
<div class="login-box-footer">
|
||||
<center><p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p></center>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.login-box -->
|
||||
|
||||
{% assets "js_login" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
{% assets "js_validation" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
$('input').iCheck({
|
||||
checkboxClass: 'icheckbox_square-blue',
|
||||
radioClass: 'iradio_square-blue',
|
||||
increaseArea: '20%' // optional
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,18 +0,0 @@
|
|||
<!doctype html>
|
||||
<title>Site Maintenance</title>
|
||||
<style>
|
||||
body { text-align: center; padding: 150px; }
|
||||
h1 { font-size: 50px; }
|
||||
body { font: 20px Helvetica, sans-serif; color: #333; }
|
||||
article { display: block; text-align: left; width: 650px; margin: 0 auto; }
|
||||
a { color: #dc8100; text-decoration: none; }
|
||||
a:hover { color: #333; text-decoration: none; }
|
||||
</style>
|
||||
|
||||
<article>
|
||||
<h1>We’ll be back soon!</h1>
|
||||
<div>
|
||||
<p>Sorry for the inconvenience but we’re performing some maintenance at the moment. Please contact the System Administrator if you need more information</a>, otherwise we’ll be back online shortly!</p>
|
||||
<p>— Team</p>
|
||||
</div>
|
||||
</article>
|
|
@ -1,98 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Register - {{ SITE_NAME }}</title>
|
||||
<!-- 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 %}
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="hold-transition register-page">
|
||||
<div class="register-box">
|
||||
<div class="register-logo">
|
||||
<a href="{{ url_for('index') }}"><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 %}
|
||||
<p class="login-box-msg">Enter your personal details below</p>
|
||||
<form action="{{ url_for('login') }}" method="post" data-toggle="validator">
|
||||
<div class="form-group has-feedback">
|
||||
<input type="text" class="form-control" placeholder="First Name" name="firstname" data-error="Please input your first name" required>
|
||||
<span class="glyphicon glyphicon-user form-control-feedback"></span>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<input type="text" class="form-control" placeholder="Last name" name="lastname" data-error="Please input your last name" required>
|
||||
<span class="glyphicon glyphicon-user form-control-feedback"></span>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<input type="email" class="form-control" placeholder="Email" name="email" data-error="Please input your valid email address"
|
||||
pattern="^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$" required>
|
||||
<span class="glyphicon glyphicon-envelope form-control-feedback"></span>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<p class="login-box-msg">Enter your account details below</p>
|
||||
<div class="form-group has-feedback">
|
||||
<input type="text" class="form-control" placeholder="Username" name="username" data-error="Please input your username" required>
|
||||
<span class="glyphicon glyphicon-user form-control-feedback"></span>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<input type="password" class="form-control" placeholder="Password" id="password" name="password" data-error="Please input your password" required>
|
||||
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
|
||||
</div>
|
||||
<div class="form-group has-feedback">
|
||||
<input type="password" class="form-control" placeholder="Retype password" name="rpassword" data-match="#password" data-match-error="Password confirmation does not match" required>
|
||||
<span class="glyphicon glyphicon-log-in form-control-feedback"></span>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-4 pull-left">
|
||||
<button type="button" class="btn btn-flat btn-block" id="button_back">Back</button>
|
||||
</div>
|
||||
<div class="col-xs-4 pull-right">
|
||||
<button type="submit" class="btn btn-flat btn-primary btn-block">Register</button>
|
||||
</div>
|
||||
<!-- /.col -->
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- /.form-box -->
|
||||
<div class="login-box-footer">
|
||||
<center><p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p></center>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.login-box -->
|
||||
|
||||
{% assets "js_login" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
{% assets "js_validation" -%}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{%- endassets %}
|
||||
<script>
|
||||
$(function () {
|
||||
$('#button_back').click(function(){
|
||||
window.location.href='{{ url_for('login') }}';
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,117 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% set active_page = "admin_domain_template" %}
|
||||
{% block title %}<title>Templates - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
{% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Templates
|
||||
<small>List</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('templates') }}"><i class="fa fa-dashboard"></i> Templates</a></li>
|
||||
<li class="active">List</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
{% with errors = get_flashed_messages(category_filter=["error"]) %} {% if errors %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert"
|
||||
aria-hidden="true">×</button>
|
||||
<h4>
|
||||
<i class="icon fa fa-ban"></i> Error!
|
||||
</h4>
|
||||
<div class="alert-message block-message error">
|
||||
<a class="close" href="#">x</a>
|
||||
<ul>
|
||||
{%- for msg in errors %}
|
||||
<li>{{ msg }}</li> {% endfor -%}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% endwith %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Templates</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<a href="{{ url_for('create_template') }}">
|
||||
<button type="button" class="btn btn-flat btn-primary pull-left">
|
||||
Create Template <i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<table id="tbl_template_list" class="table table-bordered table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Number of Records</th>
|
||||
<th width="20%">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for template in templates %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_template', template=template.name) }}"><strong>{{ template.name }}</strong></a>
|
||||
</td>
|
||||
<td>
|
||||
{{ template.description }}
|
||||
</td>
|
||||
<td>
|
||||
{{ template.records|count }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_template', template=template.name) }}">
|
||||
<button type="button" class="btn btn-flat btn-warning button_edit" id="btn_edit">
|
||||
Edit <i class="fa fa-edit"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="{{ url_for('delete_template', template=template.name) }}">
|
||||
<button type="button" class="btn btn-flat btn-danger button_delete" id="btn_delete">
|
||||
Delete <i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
</div>
|
||||
<!-- /.col -->
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
</section>
|
||||
<!-- /.content -->
|
||||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
// set up history data table
|
||||
$("#tbl_template_list").DataTable({
|
||||
"paging" : true,
|
||||
"lengthChange" : true,
|
||||
"searching" : true,
|
||||
"ordering" : true,
|
||||
"info" : false,
|
||||
"autoWidth" : false
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block modals %}
|
||||
{% endblock %}
|
1662
app/views.py
1662
app/views.py
File diff suppressed because it is too large
Load diff
|
@ -1,105 +0,0 @@
|
|||
import os
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
# BASIC APP CONFIG
|
||||
WTF_CSRF_ENABLED = True
|
||||
SECRET_KEY = 'We are the world'
|
||||
BIND_ADDRESS = '127.0.0.1'
|
||||
PORT = 9191
|
||||
|
||||
# TIMEOUT - for large zones
|
||||
TIMEOUT = 10
|
||||
|
||||
# LOG CONFIG
|
||||
# - For docker, LOG_FILE=''
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
LOG_FILE = 'logfile.log'
|
||||
|
||||
# UPLOAD DIRECTORY
|
||||
UPLOAD_DIR = os.path.join(basedir, 'upload')
|
||||
|
||||
# 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
|
||||
|
||||
# DATBASE - MySQL
|
||||
SQLALCHEMY_DATABASE_URI = 'mysql://'+SQLA_DB_USER+':'+SQLA_DB_PASSWORD+'@'+SQLA_DB_HOST+'/'+SQLA_DB_NAME
|
||||
|
||||
# DATABSE - SQLite
|
||||
# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
|
||||
|
||||
# SAML Authnetication
|
||||
SAML_ENABLED = False
|
||||
SAML_DEBUG = True
|
||||
SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml')
|
||||
##Example for ADFS Metadata-URL
|
||||
SAML_METADATA_URL = 'https://<hostname>/FederationMetadata/2007-06/FederationMetadata.xml'
|
||||
#Cache Lifetime in Seconds
|
||||
SAML_METADATA_CACHE_LIFETIME = 1
|
||||
|
||||
# SAML SSO binding format to use
|
||||
## Default: library default (urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect)
|
||||
#SAML_IDP_SSO_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
|
||||
|
||||
## EntityID of the IdP to use. Only needed if more than one IdP is
|
||||
## in the SAML_METADATA_URL
|
||||
### Default: First (only) IdP in the SAML_METADATA_URL
|
||||
### Example: https://idp.example.edu/idp
|
||||
#SAML_IDP_ENTITY_ID = 'https://idp.example.edu/idp'
|
||||
## NameID format to request
|
||||
### Default: The SAML NameID Format in the metadata if present,
|
||||
### otherwise urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
|
||||
### Example: urn:oid:0.9.2342.19200300.100.1.1
|
||||
#SAML_NAMEID_FORMAT = 'urn:oid:0.9.2342.19200300.100.1.1'
|
||||
|
||||
## Attribute to use for Email address
|
||||
### Default: email
|
||||
### Example: urn:oid:0.9.2342.19200300.100.1.3
|
||||
#SAML_ATTRIBUTE_EMAIL = 'urn:oid:0.9.2342.19200300.100.1.3'
|
||||
|
||||
## Attribute to use for Given name
|
||||
### Default: givenname
|
||||
### Example: urn:oid:2.5.4.42
|
||||
#SAML_ATTRIBUTE_GIVENNAME = 'urn:oid:2.5.4.42'
|
||||
|
||||
## Attribute to use for Surname
|
||||
### Default: surname
|
||||
### Example: urn:oid:2.5.4.4
|
||||
#SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4'
|
||||
|
||||
## Attribute to use for username
|
||||
### Default: Use NameID instead
|
||||
### Example: urn:oid:0.9.2342.19200300.100.1.1
|
||||
#SAML_ATTRIBUTE_USERNAME = 'urn:oid:0.9.2342.19200300.100.1.1'
|
||||
|
||||
## Attribute to get admin status from
|
||||
### Default: Don't control admin with SAML attribute
|
||||
### Example: https://example.edu/pdns-admin
|
||||
### If set, look for the value 'true' to set a user as an administrator
|
||||
### If not included in assertion, or set to something other than 'true',
|
||||
### the user is set as a non-administrator user.
|
||||
#SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin'
|
||||
|
||||
## Attribute to get account names from
|
||||
### Default: Don't control accounts with SAML attribute
|
||||
### If set, the user will be added and removed from accounts to match
|
||||
### what's in the login assertion. Accounts that don't exist will
|
||||
### be created and the user added to them.
|
||||
SAML_ATTRIBUTE_ACCOUNT = 'https://example.edu/pdns-account'
|
||||
|
||||
SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
|
||||
SAML_SP_CONTACT_NAME = '<contact name>'
|
||||
SAML_SP_CONTACT_MAIL = '<contact mail>'
|
||||
#Cofigures if SAML tokens should be encrypted.
|
||||
#If enabled a new app certificate will be generated on restart
|
||||
SAML_SIGN_REQUEST = False
|
||||
#Use SAML standard logout mechanism retreived from idp metadata
|
||||
#If configured false don't care about SAML session on logout.
|
||||
#Logout from PowerDNS-Admin only and keep SAML session authenticated.
|
||||
SAML_LOGOUT = False
|
||||
#Configure to redirect to a different url then PowerDNS-Admin login after SAML logout
|
||||
#for example redirect to google.com after successful saml logout
|
||||
#SAML_LOGOUT_URL = 'https://google.com'
|
|
@ -1,98 +1,165 @@
|
|||
import os
|
||||
#import urllib.parse
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
# BASIC APP CONFIG
|
||||
WTF_CSRF_ENABLED = True
|
||||
SECRET_KEY = 'changeme'
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
LOG_FILE = 'logs/log.txt'
|
||||
### BASIC APP CONFIG
|
||||
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
||||
SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
|
||||
BIND_ADDRESS = '0.0.0.0'
|
||||
PORT = 9191
|
||||
OFFLINE_MODE = False
|
||||
|
||||
# TIMEOUT - for large zones
|
||||
TIMEOUT = 10
|
||||
|
||||
# UPLOAD DIR
|
||||
UPLOAD_DIR = os.path.join(basedir, 'upload')
|
||||
|
||||
# DATABASE CONFIG FOR MYSQL
|
||||
DB_HOST = os.environ.get('PDA_DB_HOST')
|
||||
DB_NAME = os.environ.get('PDA_DB_NAME')
|
||||
DB_USER = os.environ.get('PDA_DB_USER')
|
||||
DB_PASSWORD = os.environ.get('PDA_DB_PASSWORD')
|
||||
|
||||
#MySQL
|
||||
SQLALCHEMY_DATABASE_URI = 'mysql://'+DB_USER+':'+DB_PASSWORD+'@'+DB_HOST+'/'+DB_NAME
|
||||
SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'db_repository')
|
||||
### 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
|
||||
|
||||
### DATABASE - MySQL
|
||||
#SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format(
|
||||
# urllib.parse.quote_plus(SQLA_DB_USER),
|
||||
# urllib.parse.quote_plus(SQLA_DB_PASSWORD),
|
||||
# SQLA_DB_HOST,
|
||||
# SQLA_DB_NAME
|
||||
#)
|
||||
|
||||
### DATABASE - SQLite
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
|
||||
|
||||
### SMTP config
|
||||
# MAIL_SERVER = 'localhost'
|
||||
# MAIL_PORT = 25
|
||||
# MAIL_DEBUG = False
|
||||
# MAIL_USE_TLS = False
|
||||
# MAIL_USE_SSL = False
|
||||
# MAIL_USERNAME = None
|
||||
# MAIL_PASSWORD = None
|
||||
# MAIL_DEFAULT_SENDER = ('PowerDNS-Admin', 'noreply@domain.ltd')
|
||||
|
||||
# SAML Authnetication
|
||||
SAML_ENABLED = False
|
||||
SAML_DEBUG = True
|
||||
SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml')
|
||||
##Example for ADFS Metadata-URL
|
||||
SAML_METADATA_URL = 'https://<hostname>/FederationMetadata/2007-06/FederationMetadata.xml'
|
||||
#Cache Lifetime in Seconds
|
||||
SAML_METADATA_CACHE_LIFETIME = 1
|
||||
# SAML_DEBUG = True
|
||||
# SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml')
|
||||
# ##Example for ADFS Metadata-URL
|
||||
# SAML_METADATA_URL = 'https://<hostname>/FederationMetadata/2007-06/FederationMetadata.xml'
|
||||
# #Cache Lifetime in Seconds
|
||||
# SAML_METADATA_CACHE_LIFETIME = 1
|
||||
|
||||
# SAML SSO binding format to use
|
||||
## Default: library default (urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect)
|
||||
#SAML_IDP_SSO_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
|
||||
# # SAML SSO binding format to use
|
||||
# ## Default: library default (urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect)
|
||||
# #SAML_IDP_SSO_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
|
||||
|
||||
## EntityID of the IdP to use. Only needed if more than one IdP is
|
||||
## in the SAML_METADATA_URL
|
||||
### Default: First (only) IdP in the SAML_METADATA_URL
|
||||
### Example: https://idp.example.edu/idp
|
||||
#SAML_IDP_ENTITY_ID = 'https://idp.example.edu/idp'
|
||||
## NameID format to request
|
||||
### Default: The SAML NameID Format in the metadata if present,
|
||||
### otherwise urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
|
||||
### Example: urn:oid:0.9.2342.19200300.100.1.1
|
||||
#SAML_NAMEID_FORMAT = 'urn:oid:0.9.2342.19200300.100.1.1'
|
||||
# ## EntityID of the IdP to use. Only needed if more than one IdP is
|
||||
# ## in the SAML_METADATA_URL
|
||||
# ### Default: First (only) IdP in the SAML_METADATA_URL
|
||||
# ### Example: https://idp.example.edu/idp
|
||||
# #SAML_IDP_ENTITY_ID = 'https://idp.example.edu/idp'
|
||||
# ## NameID format to request
|
||||
# ### Default: The SAML NameID Format in the metadata if present,
|
||||
# ### otherwise urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
|
||||
# ### Example: urn:oid:0.9.2342.19200300.100.1.1
|
||||
# #SAML_NAMEID_FORMAT = 'urn:oid:0.9.2342.19200300.100.1.1'
|
||||
|
||||
## Attribute to use for Email address
|
||||
### Default: email
|
||||
### Example: urn:oid:0.9.2342.19200300.100.1.3
|
||||
#SAML_ATTRIBUTE_EMAIL = 'urn:oid:0.9.2342.19200300.100.1.3'
|
||||
# Following parameter defines RequestedAttributes section in SAML metadata
|
||||
# since certain iDPs require explicit attribute request. If not provided section
|
||||
# will not be available in metadata.
|
||||
#
|
||||
# Possible attributes:
|
||||
# name (mandatory), nameFormat, isRequired, friendlyName
|
||||
#
|
||||
# NOTE: This parameter requires to be entered in valid JSON format as displayed below
|
||||
# and multiple attributes can given
|
||||
#
|
||||
# Following example:
|
||||
#
|
||||
# SAML_SP_REQUESTED_ATTRIBUTES = '[ \
|
||||
# {"name": "urn:oid:0.9.2342.19200300.100.1.3", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": true, "friendlyName": "email"}, \
|
||||
# {"name": "mail", "isRequired": false, "friendlyName": "test-field"} \
|
||||
# ]'
|
||||
#
|
||||
# produces following metadata section:
|
||||
# <md:AttributeConsumingService index="1">
|
||||
# <md:RequestedAttribute Name="urn:oid:0.9.2342.19200300.100.1.3" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="email" isRequired="true"/>
|
||||
# <md:RequestedAttribute Name="mail" FriendlyName="test-field"/>
|
||||
# </md:AttributeConsumingService>
|
||||
|
||||
## Attribute to use for Given name
|
||||
### Default: givenname
|
||||
### Example: urn:oid:2.5.4.42
|
||||
#SAML_ATTRIBUTE_GIVENNAME = 'urn:oid:2.5.4.42'
|
||||
|
||||
## Attribute to use for Surname
|
||||
### Default: surname
|
||||
### Example: urn:oid:2.5.4.4
|
||||
#SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4'
|
||||
# ## Attribute to use for Email address
|
||||
# ### Default: email
|
||||
# ### Example: urn:oid:0.9.2342.19200300.100.1.3
|
||||
# #SAML_ATTRIBUTE_EMAIL = 'urn:oid:0.9.2342.19200300.100.1.3'
|
||||
|
||||
## Attribute to use for username
|
||||
### Default: Use NameID instead
|
||||
### Example: urn:oid:0.9.2342.19200300.100.1.1
|
||||
#SAML_ATTRIBUTE_USERNAME = 'urn:oid:0.9.2342.19200300.100.1.1'
|
||||
# ## Attribute to use for Given name
|
||||
# ### Default: givenname
|
||||
# ### Example: urn:oid:2.5.4.42
|
||||
# #SAML_ATTRIBUTE_GIVENNAME = 'urn:oid:2.5.4.42'
|
||||
|
||||
## Attribute to get admin status from
|
||||
### Default: Don't control admin with SAML attribute
|
||||
### Example: https://example.edu/pdns-admin
|
||||
### If set, look for the value 'true' to set a user as an administrator
|
||||
### If not included in assertion, or set to something other than 'true',
|
||||
### the user is set as a non-administrator user.
|
||||
#SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin'
|
||||
# ## Attribute to use for Surname
|
||||
# ### Default: surname
|
||||
# ### Example: urn:oid:2.5.4.4
|
||||
# #SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4'
|
||||
|
||||
## Attribute to get account names from
|
||||
### Default: Don't control accounts with SAML attribute
|
||||
### If set, the user will be added and removed from accounts to match
|
||||
### what's in the login assertion. Accounts that don't exist will
|
||||
### be created and the user added to them.
|
||||
SAML_ATTRIBUTE_ACCOUNT = 'https://example.edu/pdns-account'
|
||||
# ## Attribute to use for username
|
||||
# ### Default: Use NameID instead
|
||||
# ### Example: urn:oid:0.9.2342.19200300.100.1.1
|
||||
# #SAML_ATTRIBUTE_USERNAME = 'urn:oid:0.9.2342.19200300.100.1.1'
|
||||
|
||||
SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
|
||||
SAML_SP_CONTACT_NAME = '<contact name>'
|
||||
SAML_SP_CONTACT_MAIL = '<contact mail>'
|
||||
#Cofigures if SAML tokens should be encrypted.
|
||||
#If enabled a new app certificate will be generated on restart
|
||||
SAML_SIGN_REQUEST = False
|
||||
#Use SAML standard logout mechanism retreived from idp metadata
|
||||
#If configured false don't care about SAML session on logout.
|
||||
#Logout from PowerDNS-Admin only and keep SAML session authenticated.
|
||||
SAML_LOGOUT = False
|
||||
#Configure to redirect to a different url then PowerDNS-Admin login after SAML logout
|
||||
#for example redirect to google.com after successful saml logout
|
||||
#SAML_LOGOUT_URL = 'https://google.com'
|
||||
# ## Attribute to get admin status from
|
||||
# ### Default: Don't control admin with SAML attribute
|
||||
# ### Example: https://example.edu/pdns-admin
|
||||
# ### If set, look for the value 'true' to set a user as an administrator
|
||||
# ### If not included in assertion, or set to something other than 'true',
|
||||
# ### the user is set as a non-administrator user.
|
||||
# #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin'
|
||||
|
||||
# ## Attribute to get account names from
|
||||
# ### Default: Don't control accounts with SAML attribute
|
||||
# ### If set, the user will be added and removed from accounts to match
|
||||
# ### what's in the login assertion. Accounts that don't exist will
|
||||
# ### be created and the user added to them.
|
||||
# SAML_ATTRIBUTE_ACCOUNT = 'https://example.edu/pdns-account'
|
||||
|
||||
# SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
|
||||
# SAML_SP_CONTACT_NAME = '<contact name>'
|
||||
# SAML_SP_CONTACT_MAIL = '<contact mail>'
|
||||
|
||||
# Configures the path to certificate file and it's respective private key file
|
||||
# This pair is used for signing metadata, encrypting tokens and all other signing/encryption
|
||||
# tasks during communication between iDP and SP
|
||||
# NOTE: if this two parameters aren't explicitly provided, self-signed certificate-key pair
|
||||
# will be generated in "PowerDNS-Admin" root directory
|
||||
# ###########################################################################################
|
||||
# CAUTION: For production use, usage of self-signed certificates it's highly discouraged.
|
||||
# Use certificates from trusted CA instead
|
||||
# ###########################################################################################
|
||||
# SAML_CERT_FILE = '/etc/pki/powerdns-admin/cert.crt'
|
||||
# SAML_CERT_KEY = '/etc/pki/powerdns-admin/key.pem'
|
||||
|
||||
# Configures if SAML tokens should be encrypted.
|
||||
# SAML_SIGN_REQUEST = False
|
||||
# #Use SAML standard logout mechanism retreived from idp metadata
|
||||
# #If configured false don't care about SAML session on logout.
|
||||
# #Logout from PowerDNS-Admin only and keep SAML session authenticated.
|
||||
# SAML_LOGOUT = False
|
||||
# #Configure to redirect to a different url then PowerDNS-Admin login after SAML logout
|
||||
# #for example redirect to google.com after successful saml logout
|
||||
# #SAML_LOGOUT_URL = 'https://google.com'
|
||||
|
||||
# #SAML_ASSERTION_ENCRYPTED = True
|
||||
|
||||
# Remote authentication settings
|
||||
|
||||
# Whether to enable remote user authentication or not
|
||||
# Defaults to False
|
||||
# REMOTE_USER_ENABLED=True
|
||||
|
||||
# If set, users will be redirected to this location on logout
|
||||
# Ignore or set to None to avoid redirecting altogether
|
||||
# Warning: if REMOTE_USER environment variable is still set after logging out and not cleared by
|
||||
# some external module, not defining a custom logout URL might trigger a loop
|
||||
# that will just log the user back in right after logging out
|
||||
# REMOTE_USER_LOGOUT_URL=https://my.sso.com/cas/logout
|
||||
|
||||
# An optional list of remote authentication tied cookies to be removed upon logout
|
||||
# REMOTE_USER_COOKIES=['MOD_AUTH_CAS', 'MOD_AUTH_CAS_S']
|
||||
|
|
115
configs/docker_config.py
Normal file
115
configs/docker_config.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
# Defaults for Docker image
|
||||
BIND_ADDRESS = '0.0.0.0'
|
||||
PORT = 80
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
|
||||
|
||||
legal_envvars = (
|
||||
'SECRET_KEY',
|
||||
'OIDC_OAUTH_API_URL',
|
||||
'OIDC_OAUTH_TOKEN_URL',
|
||||
'OIDC_OAUTH_AUTHORIZE_URL',
|
||||
'BIND_ADDRESS',
|
||||
'PORT',
|
||||
'LOG_LEVEL',
|
||||
'SALT',
|
||||
'SQLALCHEMY_TRACK_MODIFICATIONS',
|
||||
'SQLALCHEMY_DATABASE_URI',
|
||||
'MAIL_SERVER',
|
||||
'MAIL_PORT',
|
||||
'MAIL_DEBUG',
|
||||
'MAIL_USE_TLS',
|
||||
'MAIL_USE_SSL',
|
||||
'MAIL_USERNAME',
|
||||
'MAIL_PASSWORD',
|
||||
'MAIL_DEFAULT_SENDER',
|
||||
'SAML_ENABLED',
|
||||
'SAML_DEBUG',
|
||||
'SAML_PATH',
|
||||
'SAML_METADATA_URL',
|
||||
'SAML_METADATA_CACHE_LIFETIME',
|
||||
'SAML_IDP_SSO_BINDING',
|
||||
'SAML_IDP_ENTITY_ID',
|
||||
'SAML_NAMEID_FORMAT',
|
||||
'SAML_ATTRIBUTE_EMAIL',
|
||||
'SAML_ATTRIBUTE_GIVENNAME',
|
||||
'SAML_ATTRIBUTE_SURNAME',
|
||||
'SAML_ATTRIBUTE_NAME',
|
||||
'SAML_ATTRIBUTE_USERNAME',
|
||||
'SAML_ATTRIBUTE_ADMIN',
|
||||
'SAML_ATTRIBUTE_GROUP',
|
||||
'SAML_GROUP_ADMIN_NAME',
|
||||
'SAML_GROUP_TO_ACCOUNT_MAPPING',
|
||||
'SAML_ATTRIBUTE_ACCOUNT',
|
||||
'SAML_SP_ENTITY_ID',
|
||||
'SAML_SP_CONTACT_NAME',
|
||||
'SAML_SP_CONTACT_MAIL',
|
||||
'SAML_SIGN_REQUEST',
|
||||
'SAML_WANT_MESSAGE_SIGNED',
|
||||
'SAML_LOGOUT',
|
||||
'SAML_LOGOUT_URL',
|
||||
'SAML_ASSERTION_ENCRYPTED',
|
||||
'OFFLINE_MODE',
|
||||
'REMOTE_USER_LOGOUT_URL',
|
||||
'REMOTE_USER_COOKIES',
|
||||
'SIGNUP_ENABLED',
|
||||
'LOCAL_DB_ENABLED',
|
||||
'LDAP_ENABLED',
|
||||
'SAML_CERT',
|
||||
'SAML_KEY',
|
||||
'FILESYSTEM_SESSIONS_ENABLED'
|
||||
)
|
||||
|
||||
legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
|
||||
|
||||
legal_envvars_bool = (
|
||||
'SQLALCHEMY_TRACK_MODIFICATIONS',
|
||||
'HSTS_ENABLED',
|
||||
'MAIL_DEBUG',
|
||||
'MAIL_USE_TLS',
|
||||
'MAIL_USE_SSL',
|
||||
'SAML_ENABLED',
|
||||
'SAML_DEBUG',
|
||||
'SAML_SIGN_REQUEST',
|
||||
'SAML_WANT_MESSAGE_SIGNED',
|
||||
'SAML_LOGOUT',
|
||||
'SAML_ASSERTION_ENCRYPTED',
|
||||
'OFFLINE_MODE',
|
||||
'REMOTE_USER_ENABLED',
|
||||
'SIGNUP_ENABLED',
|
||||
'LOCAL_DB_ENABLED',
|
||||
'LDAP_ENABLED',
|
||||
'FILESYSTEM_SESSIONS_ENABLED'
|
||||
)
|
||||
|
||||
# import everything from environment variables
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def str2bool(v):
|
||||
return v.lower() in ("true", "yes", "1")
|
||||
|
||||
|
||||
for v in legal_envvars:
|
||||
|
||||
ret = None
|
||||
# _FILE suffix will allow to read value from file, usefull for Docker's
|
||||
# secrets feature
|
||||
if v + '_FILE' in os.environ:
|
||||
if v in os.environ:
|
||||
raise AttributeError(
|
||||
"Both {} and {} are set but are exclusive.".format(
|
||||
v, v + '_FILE'))
|
||||
with open(os.environ[v + '_FILE']) as f:
|
||||
ret = f.read()
|
||||
f.close()
|
||||
|
||||
elif v in os.environ:
|
||||
ret = os.environ[v]
|
||||
|
||||
if ret is not None:
|
||||
if v in legal_envvars_bool:
|
||||
ret = str2bool(ret)
|
||||
if v in legal_envvars_int:
|
||||
ret = int(ret)
|
||||
sys.modules[__name__].__dict__[v] = ret
|
25
configs/test.py
Normal file
25
configs/test.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
import os
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
### BASIC APP CONFIG
|
||||
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
||||
SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
|
||||
BIND_ADDRESS = '0.0.0.0'
|
||||
PORT = 9191
|
||||
HSTS_ENABLED = False
|
||||
|
||||
### DATABASE - SQLite
|
||||
TEST_DB_LOCATION = '/tmp/testing.sqlite'
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///{0}'.format(TEST_DB_LOCATION)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# SAML Authnetication
|
||||
SAML_ENABLED = False
|
||||
|
||||
# TEST SAMPLE DATA
|
||||
TEST_USER = 'test'
|
||||
TEST_USER_PASSWORD = 'test'
|
||||
TEST_ADMIN_USER = 'admin'
|
||||
TEST_ADMIN_PASSWORD = 'admin'
|
||||
TEST_USER_APIKEY = 'wewdsfewrfsfsdf'
|
||||
TEST_ADMIN_APIKEY = 'nghnbnhtghrtert'
|
34
docker-compose-test.yml
Normal file
34
docker-compose-test.yml
Normal file
|
@ -0,0 +1,34 @@
|
|||
version: "2.1"
|
||||
|
||||
services:
|
||||
powerdns-admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker-test/Dockerfile
|
||||
image: powerdns-admin-test
|
||||
container_name: powerdns-admin-test
|
||||
ports:
|
||||
- "9191:80"
|
||||
networks:
|
||||
- default
|
||||
env_file:
|
||||
- ./docker-test/env
|
||||
depends_on:
|
||||
- pdns-server
|
||||
|
||||
pdns-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker-test/Dockerfile.pdns
|
||||
image: pdns-server-test
|
||||
ports:
|
||||
- "5053:53"
|
||||
- "5053:53/udp"
|
||||
- "8081:8081"
|
||||
networks:
|
||||
- default
|
||||
env_file:
|
||||
- ./docker-test/env
|
||||
|
||||
networks:
|
||||
default:
|
|
@ -1,113 +1,18 @@
|
|||
version: "2.1"
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
powerdns-admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/PowerDNS-Admin/Dockerfile
|
||||
args:
|
||||
- ENVIRONMENT=${ENVIRONMENT}
|
||||
image: powerdns-admin
|
||||
container_name: powerdns-admin
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
app:
|
||||
image: ngoduykhanh/powerdns-admin:latest
|
||||
container_name: powerdns_admin
|
||||
ports:
|
||||
- "9191:9191"
|
||||
volumes:
|
||||
# Code
|
||||
- .:/powerdns-admin/
|
||||
- "./configs/${ENVIRONMENT}.py:/powerdns-admin/config.py"
|
||||
# Assets dir volume
|
||||
- powerdns-admin-assets:/powerdns-admin/app/static
|
||||
- powerdns-admin-assets2:/powerdns-admin/node_modules
|
||||
- powerdns-admin-assets3:/powerdns-admin/logs
|
||||
- ./app/static/custom:/powerdns-admin/app/static/custom
|
||||
- "9191:80"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 50m
|
||||
networks:
|
||||
- default
|
||||
environment:
|
||||
- ENVIRONMENT=${ENVIRONMENT}
|
||||
- PDA_DB_HOST=${PDA_DB_HOST}
|
||||
- PDA_DB_NAME=${PDA_DB_NAME}
|
||||
- PDA_DB_USER=${PDA_DB_USER}
|
||||
- PDA_DB_PASSWORD=${PDA_DB_PASSWORD}
|
||||
- PDNS_HOST=${PDNS_HOST}
|
||||
- PDNS_API_KEY=${PDNS_API_KEY}
|
||||
- FLASK_APP=/powerdns-admin/app/__init__.py
|
||||
depends_on:
|
||||
powerdns-admin-mysql:
|
||||
condition: service_healthy
|
||||
|
||||
powerdns-admin-mysql:
|
||||
image: mysql/mysql-server:5.7
|
||||
hostname: ${PDA_DB_HOST}
|
||||
container_name: powerdns-admin-mysql
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
expose:
|
||||
- 3306
|
||||
volumes:
|
||||
- powerdns-admin-mysql-data:/var/lib/mysql
|
||||
networks:
|
||||
- default
|
||||
environment:
|
||||
- MYSQL_DATABASE=${PDA_DB_NAME}
|
||||
- MYSQL_USER=${PDA_DB_USER}
|
||||
- MYSQL_PASSWORD=${PDA_DB_PASSWORD}
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
pdns-server:
|
||||
image: psitrax/powerdns
|
||||
hostname: ${PDNS_HOST}
|
||||
ports:
|
||||
- "53:53"
|
||||
- "53:53/udp"
|
||||
networks:
|
||||
- default
|
||||
command: --api=yes --api-key=${PDNS_API_KEY} --webserver-address=0.0.0.0 --webserver-allow-from=0.0.0.0/0
|
||||
environment:
|
||||
- MYSQL_HOST=${PDNS_DB_HOST}
|
||||
- MYSQL_USER=${PDNS_DB_USER}
|
||||
- MYSQL_PASS=${PDNS_DB_PASSWORD}
|
||||
- PDNS_API_KEY=${PDNS_API_KEY}
|
||||
- PDNS_WEBSERVER_ALLOW_FROM=${PDNS_WEBSERVER_ALLOW_FROM}
|
||||
depends_on:
|
||||
pdns-mysql:
|
||||
condition: service_healthy
|
||||
|
||||
pdns-mysql:
|
||||
image: mysql/mysql-server:5.7
|
||||
hostname: ${PDNS_DB_HOST}
|
||||
container_name: ${PDNS_DB_HOST}
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
expose:
|
||||
- 3306
|
||||
volumes:
|
||||
- powerdns-mysql-data:/var/lib/mysql
|
||||
networks:
|
||||
- default
|
||||
environment:
|
||||
- MYSQL_DATABASE=${PDNS_DB_NAME}
|
||||
- MYSQL_USER=${PDNS_DB_USER}
|
||||
- MYSQL_PASSWORD=${PDNS_DB_PASSWORD}
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
volumes:
|
||||
powerdns-mysql-data:
|
||||
powerdns-admin-mysql-data:
|
||||
powerdns-admin-assets:
|
||||
powerdns-admin-assets2:
|
||||
powerdns-admin-assets3:
|
||||
- SQLALCHEMY_DATABASE_URI=mysql://pda:changeme@host.docker.internal/pda
|
||||
- GUNICORN_TIMEOUT=60
|
||||
- GUNICORN_WORKERS=2
|
||||
- GUNICORN_LOGLEVEL=DEBUG
|
||||
- OFFLINE_MODE=False # True for offline, False for external resources
|
||||
|
|
34
docker-test/Dockerfile
Normal file
34
docker-test/Dockerfile
Normal file
|
@ -0,0 +1,34 @@
|
|||
FROM debian:stretch-slim
|
||||
LABEL maintainer="k@ndk.name"
|
||||
|
||||
ENV LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y --no-install-recommends apt-transport-https locales locales-all python3-pip python3-setuptools python3-dev curl libsasl2-dev libldap2-dev libssl-dev libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev build-essential libmariadb-dev-compat \
|
||||
&& curl -sL https://deb.nodesource.com/setup_10.x | bash - \
|
||||
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
|
||||
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
|
||||
&& apt-get update -y \
|
||||
&& apt-get install -y nodejs yarn \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# We copy just the requirements.txt first to leverage Docker cache
|
||||
COPY ./requirements.txt /app/requirements.txt
|
||||
|
||||
WORKDIR /app
|
||||
RUN pip3 install --upgrade pip
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
COPY . /app
|
||||
COPY ./docker/entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENV FLASK_APP=powerdnsadmin/__init__.py
|
||||
RUN yarn install --pure-lockfile --production \
|
||||
&& yarn cache clean \
|
||||
&& flask assets build
|
||||
|
||||
COPY ./docker-test/wait-for-pdns.sh /opt
|
||||
RUN chmod u+x /opt/wait-for-pdns.sh
|
||||
CMD ["/opt/wait-for-pdns.sh", "/usr/local/bin/pytest","--capture=no","-vv"]
|
13
docker-test/Dockerfile.pdns
Normal file
13
docker-test/Dockerfile.pdns
Normal file
|
@ -0,0 +1,13 @@
|
|||
FROM ubuntu:latest
|
||||
|
||||
RUN apt-get update && apt-get install -y pdns-backend-sqlite3 pdns-server sqlite3
|
||||
|
||||
COPY ./docker-test/pdns.sqlite.sql /data/pdns.sql
|
||||
ADD ./docker-test/start.sh /data/
|
||||
|
||||
RUN rm -f /etc/powerdns/pdns.d/pdns.simplebind.conf
|
||||
RUN rm -f /etc/powerdns/pdns.d/bind.conf
|
||||
|
||||
RUN chmod +x /data/start.sh && mkdir -p /var/empty/var/run
|
||||
|
||||
CMD /data/start.sh
|
5
docker-test/env
Normal file
5
docker-test/env
Normal file
|
@ -0,0 +1,5 @@
|
|||
PDNS_PROTO=http
|
||||
PDNS_PORT=8081
|
||||
PDNS_HOST=pdns-server
|
||||
PDNS_API_KEY=changeme
|
||||
PDNS_WEBSERVER_ALLOW_FROM=0.0.0.0/0
|
92
docker-test/pdns.sqlite.sql
Normal file
92
docker-test/pdns.sqlite.sql
Normal file
|
@ -0,0 +1,92 @@
|
|||
PRAGMA foreign_keys = 1;
|
||||
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL COLLATE NOCASE,
|
||||
master VARCHAR(128) DEFAULT NULL,
|
||||
last_check INTEGER DEFAULT NULL,
|
||||
type VARCHAR(6) NOT NULL,
|
||||
notified_serial INTEGER DEFAULT NULL,
|
||||
account VARCHAR(40) DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX name_index ON domains(name);
|
||||
|
||||
|
||||
CREATE TABLE records (
|
||||
id INTEGER PRIMARY KEY,
|
||||
domain_id INTEGER DEFAULT NULL,
|
||||
name VARCHAR(255) DEFAULT NULL,
|
||||
type VARCHAR(10) DEFAULT NULL,
|
||||
content VARCHAR(65535) DEFAULT NULL,
|
||||
ttl INTEGER DEFAULT NULL,
|
||||
prio INTEGER DEFAULT NULL,
|
||||
change_date INTEGER DEFAULT NULL,
|
||||
disabled BOOLEAN DEFAULT 0,
|
||||
ordername VARCHAR(255),
|
||||
auth BOOL DEFAULT 1,
|
||||
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX rec_name_index ON records(name);
|
||||
CREATE INDEX nametype_index ON records(name,type);
|
||||
CREATE INDEX domain_id ON records(domain_id);
|
||||
CREATE INDEX orderindex ON records(ordername);
|
||||
|
||||
|
||||
CREATE TABLE supermasters (
|
||||
ip VARCHAR(64) NOT NULL,
|
||||
nameserver VARCHAR(255) NOT NULL COLLATE NOCASE,
|
||||
account VARCHAR(40) NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX ip_nameserver_pk ON supermasters(ip, nameserver);
|
||||
|
||||
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
domain_id INTEGER NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
modified_at INT NOT NULL,
|
||||
account VARCHAR(40) DEFAULT NULL,
|
||||
comment VARCHAR(65535) NOT NULL,
|
||||
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX comments_domain_id_index ON comments (domain_id);
|
||||
CREATE INDEX comments_nametype_index ON comments (name, type);
|
||||
CREATE INDEX comments_order_idx ON comments (domain_id, modified_at);
|
||||
|
||||
|
||||
CREATE TABLE domainmetadata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
domain_id INT NOT NULL,
|
||||
kind VARCHAR(32) COLLATE NOCASE,
|
||||
content TEXT,
|
||||
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX domainmetaidindex ON domainmetadata(domain_id);
|
||||
|
||||
|
||||
CREATE TABLE cryptokeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
domain_id INT NOT NULL,
|
||||
flags INT NOT NULL,
|
||||
active BOOL,
|
||||
content TEXT,
|
||||
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX domainidindex ON cryptokeys(domain_id);
|
||||
|
||||
|
||||
CREATE TABLE tsigkeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(255) COLLATE NOCASE,
|
||||
algorithm VARCHAR(50) COLLATE NOCASE,
|
||||
secret VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm);
|
24
docker-test/start.sh
Normal file
24
docker-test/start.sh
Normal file
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
if [ -z ${PDNS_API_KEY+x} ]; then
|
||||
API_KEY=changeme
|
||||
fi
|
||||
|
||||
if [ -z ${PDNS_PORT+x} ]; then
|
||||
WEB_PORT=8081
|
||||
fi
|
||||
|
||||
# Import schema structure
|
||||
if [ -e "/data/pdns.sql" ]; then
|
||||
rm /data/pdns.db
|
||||
cat /data/pdns.sql | sqlite3 /data/pdns.db
|
||||
rm /data/pdns.sql
|
||||
echo "Imported schema structure"
|
||||
fi
|
||||
|
||||
chown -R pdns:pdns /data/
|
||||
|
||||
/usr/sbin/pdns_server \
|
||||
--launch=gsqlite3 --gsqlite3-database=/data/pdns.db \
|
||||
--webserver=yes --webserver-address=0.0.0.0 --webserver-port=${PDNS_PORT} \
|
||||
--api=yes --api-key=$PDNS_API_KEY --webserver-allow-from=${PDNS_WEBSERVER_ALLOW_FROM}
|
22
docker-test/wait-for-pdns.sh
Normal file
22
docker-test/wait-for-pdns.sh
Normal file
|
@ -0,0 +1,22 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
CMD="$1"
|
||||
shift
|
||||
CMD_ARGS="$@"
|
||||
|
||||
LOOPS=10
|
||||
until curl -H "X-API-Key: ${PDNS_API_KEY}" "${PDNS_PROTO}://${PDNS_HOST}:${PDNS_PORT}/api/v1/servers"; do
|
||||
>&2 echo "PDNS is unavailable - sleeping"
|
||||
sleep 1
|
||||
if [ $LOOPS -eq 10 ]
|
||||
then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 5
|
||||
|
||||
>&2 echo "PDNS is up - executing command"
|
||||
exec $CMD $CMD_ARGS
|
95
docker/Dockerfile
Normal file
95
docker/Dockerfile
Normal file
|
@ -0,0 +1,95 @@
|
|||
FROM alpine:3.13 AS builder
|
||||
LABEL maintainer="k@ndk.name"
|
||||
|
||||
ARG BUILD_DEPENDENCIES="build-base \
|
||||
libffi-dev \
|
||||
libxml2-dev \
|
||||
mariadb-connector-c-dev \
|
||||
openldap-dev \
|
||||
python3-dev \
|
||||
xmlsec-dev \
|
||||
yarn \
|
||||
cargo"
|
||||
|
||||
ENV LC_ALL=en_US.UTF-8 \
|
||||
LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US.UTF-8 \
|
||||
FLASK_APP=/build/powerdnsadmin/__init__.py
|
||||
|
||||
# Get dependencies
|
||||
# py3-pip should not belong to BUILD_DEPENDENCIES. Otherwise, when we remove
|
||||
# them with "apk del" at the end of build stage, the python requests module
|
||||
# will be removed as well - (Tested with alpine:3.12 and python 3.8.5).
|
||||
RUN apk add --no-cache ${BUILD_DEPENDENCIES} && \
|
||||
apk add --no-cache py3-pip
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# We copy just the requirements.txt first to leverage Docker cache
|
||||
COPY ./requirements.txt /build/requirements.txt
|
||||
|
||||
# Get application dependencies
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Add sources
|
||||
COPY . /build
|
||||
|
||||
# Prepare assets
|
||||
RUN yarn install --pure-lockfile --production && \
|
||||
yarn cache clean && \
|
||||
sed -i -r -e "s|'cssmin',\s?'cssrewrite'|'cssmin'|g" /build/powerdnsadmin/assets.py && \
|
||||
flask assets build
|
||||
|
||||
RUN mv /build/powerdnsadmin/static /tmp/static && \
|
||||
mkdir /build/powerdnsadmin/static && \
|
||||
cp -r /tmp/static/generated /build/powerdnsadmin/static && \
|
||||
cp -r /tmp/static/assets /build/powerdnsadmin/static && \
|
||||
cp -r /tmp/static/img /build/powerdnsadmin/static && \
|
||||
find /tmp/static/node_modules -name 'fonts' -exec cp -r {} /build/powerdnsadmin/static \; && \
|
||||
find /tmp/static/node_modules/icheck/skins/square -name '*.png' -exec cp {} /build/powerdnsadmin/static/generated \;
|
||||
|
||||
RUN { \
|
||||
echo "from flask_assets import Environment"; \
|
||||
echo "assets = Environment()"; \
|
||||
echo "assets.register('js_login', 'generated/login.js')"; \
|
||||
echo "assets.register('js_validation', 'generated/validation.js')"; \
|
||||
echo "assets.register('css_login', 'generated/login.css')"; \
|
||||
echo "assets.register('js_main', 'generated/main.js')"; \
|
||||
echo "assets.register('css_main', 'generated/main.css')"; \
|
||||
} > /build/powerdnsadmin/assets.py
|
||||
|
||||
# Move application
|
||||
RUN mkdir -p /app && \
|
||||
cp -r /build/migrations/ /build/powerdnsadmin/ /build/run.py /app && \
|
||||
mkdir -p /app/configs && \
|
||||
cp -r /build/configs/docker_config.py /app/configs
|
||||
|
||||
# Build image
|
||||
FROM alpine:3.13
|
||||
|
||||
ENV FLASK_APP=/app/powerdnsadmin/__init__.py \
|
||||
USER=pda
|
||||
|
||||
RUN apk add --no-cache mariadb-connector-c postgresql-client py3-gunicorn py3-psycopg2 xmlsec tzdata libcap && \
|
||||
addgroup -S ${USER} && \
|
||||
adduser -S -D -G ${USER} ${USER} && \
|
||||
mkdir /data && \
|
||||
chown ${USER}:${USER} /data && \
|
||||
setcap cap_net_bind_service=+ep $(readlink -f /usr/bin/python3) && \
|
||||
apk del libcap
|
||||
|
||||
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 --chown=root:${USER} /app /app/
|
||||
COPY ./docker/entrypoint.sh /usr/bin/
|
||||
|
||||
WORKDIR /app
|
||||
RUN chown ${USER}:${USER} ./configs /app && \
|
||||
cat ./powerdnsadmin/default_config.py ./configs/docker_config.py > ./powerdnsadmin/docker_config.py
|
||||
|
||||
EXPOSE 80/tcp
|
||||
USER ${USER}
|
||||
HEALTHCHECK CMD ["wget","--output-document=-","--quiet","--tries=1","http://127.0.0.1/"]
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["gunicorn","powerdnsadmin:create_app()"]
|
|
@ -1,45 +0,0 @@
|
|||
FROM ubuntu:16.04
|
||||
MAINTAINER Khanh Ngo "k@ndk.name"
|
||||
ARG ENVIRONMENT=development
|
||||
ENV ENVIRONMENT=${ENVIRONMENT}
|
||||
|
||||
WORKDIR /powerdns-admin
|
||||
|
||||
RUN apt-get update -y
|
||||
RUN apt-get install -y apt-transport-https
|
||||
|
||||
RUN apt-get install -y locales locales-all
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US.UTF-8
|
||||
|
||||
RUN apt-get install -y python3-pip python3-dev supervisor curl mysql-client
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
|
||||
|
||||
# Install yarn
|
||||
RUN apt-get update -y
|
||||
RUN apt-get install -y yarn
|
||||
|
||||
# Install Netcat for DB healthcheck
|
||||
RUN apt-get install -y netcat
|
||||
|
||||
# lib for building mysql db driver
|
||||
RUN apt-get install -y libmysqlclient-dev
|
||||
|
||||
# lib for buiding ldap and ssl-based application
|
||||
RUN apt-get install -y libsasl2-dev libldap2-dev libssl-dev
|
||||
|
||||
# lib for building python3-saml
|
||||
RUN apt-get install -y libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev pkg-config
|
||||
|
||||
COPY ./requirements.txt /powerdns-admin/requirements.txt
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
ADD ./supervisord.conf /etc/supervisord.conf
|
||||
ADD . /powerdns-admin/
|
||||
COPY ./configs/${ENVIRONMENT}.py /powerdns-admin/config.py
|
||||
COPY ./docker/PowerDNS-Admin/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
@ -1,64 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o nounset
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
|
||||
# == Vars
|
||||
#
|
||||
DB_MIGRATION_DIR='/powerdns-admin/migrations'
|
||||
|
||||
|
||||
# Wait for us to be able to connect to MySQL before proceeding
|
||||
echo "===> Waiting for $PDA_DB_HOST MySQL service"
|
||||
until nc -zv \
|
||||
$PDA_DB_HOST \
|
||||
3306;
|
||||
do
|
||||
echo "MySQL ($PDA_DB_HOST) is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
||||
echo "===> DB management"
|
||||
# Go in Workdir
|
||||
cd /powerdns-admin
|
||||
|
||||
if [ ! -d "${DB_MIGRATION_DIR}" ]; then
|
||||
echo "---> Running DB Init"
|
||||
flask db init --directory ${DB_MIGRATION_DIR}
|
||||
flask db migrate -m "Init DB" --directory ${DB_MIGRATION_DIR}
|
||||
flask db upgrade --directory ${DB_MIGRATION_DIR}
|
||||
./init_data.py
|
||||
|
||||
else
|
||||
echo "---> Running DB Migration"
|
||||
set +e
|
||||
flask db migrate -m "Upgrade BD Schema" --directory ${DB_MIGRATION_DIR}
|
||||
flask db upgrade --directory ${DB_MIGRATION_DIR}
|
||||
set -e
|
||||
fi
|
||||
|
||||
echo "===> Update PDNS API connection info"
|
||||
# initial setting if not available in the DB
|
||||
mysql -h${PDA_DB_HOST} -u${PDA_DB_USER} -p${PDA_DB_PASSWORD} ${PDA_DB_NAME} -e "INSERT INTO setting (name, value) SELECT * FROM (SELECT 'pdns_api_url', 'http://${PDNS_HOST}:8081') AS tmp WHERE NOT EXISTS (SELECT name FROM setting WHERE name = 'pdns_api_url') LIMIT 1;"
|
||||
mysql -h${PDA_DB_HOST} -u${PDA_DB_USER} -p${PDA_DB_PASSWORD} ${PDA_DB_NAME} -e "INSERT INTO setting (name, value) SELECT * FROM (SELECT 'pdns_api_key', '${PDNS_API_KEY}') AS tmp WHERE NOT EXISTS (SELECT name FROM setting WHERE name = 'pdns_api_key') LIMIT 1;"
|
||||
|
||||
# update pdns api setting if .env is changed.
|
||||
mysql -h${PDA_DB_HOST} -u${PDA_DB_USER} -p${PDA_DB_PASSWORD} ${PDA_DB_NAME} -e "UPDATE setting SET value='http://${PDNS_HOST}:8081' WHERE name='pdns_api_url';"
|
||||
mysql -h${PDA_DB_HOST} -u${PDA_DB_USER} -p${PDA_DB_PASSWORD} ${PDA_DB_NAME} -e "UPDATE setting SET value='${PDNS_API_KEY}' WHERE name='pdns_api_key';"
|
||||
|
||||
echo "===> Assets management"
|
||||
echo "---> Running Yarn"
|
||||
chown -R www-data:www-data /powerdns-admin/app/static
|
||||
chown -R www-data:www-data /powerdns-admin/node_modules
|
||||
su -s /bin/bash -c 'yarn install --pure-lockfile' www-data
|
||||
|
||||
echo "---> Running Flask assets"
|
||||
chown -R www-data:www-data /powerdns-admin/logs
|
||||
su -s /bin/bash -c 'flask assets build' www-data
|
||||
|
||||
|
||||
echo "===> Start supervisor"
|
||||
/usr/bin/supervisord -c /etc/supervisord.conf
|
17
docker/entrypoint.sh
Executable file
17
docker/entrypoint.sh
Executable file
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
cd /app
|
||||
|
||||
GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-120}"
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-4}"
|
||||
GUNICORN_LOGLEVEL="${GUNICORN_LOGLEVEL:-info}"
|
||||
BIND_ADDRESS="${BIND_ADDRESS:-0.0.0.0:80}"
|
||||
|
||||
GUNICORN_ARGS="-t ${GUNICORN_TIMEOUT} --workers ${GUNICORN_WORKERS} --bind ${BIND_ADDRESS} --log-level ${GUNICORN_LOGLEVEL}"
|
||||
if [ "$1" == gunicorn ]; then
|
||||
/bin/sh -c "flask db upgrade"
|
||||
exec "$@" $GUNICORN_ARGS
|
||||
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
134
docs/API.md
Normal file
134
docs/API.md
Normal file
|
@ -0,0 +1,134 @@
|
|||
### API Usage
|
||||
|
||||
#### Getting started with docker
|
||||
|
||||
1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification
|
||||
2. Click to register user, type e.g. user: admin and password: admin
|
||||
3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users
|
||||
4. Click on the API Keys menu then click on teh "Add Key" button to add a new Administrator Key
|
||||
5. Keep the base64 encoded apikey somewhere safe as it won't be available in clear anymore
|
||||
|
||||
|
||||
#### Accessing the API
|
||||
|
||||
The PDA API consists of two distinct parts:
|
||||
|
||||
- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
|
||||
- The /server endpoints are proxying queries to the backend PowerDNS instance's API. PDA acts as a proxy managing several API Keys and permissions to the PowerDNS content.
|
||||
|
||||
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 harmless to use it on each call
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
# Encode your user and password to base64
|
||||
$ echo -n 'admin:admin'|base64
|
||||
YWRtaW46YWRtaW4=
|
||||
# Use the ouput as your basic auth header
|
||||
curl -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X <method> <url>
|
||||
```
|
||||
|
||||
When you access the `/server` endpoint, you must use the ApiKey
|
||||
|
||||
```bash
|
||||
# Use the already base64 encoded key in your header
|
||||
curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X <method> <url>
|
||||
```
|
||||
|
||||
Finally, the `/sync_domains` endpoint accepts both basic and apikey authentication
|
||||
|
||||
#### Examples
|
||||
|
||||
Creating domain via `/powerdnsadmin`:
|
||||
|
||||
```bash
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/zones --data '{"name": "yourdomain.com.", "kind": "NATIVE", "nameservers": ["ns1.mydomain.com."]}'
|
||||
```
|
||||
|
||||
Creating an apikey which has the Administrator role:
|
||||
|
||||
```bash
|
||||
# Create the key
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}'
|
||||
```
|
||||
Example response (don't forget to save the plain key from the output)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"accounts": [],
|
||||
"description": "masterkey",
|
||||
"domains": [],
|
||||
"role": {
|
||||
"name": "Administrator",
|
||||
"id": 1
|
||||
},
|
||||
"id": 2,
|
||||
"plain_key": "aGCthP3KLAeyjZI"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type):
|
||||
|
||||
Getting powerdns configuration (Administrator Key is needed):
|
||||
|
||||
```bash
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config
|
||||
```
|
||||
|
||||
Creating and updating records:
|
||||
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com.
|
||||
```
|
||||
|
||||
Getting a domain:
|
||||
|
||||
```bash
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
|
||||
```
|
||||
|
||||
List a zone's records:
|
||||
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
|
||||
```
|
||||
|
||||
Add a new record:
|
||||
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
|
||||
```
|
||||
|
||||
Update a record:
|
||||
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
|
||||
```
|
||||
|
||||
Delete a record:
|
||||
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq
|
||||
```
|
||||
|
||||
### Generate ER diagram
|
||||
|
||||
With docker
|
||||
|
||||
```bash
|
||||
# Install build packages
|
||||
apt-get install python-dev graphviz libgraphviz-dev pkg-config
|
||||
# Get the required python libraries
|
||||
pip install graphviz mysqlclient ERAlchemy
|
||||
# Start the docker container
|
||||
docker-compose up -d
|
||||
# Set environment variables
|
||||
source .env
|
||||
# Generate the diagrams
|
||||
eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf
|
||||
```
|
99
docs/oauth.md
Normal file
99
docs/oauth.md
Normal file
|
@ -0,0 +1,99 @@
|
|||
### OAuth Authentication
|
||||
|
||||
#### Microsoft Azure
|
||||
|
||||
To link to Azure for authentication, you need to register PowerDNS-Admin in Azure. This requires your PowerDNS-Admin web interface to use an HTTPS URL.
|
||||
|
||||
* Under the Azure Active Directory, select App Registrations, and create a new one. Give it any name you want, and the Redirect URI shoule be type 'Web' and of the format https://powerdnsadmin/azure/authorized (replace the host name approriately).
|
||||
* Select the newly-created registration
|
||||
* On the Overview page, the Application ID is your new Client ID to use with PowerDNS-Admin
|
||||
* On the Overview page, make a note of your Directory/Tenant ID - you need it for the API URLs later
|
||||
* Ensure Access Tokens are enabled in the Authentication section
|
||||
* Under Certificates and Secrets, create a new Client Secret. Note this secret as it is the new Client Secret to use with PowerDNS-Admin
|
||||
* Under API Permissions, you need to add permissions. Add permissions for Graph API, Delegated. Add email, openid, profile, User.Read and possibly User.Read.All. You then need to grant admin approval for your organisation.
|
||||
|
||||
Now you can enable the OAuth in PowerDNS-Admin.
|
||||
* For the Scope, use 'User.Read openid mail profile'
|
||||
* Replace the [tenantID] in the default URLs for authorize and token with your Tenant ID.
|
||||
* Restart PowerDNS-Admin
|
||||
|
||||
This should allow you to log in using OAuth.
|
||||
|
||||
#### Keycloak
|
||||
|
||||
To link to Keycloak for authentication, you need to create a new client in the Keycloak Administration Console.
|
||||
* Log in to the Keycloak Administration Console
|
||||
* Go to Clients > Create
|
||||
* Enter a Client ID (for example 'powerdns-admin') and click 'Save'
|
||||
* Scroll down to 'Access Type' and choose 'Confidential'.
|
||||
* Scroll down to 'Valid Redirect URIs' and enter 'https://<pdnsa address>/oidc/authorized'
|
||||
* Click 'Save'
|
||||
* Go to the 'Credentials' tab and copy the Client Secret
|
||||
* Log in to PowerDNS-Admin and go to 'Settings > Authentication > OpenID Connect OAuth'
|
||||
* Enter the following details:
|
||||
* Client key -> Client ID
|
||||
* Client secret > Client secret copied from keycloak
|
||||
* Scope: `profile`
|
||||
* API URL: https://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/
|
||||
* Token URL: https://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/token
|
||||
* Authorize URL: https://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/auth
|
||||
* Logout URL: https://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/logout
|
||||
* Leave the rest default
|
||||
* Save the changes and restart PowerDNS-Admin
|
||||
* Use the new 'Sign in using OpenID Connect' button to log in.
|
||||
|
||||
#### OpenID Connect OAuth
|
||||
To link to oidc service for authenticationregister your PowerDNS-Admin in the OIDC Provider. This requires your PowerDNS-Admin web interface to use an HTTPS URL.
|
||||
|
||||
Enable OpenID Connect OAuth option.
|
||||
* Client key, The client ID
|
||||
* Scope, The scope of the data.
|
||||
* API URL, <oidc_provider_link>/auth (The ending can be different with each provider)
|
||||
* Token URL, <oidc_provider_link>/token
|
||||
* Authorize URL, <oidc_provider_link>/auth
|
||||
* Logout URL, <oidc_provider_link>/logout
|
||||
|
||||
* Username, This will be the claim that will be used as the username. (Usually preferred_username)
|
||||
* First Name, This will be the firstname of the user. (Usually given_name)
|
||||
* Last Name, This will be the lastname of the user. (Usually family_name)
|
||||
* Email, This will be the email of the user. (Usually email)
|
||||
|
||||
#### To create accounts on oidc login use the following properties:
|
||||
* Autoprovision Account Name Property, This property will set the name of the created account.
|
||||
This property can be a string or a list.
|
||||
* Autoprovision Account Description Property, This property will set the description of the created account.
|
||||
This property can be a string or a list.
|
||||
|
||||
If we get a variable named "groups" and "groups_description" from our IdP.
|
||||
This variable contains groups that the user is a part of.
|
||||
We will put the variable name "groups" in the "Name Property" and "groups_description" in the "Description Property".
|
||||
This will result in the following account being created:
|
||||
Input we get from the Idp:
|
||||
|
||||
```
|
||||
{
|
||||
"preferred_username": "example_username",
|
||||
"given_name": "example_firstame",
|
||||
"family_name": "example_lastname",
|
||||
"email": "example_email",
|
||||
"groups": ["github", "gitlab"]
|
||||
"groups_description": ["github.com", "gitlab.com"]
|
||||
}
|
||||
```
|
||||
|
||||
The user properties will be:
|
||||
```
|
||||
Username: customer_username
|
||||
First Name: customer_firstame
|
||||
Last Name: customer_lastname
|
||||
Email: customer_email
|
||||
Role: User
|
||||
```
|
||||
|
||||
The groups properties will be:
|
||||
```
|
||||
Name: github Description: github.com Members: example_username
|
||||
Name: gitlab Description: gitlab.com Members: example_username
|
||||
```
|
||||
|
||||
If the option "delete_sso_accounts" is turned on the user will only be apart of groups the IdP provided and removed from all other accoubnts.
|
24
docs/running_tests.md
Normal file
24
docs/running_tests.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
### Running tests
|
||||
|
||||
**NOTE:** Tests will create `__pycache__` folders which will be owned by root, which might be issue during rebuild
|
||||
|
||||
thus (e.g. invalid tar headers message) when such situation occurs, you need to remove those folders as root
|
||||
|
||||
1. Build images
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose-test.yml build
|
||||
```
|
||||
|
||||
2. Run tests
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose-test.yml up
|
||||
```
|
||||
|
||||
3. To teardown the test environment
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose-test.yml down
|
||||
docker-compose -f docker-compose-test.yml rm
|
||||
```
|
20
init_data.py
20
init_data.py
|
@ -1,20 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from app import db
|
||||
from app.models import Role, DomainTemplate
|
||||
|
||||
admin_role = Role(name='Administrator', description='Administrator')
|
||||
user_role = Role(name='User', description='User')
|
||||
|
||||
template_1 = DomainTemplate(name='basic_template_1', description='Basic Template #1')
|
||||
template_2 = DomainTemplate(name='basic_template_2', description='Basic Template #2')
|
||||
template_3 = DomainTemplate(name='basic_template_3', description='Basic Template #3')
|
||||
|
||||
db.session.add(admin_role)
|
||||
db.session.add(user_role)
|
||||
|
||||
db.session.add(template_1)
|
||||
db.session.add(template_2)
|
||||
db.session.add(template_3)
|
||||
|
||||
db.session.commit()
|
|
@ -73,6 +73,7 @@ def run_migrations_online():
|
|||
context.configure(connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
process_revision_directives=process_revision_directives,
|
||||
render_as_batch=config.get_main_option('sqlalchemy.url').startswith('sqlite:'),
|
||||
**current_app.extensions['migrate'].configure_args)
|
||||
|
||||
try:
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
"""add apikey account mapping table
|
||||
|
||||
Revision ID: 0967658d9c0d
|
||||
Revises: 0d3d93f1c2e0
|
||||
Create Date: 2021-11-13 22:28:46.133474
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0967658d9c0d'
|
||||
down_revision = '0d3d93f1c2e0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('apikey_account',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('apikey_id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['account.id'], ),
|
||||
sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_history_created_on'), ['created_on'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_history_created_on'))
|
||||
|
||||
op.drop_table('apikey_account')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,34 @@
|
|||
"""Add domain_id to history table
|
||||
|
||||
Revision ID: 0d3d93f1c2e0
|
||||
Revises: 3f76448bb6de
|
||||
Create Date: 2021-02-15 17:23:05.688241
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0d3d93f1c2e0'
|
||||
down_revision = '3f76448bb6de'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('domain_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_domain_id', 'domain', ['domain_id'], ['id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('fk_domain_id', type_='foreignkey')
|
||||
batch_op.drop_column('domain_id')
|
||||
|
||||
# ### end Alembic commands ###
|
29
migrations/versions/0fb6d23a4863_remove_user_avatar.py
Normal file
29
migrations/versions/0fb6d23a4863_remove_user_avatar.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
"""Remove user avatar
|
||||
|
||||
Revision ID: 0fb6d23a4863
|
||||
Revises: 654298797277
|
||||
Create Date: 2019-12-02 10:29:41.945044
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0fb6d23a4863'
|
||||
down_revision = '654298797277'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
batch_op.drop_column('avatar')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('user', sa.Column('avatar', mysql.VARCHAR(length=128), nullable=True))
|
||||
# ### end Alembic commands ###
|
|
@ -33,8 +33,9 @@ def update_data():
|
|||
)
|
||||
|
||||
def upgrade():
|
||||
# change column data type
|
||||
op.alter_column('setting', 'value', existing_type=sa.String(256), type_=sa.Text())
|
||||
with op.batch_alter_table('setting') as batch_op:
|
||||
# change column data type
|
||||
batch_op.alter_column('value', existing_type=sa.String(256), type_=sa.Text())
|
||||
# update data for new schema
|
||||
update_data()
|
||||
|
||||
|
@ -42,5 +43,6 @@ def upgrade():
|
|||
def downgrade():
|
||||
# delete added records in previous version
|
||||
op.execute("DELETE FROM setting WHERE id > 41")
|
||||
# change column data type
|
||||
op.alter_column('setting', 'value', existing_type=sa.Text(), type_=sa.String(256))
|
||||
with op.batch_alter_table('setting') as batch_op:
|
||||
# change column data type
|
||||
batch_op.alter_column('value', existing_type=sa.Text(), type_=sa.String(256))
|
||||
|
|
|
@ -23,8 +23,10 @@ def upgrade():
|
|||
# written to the DB.
|
||||
op.execute("DELETE FROM setting")
|
||||
|
||||
# drop view column since we don't need it
|
||||
op.drop_column('setting', 'view')
|
||||
with op.batch_alter_table('setting') as batch_op:
|
||||
# drop view column since we don't need it
|
||||
batch_op.drop_column('view')
|
||||
|
||||
def downgrade():
|
||||
op.add_column('setting', sa.Column('view', sa.String(length=64), nullable=True))
|
||||
with op.batch_alter_table('setting') as batch_op:
|
||||
batch_op.add_column(sa.Column('view', sa.String(length=64), nullable=True))
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
"""Add user.confirmed column
|
||||
|
||||
Revision ID: 3f76448bb6de
|
||||
Revises: b0fea72a3f20
|
||||
Create Date: 2019-12-21 17:11:36.564632
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3f76448bb6de'
|
||||
down_revision = 'b0fea72a3f20'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('confirmed', sa.Boolean(), nullable=False,
|
||||
default=False))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('user') as batch_op:
|
||||
batch_op.drop_column('confirmed')
|
|
@ -82,7 +82,7 @@ def downgrade():
|
|||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
## NOTE:
|
||||
## - Drop action does not work on sqlite3
|
||||
## - This action touchs the `setting` table which loaded in views.py
|
||||
## - This action touches the `setting` table which loaded in views.py
|
||||
## during app initlization, so the downgrade function won't work
|
||||
## unless we temporary remove importing `views` from `app/__init__.py`
|
||||
op.drop_column('setting', 'view')
|
||||
|
|
43
migrations/versions/654298797277_add_apikey_schema.py
Normal file
43
migrations/versions/654298797277_add_apikey_schema.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
"""Upgrade DB Schema
|
||||
|
||||
Revision ID: 654298797277
|
||||
Revises: 31a4ed468b18
|
||||
Create Date: 2018-12-23 22:18:01.904885
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '654298797277'
|
||||
down_revision = '31a4ed468b18'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('apikey',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('role_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('key')
|
||||
)
|
||||
op.create_table('domain_apikey',
|
||||
sa.Column('domain_id', sa.Integer(), nullable=True),
|
||||
sa.Column('apikey_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ),
|
||||
sa.ForeignKeyConstraint(['domain_id'], ['domain.id'], )
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('domain_apikey')
|
||||
op.drop_table('apikey')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,30 @@
|
|||
"""Add comment column in domain template record table
|
||||
|
||||
Revision ID: 856bb94b7040
|
||||
Revises: 0fb6d23a4863
|
||||
Create Date: 2019-12-09 17:17:46.257906
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '856bb94b7040'
|
||||
down_revision = '0fb6d23a4863'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('domain_template_record',
|
||||
sa.Column('comment', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('domain_template_record') as batch_op:
|
||||
batch_op.drop_column('comment')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,35 @@
|
|||
"""Update domain serial columns type
|
||||
|
||||
Revision ID: b0fea72a3f20
|
||||
Revises: 856bb94b7040
|
||||
Create Date: 2019-12-20 09:18:51.541569
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b0fea72a3f20'
|
||||
down_revision = '856bb94b7040'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('domain') as batch_op:
|
||||
batch_op.alter_column('serial',
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.BigInteger())
|
||||
batch_op.alter_column('notified_serial',
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.BigInteger())
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('domain') as batch_op:
|
||||
batch_op.alter_column('serial',
|
||||
existing_type=sa.BigInteger(),
|
||||
type_=sa.Integer())
|
||||
batch_op.alter_column('notified_serial',
|
||||
existing_type=sa.BigInteger(),
|
||||
type_=sa.Integer())
|
|
@ -1,10 +1,15 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"admin-lte": "2.4.3",
|
||||
"admin-lte": "2.4.9",
|
||||
"bootstrap": "^3.4.1",
|
||||
"bootstrap-datepicker": "^1.8.0",
|
||||
"bootstrap-validator": "^0.11.9",
|
||||
"datatables.net-plugins": "^1.10.19",
|
||||
"icheck": "^1.0.2",
|
||||
"jquery-slimscroll": "^1.3.8",
|
||||
"jquery-ui-dist": "^1.12.1",
|
||||
"jquery.quicksearch": "^2.4.0",
|
||||
"jtimeout": "^3.1.0",
|
||||
"multiselect": "^0.9.12"
|
||||
}
|
||||
}
|
||||
|
|
127
powerdnsadmin/__init__.py
Executable file
127
powerdnsadmin/__init__.py
Executable file
|
@ -0,0 +1,127 @@
|
|||
import os
|
||||
import logging
|
||||
from flask import Flask
|
||||
from flask_seasurf import SeaSurf
|
||||
from flask_mail import Mail
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from flask_session import Session
|
||||
|
||||
from .lib import utils
|
||||
|
||||
|
||||
def create_app(config=None):
|
||||
from . import models, routes, services
|
||||
from .assets import assets
|
||||
app = Flask(__name__)
|
||||
|
||||
# Read log level from environment variable
|
||||
log_level_name = os.environ.get('PDNS_ADMIN_LOG_LEVEL', 'WARNING')
|
||||
log_level = logging.getLevelName(log_level_name.upper())
|
||||
# Setting logger
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format=
|
||||
"[%(asctime)s] [%(filename)s:%(lineno)d] %(levelname)s - %(message)s")
|
||||
|
||||
# If we use Docker + Gunicorn, adjust the
|
||||
# log handler
|
||||
if "GUNICORN_LOGLEVEL" in os.environ:
|
||||
gunicorn_logger = logging.getLogger("gunicorn.error")
|
||||
app.logger.handlers = gunicorn_logger.handlers
|
||||
app.logger.setLevel(gunicorn_logger.level)
|
||||
|
||||
# Proxy
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||
|
||||
# CSRF protection
|
||||
csrf = SeaSurf(app)
|
||||
csrf.exempt(routes.index.dyndns_checkip)
|
||||
csrf.exempt(routes.index.dyndns_update)
|
||||
csrf.exempt(routes.index.saml_authorized)
|
||||
csrf.exempt(routes.api.api_login_create_zone)
|
||||
csrf.exempt(routes.api.api_login_delete_zone)
|
||||
csrf.exempt(routes.api.api_generate_apikey)
|
||||
csrf.exempt(routes.api.api_delete_apikey)
|
||||
csrf.exempt(routes.api.api_update_apikey)
|
||||
csrf.exempt(routes.api.api_zone_subpath_forward)
|
||||
csrf.exempt(routes.api.api_zone_forward)
|
||||
csrf.exempt(routes.api.api_create_zone)
|
||||
csrf.exempt(routes.api.api_create_account)
|
||||
csrf.exempt(routes.api.api_delete_account)
|
||||
csrf.exempt(routes.api.api_update_account)
|
||||
csrf.exempt(routes.api.api_create_user)
|
||||
csrf.exempt(routes.api.api_delete_user)
|
||||
csrf.exempt(routes.api.api_update_user)
|
||||
csrf.exempt(routes.api.api_list_account_users)
|
||||
csrf.exempt(routes.api.api_add_account_user)
|
||||
csrf.exempt(routes.api.api_remove_account_user)
|
||||
csrf.exempt(routes.api.api_zone_cryptokeys)
|
||||
csrf.exempt(routes.api.api_zone_cryptokey)
|
||||
|
||||
# Load config from env variables if using docker
|
||||
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):
|
||||
app.config.from_object('powerdnsadmin.docker_config')
|
||||
else:
|
||||
# Load default configuration
|
||||
app.config.from_object('powerdnsadmin.default_config')
|
||||
|
||||
# Load config file from FLASK_CONF env variable
|
||||
if 'FLASK_CONF' in os.environ:
|
||||
app.config.from_envvar('FLASK_CONF')
|
||||
|
||||
# Load app sepecified configuration
|
||||
if config is not None:
|
||||
if isinstance(config, dict):
|
||||
app.config.update(config)
|
||||
elif config.endswith('.py'):
|
||||
app.config.from_pyfile(config)
|
||||
|
||||
# HSTS
|
||||
if app.config.get('HSTS_ENABLED'):
|
||||
from flask_sslify import SSLify
|
||||
_sslify = SSLify(app) # lgtm [py/unused-local-variable]
|
||||
|
||||
# Load Flask-Session
|
||||
if app.config.get('FILESYSTEM_SESSIONS_ENABLED'):
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
sess = Session()
|
||||
sess.init_app(app)
|
||||
|
||||
# SMTP
|
||||
app.mail = Mail(app)
|
||||
|
||||
# Load app's components
|
||||
assets.init_app(app)
|
||||
models.init_app(app)
|
||||
routes.init_app(app)
|
||||
services.init_app(app)
|
||||
|
||||
# Register filters
|
||||
app.jinja_env.filters['display_record_name'] = utils.display_record_name
|
||||
app.jinja_env.filters['display_master_name'] = utils.display_master_name
|
||||
app.jinja_env.filters['display_second_to_time'] = utils.display_time
|
||||
app.jinja_env.filters[
|
||||
'email_to_gravatar_url'] = utils.email_to_gravatar_url
|
||||
app.jinja_env.filters[
|
||||
'display_setting_state'] = utils.display_setting_state
|
||||
app.jinja_env.filters['pretty_domain_name'] = utils.pretty_domain_name
|
||||
|
||||
# Register context proccessors
|
||||
from .models.setting import Setting
|
||||
|
||||
@app.context_processor
|
||||
def inject_sitename():
|
||||
setting = Setting().get('site_name')
|
||||
return dict(SITE_NAME=setting)
|
||||
|
||||
@app.context_processor
|
||||
def inject_setting():
|
||||
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
|
72
powerdnsadmin/assets.py
Normal file
72
powerdnsadmin/assets.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
from flask_assets import Bundle, Environment, Filter
|
||||
|
||||
|
||||
class ConcatFilter(Filter):
|
||||
"""
|
||||
Filter that merges files, placing a semicolon between them.
|
||||
|
||||
Fixes issues caused by missing semicolons at end of JS assets, for example
|
||||
with last statement of jquery.pjax.js.
|
||||
"""
|
||||
def concat(self, out, hunks, **kw):
|
||||
out.write(';'.join([h.data() for h, info in hunks]))
|
||||
|
||||
|
||||
css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css',
|
||||
'node_modules/font-awesome/css/font-awesome.css',
|
||||
'node_modules/ionicons/dist/css/ionicons.css',
|
||||
'node_modules/icheck/skins/square/blue.css',
|
||||
'node_modules/admin-lte/dist/css/AdminLTE.css',
|
||||
filters=('cssmin', 'cssrewrite'),
|
||||
output='generated/login.css')
|
||||
|
||||
js_login = Bundle('node_modules/jquery/dist/jquery.js',
|
||||
'node_modules/bootstrap/dist/js/bootstrap.js',
|
||||
'node_modules/icheck/icheck.js',
|
||||
'custom/js/custom.js',
|
||||
filters=(ConcatFilter, 'jsmin'),
|
||||
output='generated/login.js')
|
||||
|
||||
js_validation = Bundle('node_modules/bootstrap-validator/dist/validator.js',
|
||||
output='generated/validation.js')
|
||||
|
||||
css_main = Bundle(
|
||||
'node_modules/bootstrap/dist/css/bootstrap.css',
|
||||
'node_modules/font-awesome/css/font-awesome.css',
|
||||
'node_modules/ionicons/dist/css/ionicons.css',
|
||||
'node_modules/datatables.net-bs/css/dataTables.bootstrap.css',
|
||||
'node_modules/icheck/skins/square/blue.css',
|
||||
'node_modules/multiselect/css/multi-select.css',
|
||||
'node_modules/admin-lte/dist/css/AdminLTE.css',
|
||||
'node_modules/admin-lte/dist/css/skins/_all-skins.css',
|
||||
'custom/css/custom.css',
|
||||
'node_modules/bootstrap-datepicker/dist/css/bootstrap-datepicker.css',
|
||||
filters=('cssmin', 'cssrewrite'),
|
||||
output='generated/main.css')
|
||||
|
||||
js_main = Bundle('node_modules/jquery/dist/jquery.js',
|
||||
'node_modules/jquery-ui-dist/jquery-ui.js',
|
||||
'node_modules/bootstrap/dist/js/bootstrap.js',
|
||||
'node_modules/datatables.net/js/jquery.dataTables.js',
|
||||
'node_modules/datatables.net-bs/js/dataTables.bootstrap.js',
|
||||
'node_modules/jquery-sparkline/jquery.sparkline.js',
|
||||
'node_modules/jquery-slimscroll/jquery.slimscroll.js',
|
||||
'node_modules/icheck/icheck.js',
|
||||
'node_modules/fastclick/lib/fastclick.js',
|
||||
'node_modules/moment/moment.js',
|
||||
'node_modules/admin-lte/dist/js/adminlte.js',
|
||||
'node_modules/multiselect/js/jquery.multi-select.js',
|
||||
'node_modules/datatables.net-plugins/sorting/natural.js',
|
||||
'node_modules/jtimeout/src/jTimeout.js',
|
||||
'node_modules/jquery.quicksearch/src/jquery.quicksearch.js',
|
||||
'custom/js/custom.js',
|
||||
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
|
||||
filters=(ConcatFilter, 'jsmin'),
|
||||
output='generated/main.js')
|
||||
|
||||
assets = Environment()
|
||||
assets.register('js_login', js_login)
|
||||
assets.register('js_validation', js_validation)
|
||||
assets.register('css_login', css_login)
|
||||
assets.register('js_main', js_main)
|
||||
assets.register('css_main', css_main)
|
425
powerdnsadmin/decorators.py
Normal file
425
powerdnsadmin/decorators.py
Normal file
|
@ -0,0 +1,425 @@
|
|||
import base64
|
||||
import binascii
|
||||
from functools import wraps
|
||||
from flask import g, request, abort, current_app, render_template
|
||||
from flask_login import current_user
|
||||
|
||||
from .models import User, ApiKey, Setting, Domain, Setting
|
||||
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges
|
||||
from .lib.errors import DomainAccessForbidden
|
||||
|
||||
def admin_role_required(f):
|
||||
"""
|
||||
Grant access if user is in Administrator role
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name != 'Administrator':
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def operator_role_required(f):
|
||||
"""
|
||||
Grant access if user is in Operator role or higher
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def history_access_required(f):
|
||||
"""
|
||||
Grant access if user is in Operator role or higher, or Users can view history
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_view_history'):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_access_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- user is in granted Account, or
|
||||
- user is in granted Domain
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||
domain_name = kwargs.get('domain_name')
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
|
||||
if not domain:
|
||||
abort(404)
|
||||
|
||||
valid_access = Domain(id=domain.id).is_valid_access(
|
||||
current_user.id)
|
||||
|
||||
if not valid_access:
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_configure_dnssec(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- dnssec_admins_only is off
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and Setting().get('dnssec_admins_only'):
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
def can_remove_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_remove_domain is on
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_remove_domain'):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
|
||||
def can_create_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_create_domain is on
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_create_domain'):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def api_basic_auth(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
auth_header = auth_header.replace('Basic ', '', 1)
|
||||
|
||||
try:
|
||||
auth_header = str(base64.b64decode(auth_header), 'utf-8')
|
||||
username, password = auth_header.split(":")
|
||||
except binascii.Error as e:
|
||||
current_app.logger.error(
|
||||
'Invalid base64-encoded of credential. Error {0}'.format(
|
||||
e))
|
||||
abort(401)
|
||||
except TypeError as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
abort(401)
|
||||
|
||||
user = User(username=username,
|
||||
password=password,
|
||||
plain_text_password=password)
|
||||
|
||||
try:
|
||||
if Setting().get('verify_user_email') and user.email and not user.confirmed:
|
||||
current_app.logger.warning(
|
||||
'Basic authentication failed for user {} because of unverified email address'
|
||||
.format(username))
|
||||
abort(401)
|
||||
|
||||
auth_method = request.args.get('auth_method', 'LOCAL')
|
||||
auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL'
|
||||
auth = user.is_validate(method=auth_method,
|
||||
src_ip=request.remote_addr)
|
||||
|
||||
if not auth:
|
||||
current_app.logger.error('Checking user password failed')
|
||||
abort(401)
|
||||
else:
|
||||
user = User.query.filter(User.username == username).first()
|
||||
current_user = user # lgtm [py/unused-local-variable]
|
||||
except Exception as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
abort(401)
|
||||
else:
|
||||
current_app.logger.error('Error: Authorization header missing!')
|
||||
abort(401)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_json(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if request.method in ['POST', 'PUT', 'PATCH']:
|
||||
if not request.is_json:
|
||||
raise RequestIsNotJSON()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]):
|
||||
"""
|
||||
If request body contains one or more of specified keys, call
|
||||
:param callback
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
check_current_http_method = not http_methods or request.method in http_methods
|
||||
if (check_current_http_method and
|
||||
set(request.get_json(force=True).keys()).intersection(set(keys))
|
||||
):
|
||||
callback(*args, **kwargs)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def api_role_can(action, roles=None, allow_self=False):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in the permitted roles
|
||||
- allow_self and kwargs['user_id'] = current_user.id
|
||||
- allow_self and kwargs['username'] = current_user.username
|
||||
"""
|
||||
if roles is None:
|
||||
roles = ['Administrator', 'Operator']
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
try:
|
||||
user_id = int(kwargs.get('user_id'))
|
||||
except:
|
||||
user_id = None
|
||||
try:
|
||||
username = kwargs.get('username')
|
||||
except:
|
||||
username = None
|
||||
if (
|
||||
(current_user.role.name in roles) or
|
||||
(allow_self and user_id and current_user.id == user_id) or
|
||||
(allow_self and username and current_user.username == username)
|
||||
):
|
||||
return f(*args, **kwargs)
|
||||
msg = (
|
||||
"User {} with role {} does not have enough privileges to {}"
|
||||
).format(current_user.username, current_user.role.name, action)
|
||||
raise NotEnoughPrivileges(message=msg)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def api_can_create_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_create_domain is on
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_create_domain'):
|
||||
msg = "User {0} does not have enough privileges to create domain"
|
||||
current_app.logger.error(msg.format(current_user.username))
|
||||
raise NotEnoughPrivileges()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_create_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_create_domain is on
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.apikey.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_create_domain'):
|
||||
msg = "ApiKey #{0} does not have enough privileges to create domain"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise NotEnoughPrivileges()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_remove_domain(http_methods=[]):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_remove_domain is on
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
check_current_http_method = not http_methods or request.method in http_methods
|
||||
|
||||
if (check_current_http_method and
|
||||
g.apikey.role.name not in ['Administrator', 'Operator'] and
|
||||
not Setting().get('allow_user_remove_domain')
|
||||
):
|
||||
msg = "ApiKey #{0} does not have enough privileges to remove domain"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise NotEnoughPrivileges()
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def apikey_is_admin(f):
|
||||
"""
|
||||
Grant access if user is in Administrator role
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.apikey.role.name != 'Administrator':
|
||||
msg = "Apikey {0} does not have enough privileges to create domain"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise NotEnoughPrivileges()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_access_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user has Operator role or higher, or
|
||||
- user has explicitly been granted access to domain
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.apikey.role.name not in ['Administrator', 'Operator']:
|
||||
zone_id = kwargs.get('zone_id').rstrip(".")
|
||||
domain_names = [item.name for item in g.apikey.domains]
|
||||
|
||||
accounts = g.apikey.accounts
|
||||
accounts_domains = [domain.name for a in accounts for domain in a.domains]
|
||||
|
||||
allowed_domains = set(domain_names + accounts_domains)
|
||||
|
||||
if zone_id not in allowed_domains:
|
||||
raise DomainAccessForbidden()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_configure_dnssec(http_methods=[]):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- dnssec_admins_only is off
|
||||
"""
|
||||
def decorator(f=None):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
check_current_http_method = not http_methods or request.method in http_methods
|
||||
|
||||
if (check_current_http_method and
|
||||
g.apikey.role.name not in ['Administrator', 'Operator'] and
|
||||
Setting().get('dnssec_admins_only')
|
||||
):
|
||||
msg = "ApiKey #{0} does not have enough privileges to configure dnssec"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise DomainAccessForbidden(message=msg)
|
||||
return f(*args, **kwargs) if f else None
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def apikey_auth(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('X-API-KEY')
|
||||
if auth_header:
|
||||
try:
|
||||
apikey_val = str(base64.b64decode(auth_header), 'utf-8')
|
||||
except binascii.Error as e:
|
||||
current_app.logger.error(
|
||||
'Invalid base64-encoded of credential. Error {0}'.format(
|
||||
e))
|
||||
abort(401)
|
||||
except TypeError as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
abort(401)
|
||||
|
||||
apikey = ApiKey(key=apikey_val)
|
||||
apikey.plain_text_password = apikey_val
|
||||
|
||||
try:
|
||||
auth_method = 'LOCAL'
|
||||
auth = apikey.is_validate(method=auth_method,
|
||||
src_ip=request.remote_addr)
|
||||
|
||||
g.apikey = auth
|
||||
except Exception as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
abort(401)
|
||||
else:
|
||||
current_app.logger.error('Error: API key header missing!')
|
||||
abort(401)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def dyndns_login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.is_authenticated is False:
|
||||
return render_template('dyndns.html', response='badauth'), 200
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
def apikey_or_basic_auth(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
api_auth_header = request.headers.get('X-API-KEY')
|
||||
if api_auth_header:
|
||||
return apikey_auth(f)(*args, **kwargs)
|
||||
else:
|
||||
return api_basic_auth(f)(*args, **kwargs)
|
||||
return decorated_function
|
34
powerdnsadmin/default_config.py
Normal file
34
powerdnsadmin/default_config.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
import os
|
||||
import urllib.parse
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
### BASIC APP CONFIG
|
||||
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
||||
SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2'
|
||||
BIND_ADDRESS = '0.0.0.0'
|
||||
PORT = 9191
|
||||
HSTS_ENABLED = False
|
||||
OFFLINE_MODE = False
|
||||
FILESYSTEM_SESSIONS_ENABLED = 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
|
||||
|
||||
### DATABASE - MySQL
|
||||
SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format(
|
||||
urllib.parse.quote_plus(SQLA_DB_USER),
|
||||
urllib.parse.quote_plus(SQLA_DB_PASSWORD),
|
||||
SQLA_DB_HOST,
|
||||
SQLA_DB_NAME
|
||||
)
|
||||
|
||||
### DATABASE - SQLite
|
||||
# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
|
||||
|
||||
# SAML Authnetication
|
||||
SAML_ENABLED = False
|
||||
SAML_ASSERTION_ENCRYPTED = True
|
|
@ -42,7 +42,7 @@ def create_self_signed_cert():
|
|||
cert.set_pubkey(k)
|
||||
cert.sign(k, 'sha256')
|
||||
|
||||
open(CERT_FILE, "wt").write(
|
||||
open(CERT_FILE, "bw").write(
|
||||
crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
|
||||
open(KEY_FILE, "wt").write(
|
||||
open(KEY_FILE, "bw").write(
|
||||
crypto.dump_privatekey(crypto.FILETYPE_PEM, k))
|
173
powerdnsadmin/lib/errors.py
Normal file
173
powerdnsadmin/lib/errors.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
class StructuredException(Exception):
|
||||
status_code = 0
|
||||
|
||||
def __init__(self, name=None, message="You want override this error!"):
|
||||
Exception.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
def to_dict(self):
|
||||
rv = dict()
|
||||
msg = ''
|
||||
if self.name:
|
||||
msg = '{0} {1}'.format(self.message, self.name)
|
||||
else:
|
||||
msg = self.message
|
||||
|
||||
rv['msg'] = msg
|
||||
return rv
|
||||
|
||||
|
||||
class DomainNotExists(StructuredException):
|
||||
status_code = 404
|
||||
|
||||
def __init__(self, name=None, message="Domain does not exist"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class DomainAlreadyExists(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Domain already exists"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class DomainAccessForbidden(StructuredException):
|
||||
status_code = 403
|
||||
|
||||
def __init__(self, name=None, message="Domain access not allowed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class ApiKeyCreateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Creation of api key failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class ApiKeyNotUsable(StructuredException):
|
||||
status_code = 400
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name=None,
|
||||
message=("Api key must have domains or accounts"
|
||||
" or an administrative role")):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class NotEnoughPrivileges(StructuredException):
|
||||
status_code = 401
|
||||
|
||||
def __init__(self, name=None, message="Not enough privileges"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class RequestIsNotJSON(StructuredException):
|
||||
status_code = 400
|
||||
|
||||
def __init__(self, name=None, message="Request is not json"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class AccountCreateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Creation of account failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class AccountCreateDuplicate(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Creation of account failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class AccountUpdateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Update of account failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class AccountDeleteFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Delete of account failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class AccountNotExists(StructuredException):
|
||||
status_code = 404
|
||||
|
||||
def __init__(self, name=None, message="Account does not exist"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class UserCreateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Creation of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class UserCreateDuplicate(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Creation of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
class UserUpdateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Update of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
class UserUpdateFailEmail(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Update of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class UserDeleteFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, name=None, message="Delete of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
39
powerdnsadmin/lib/helper.py
Normal file
39
powerdnsadmin/lib/helper.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
import requests
|
||||
from urllib.parse import urljoin
|
||||
from flask import request, current_app
|
||||
|
||||
from ..models import Setting
|
||||
|
||||
|
||||
def forward_request():
|
||||
pdns_api_url = Setting().get('pdns_api_url')
|
||||
pdns_api_key = Setting().get('pdns_api_key')
|
||||
headers = {}
|
||||
data = None
|
||||
|
||||
msg_str = "Sending request to powerdns API {0}"
|
||||
|
||||
if request.method != 'GET' and request.method != 'DELETE':
|
||||
msg = msg_str.format(request.get_json(force=True))
|
||||
current_app.logger.debug(msg)
|
||||
data = request.get_json(force=True)
|
||||
|
||||
verify = False
|
||||
|
||||
headers = {
|
||||
'user-agent': 'powerdns-admin/api',
|
||||
'pragma': 'no-cache',
|
||||
'cache-control': 'no-cache',
|
||||
'accept': 'application/json; q=1',
|
||||
'X-API-KEY': pdns_api_key
|
||||
}
|
||||
|
||||
url = urljoin(pdns_api_url, request.full_path)
|
||||
|
||||
resp = requests.request(request.method,
|
||||
url,
|
||||
headers=headers,
|
||||
verify=verify,
|
||||
json=data)
|
||||
|
||||
return resp
|
66
powerdnsadmin/lib/schema.py
Normal file
66
powerdnsadmin/lib/schema.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
from lima import fields, Schema
|
||||
|
||||
|
||||
class DomainSchema(Schema):
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
|
||||
|
||||
class RoleSchema(Schema):
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
|
||||
|
||||
class AccountSummarySchema(Schema):
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
|
||||
class ApiKeySummarySchema(Schema):
|
||||
id = fields.Integer()
|
||||
description = fields.String()
|
||||
|
||||
|
||||
class ApiKeySchema(Schema):
|
||||
id = fields.Integer()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
|
||||
description = fields.String()
|
||||
key = fields.String()
|
||||
|
||||
|
||||
class ApiPlainKeySchema(Schema):
|
||||
id = fields.Integer()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
|
||||
description = fields.String()
|
||||
plain_key = fields.String()
|
||||
|
||||
|
||||
class UserSchema(Schema):
|
||||
id = fields.Integer()
|
||||
username = fields.String()
|
||||
firstname = fields.String()
|
||||
lastname = fields.String()
|
||||
email = fields.String()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
|
||||
class UserDetailedSchema(Schema):
|
||||
id = fields.Integer()
|
||||
username = fields.String()
|
||||
firstname = fields.String()
|
||||
lastname = fields.String()
|
||||
email = fields.String()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
|
||||
|
||||
class AccountSchema(Schema):
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
description = fields.String()
|
||||
contact = fields.String()
|
||||
mail = fields.String()
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True)
|
257
powerdnsadmin/lib/utils.py
Normal file
257
powerdnsadmin/lib/utils.py
Normal file
|
@ -0,0 +1,257 @@
|
|||
import logging
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
import hashlib
|
||||
import ipaddress
|
||||
|
||||
from collections.abc import Iterable
|
||||
from distutils.version import StrictVersion
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def auth_from_url(url):
|
||||
auth = None
|
||||
parsed_url = urlparse(url).netloc
|
||||
if '@' in parsed_url:
|
||||
auth = parsed_url.split('@')[0].split(':')
|
||||
auth = requests.auth.HTTPBasicAuth(auth[0], auth[1])
|
||||
return auth
|
||||
|
||||
|
||||
def fetch_remote(remote_url,
|
||||
method='GET',
|
||||
data=None,
|
||||
accept=None,
|
||||
params=None,
|
||||
timeout=None,
|
||||
headers=None,
|
||||
verify=True):
|
||||
if data is not None and type(data) != str:
|
||||
data = json.dumps(data)
|
||||
|
||||
verify = bool(verify) # enforce type boolean
|
||||
|
||||
our_headers = {
|
||||
'user-agent': 'powerdnsadmin/0',
|
||||
'pragma': 'no-cache',
|
||||
'cache-control': 'no-cache'
|
||||
}
|
||||
if accept is not None:
|
||||
our_headers['accept'] = accept
|
||||
if headers is not None:
|
||||
our_headers.update(headers)
|
||||
|
||||
r = requests.request(method,
|
||||
remote_url,
|
||||
headers=headers,
|
||||
verify=verify,
|
||||
auth=auth_from_url(remote_url),
|
||||
timeout=timeout,
|
||||
data=data,
|
||||
params=params)
|
||||
logging.debug(
|
||||
'Querying remote server "{0}" ({1}) finished with code {2} (took {3}s)'
|
||||
.format(remote_url, method, r.status_code, r.elapsed.total_seconds()))
|
||||
try:
|
||||
if r.status_code not in (200, 201, 204, 400, 409, 422):
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
msg = "Returned status {0} and content {1}".format(r.status_code, r.text)
|
||||
raise RuntimeError('Error while fetching {0}. {1}'.format(
|
||||
remote_url, msg))
|
||||
|
||||
return r
|
||||
|
||||
|
||||
def fetch_json(remote_url,
|
||||
method='GET',
|
||||
data=None,
|
||||
params=None,
|
||||
headers=None,
|
||||
timeout=None,
|
||||
verify=True):
|
||||
r = fetch_remote(remote_url,
|
||||
method=method,
|
||||
data=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
verify=verify,
|
||||
accept='application/json; q=1')
|
||||
|
||||
if method == "DELETE":
|
||||
return True
|
||||
|
||||
if r.status_code == 204:
|
||||
return {}
|
||||
elif r.status_code == 409:
|
||||
return {
|
||||
'error': 'Resource already exists or conflict',
|
||||
'http_code': r.status_code
|
||||
}
|
||||
|
||||
try:
|
||||
assert ('json' in r.headers['content-type'])
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
'Error while fetching {0}'.format(remote_url)) from e
|
||||
|
||||
# don't use r.json here, as it will read from r.text, which will trigger
|
||||
# content encoding auto-detection in almost all cases, WHICH IS EXTREMELY
|
||||
# SLOOOOOOOOOOOOOOOOOOOOOOW. just don't.
|
||||
data = None
|
||||
try:
|
||||
data = json.loads(r.content.decode('utf-8'))
|
||||
except UnicodeDecodeError:
|
||||
# If the decoding fails, switch to slower but probably working .json()
|
||||
try:
|
||||
logging.warning("UTF-8 content.decode failed, switching to slower .json method")
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
'Error while loading JSON data from {0}'.format(remote_url)) from e
|
||||
return data
|
||||
|
||||
|
||||
def display_record_name(data):
|
||||
record_name, domain_name = data
|
||||
if record_name == domain_name:
|
||||
return '@'
|
||||
else:
|
||||
return re.sub('\.{}$'.format(domain_name), '', record_name)
|
||||
|
||||
|
||||
def display_master_name(data):
|
||||
"""
|
||||
input data: "[u'127.0.0.1', u'8.8.8.8']"
|
||||
"""
|
||||
matches = re.findall(r'\'(.+?)\'', data)
|
||||
return ", ".join(matches)
|
||||
|
||||
|
||||
def display_time(amount, units='s', remove_seconds=True):
|
||||
"""
|
||||
Convert timestamp to normal time format
|
||||
"""
|
||||
amount = int(amount)
|
||||
INTERVALS = [(lambda mlsec: divmod(mlsec, 1000), 'ms'),
|
||||
(lambda seconds: divmod(seconds, 60), 's'),
|
||||
(lambda minutes: divmod(minutes, 60), 'm'),
|
||||
(lambda hours: divmod(hours, 24), 'h'),
|
||||
(lambda days: divmod(days, 7), 'D'),
|
||||
(lambda weeks: divmod(weeks, 4), 'W'),
|
||||
(lambda years: divmod(years, 12), 'M'),
|
||||
(lambda decades: divmod(decades, 10), 'Y')]
|
||||
|
||||
for index_start, (interval, unit) in enumerate(INTERVALS):
|
||||
if unit == units:
|
||||
break
|
||||
|
||||
amount_abrev = []
|
||||
last_index = 0
|
||||
amount_temp = amount
|
||||
for index, (formula,
|
||||
abrev) in enumerate(INTERVALS[index_start:len(INTERVALS)]):
|
||||
divmod_result = formula(amount_temp)
|
||||
amount_temp = divmod_result[0]
|
||||
amount_abrev.append((divmod_result[1], abrev))
|
||||
if divmod_result[1] > 0:
|
||||
last_index = index
|
||||
amount_abrev_partial = amount_abrev[0:last_index + 1]
|
||||
amount_abrev_partial.reverse()
|
||||
|
||||
final_string = ''
|
||||
for amount, abrev in amount_abrev_partial:
|
||||
final_string += str(amount) + abrev + ' '
|
||||
|
||||
if remove_seconds and 'm' in final_string:
|
||||
final_string = final_string[:final_string.rfind(' ')]
|
||||
return final_string[:final_string.rfind(' ')]
|
||||
|
||||
return final_string
|
||||
|
||||
|
||||
def pdns_api_extended_uri(version):
|
||||
"""
|
||||
Check the pdns version
|
||||
"""
|
||||
if StrictVersion(version) >= StrictVersion('4.0.0'):
|
||||
return "/api/v1"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def email_to_gravatar_url(email="", size=100):
|
||||
"""
|
||||
AD doesn't necessarily have email
|
||||
"""
|
||||
if email is None:
|
||||
email = ""
|
||||
|
||||
hash_string = hashlib.md5(email.encode('utf-8')).hexdigest()
|
||||
return "https://s.gravatar.com/avatar/{0}?s={1}".format(hash_string, size)
|
||||
|
||||
|
||||
def display_setting_state(value):
|
||||
if value == 1:
|
||||
return "ON"
|
||||
elif value == 0:
|
||||
return "OFF"
|
||||
else:
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def validate_ipaddress(address):
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if isinstance(ip, (ipaddress.IPv4Address, ipaddress.IPv6Address)):
|
||||
return [ip]
|
||||
return []
|
||||
|
||||
|
||||
def pretty_json(data):
|
||||
return json.dumps(data, sort_keys=True, indent=4)
|
||||
|
||||
|
||||
def ensure_list(l):
|
||||
if not l:
|
||||
l = []
|
||||
elif not isinstance(l, Iterable) or isinstance(l, str):
|
||||
l = [l]
|
||||
|
||||
yield from l
|
||||
|
||||
|
||||
class customBoxes:
|
||||
boxes = {
|
||||
"reverse": (" ", " "),
|
||||
"ip6arpa": ("ip6", "%.ip6.arpa"),
|
||||
"inaddrarpa": ("in-addr", "%.in-addr.arpa")
|
||||
}
|
||||
order = ["reverse", "ip6arpa", "inaddrarpa"]
|
||||
|
||||
def pretty_domain_name(value):
|
||||
"""
|
||||
Display domain name in original format.
|
||||
If it is IDN domain (Punycode starts with xn--), do the
|
||||
idna decoding.
|
||||
Note that any part of the domain name can be individually punycoded
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if value.startswith('xn--') \
|
||||
or value.find('.xn--') != -1:
|
||||
try:
|
||||
return value.encode().decode('idna')
|
||||
except:
|
||||
raise Exception("Cannot decode IDN domain")
|
||||
else:
|
||||
return value
|
||||
else:
|
||||
raise Exception("Require the Punycode in string format")
|
32
powerdnsadmin/lib/validators.py
Normal file
32
powerdnsadmin/lib/validators.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import os
|
||||
from bravado_core.spec import Spec
|
||||
from bravado_core.validate import validate_object
|
||||
from yaml import load, Loader
|
||||
|
||||
|
||||
def validate_zone(zone):
|
||||
validate_object(spec, zone_spec, zone)
|
||||
|
||||
|
||||
def validate_apikey(apikey):
|
||||
validate_object(spec, apikey_spec, apikey)
|
||||
|
||||
|
||||
def get_swagger_spec(spec_path):
|
||||
with open(spec_path, 'r') as spec:
|
||||
return load(spec.read(), Loader)
|
||||
|
||||
|
||||
bravado_config = {
|
||||
'validate_swagger_spec': False,
|
||||
'validate_requests': False,
|
||||
'validate_responses': False,
|
||||
'use_models': True,
|
||||
}
|
||||
|
||||
dir_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
spec_path = os.path.join(dir_path, "swagger-spec.yaml")
|
||||
spec_dict = get_swagger_spec(spec_path)
|
||||
spec = Spec.from_dict(spec_dict, config=bravado_config)
|
||||
zone_spec = spec_dict['definitions']['Zone']
|
||||
apikey_spec = spec_dict['definitions']['ApiKey']
|
24
powerdnsadmin/models/__init__.py
Normal file
24
powerdnsadmin/models/__init__.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from flask_migrate import Migrate
|
||||
|
||||
from .base import db
|
||||
from .user import User
|
||||
from .role import Role
|
||||
from .account import Account
|
||||
from .account_user import AccountUser
|
||||
from .server import Server
|
||||
from .history import History
|
||||
from .api_key import ApiKey
|
||||
from .api_key_account import ApiKeyAccount
|
||||
from .setting import Setting
|
||||
from .domain import Domain
|
||||
from .domain_setting import DomainSetting
|
||||
from .domain_user import DomainUser
|
||||
from .domain_template import DomainTemplate
|
||||
from .domain_template_record import DomainTemplateRecord
|
||||
from .record import Record
|
||||
from .record_entry import RecordEntry
|
||||
|
||||
|
||||
def init_app(app):
|
||||
db.init_app(app)
|
||||
_migrate = Migrate(app, db) # lgtm [py/unused-local-variable]
|
272
powerdnsadmin/models/account.py
Normal file
272
powerdnsadmin/models/account.py
Normal file
|
@ -0,0 +1,272 @@
|
|||
import traceback
|
||||
from flask import current_app
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from ..lib import utils
|
||||
from .base import db
|
||||
from .setting import Setting
|
||||
from .user import User
|
||||
from .account_user import AccountUser
|
||||
|
||||
|
||||
class Account(db.Model):
|
||||
__tablename__ = 'account'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(40), index=True, unique=True, nullable=False)
|
||||
description = db.Column(db.String(128))
|
||||
contact = db.Column(db.String(128))
|
||||
mail = db.Column(db.String(128))
|
||||
domains = db.relationship("Domain", back_populates="account")
|
||||
apikeys = db.relationship("ApiKey",
|
||||
secondary="apikey_account",
|
||||
back_populates="accounts")
|
||||
|
||||
def __init__(self, name=None, description=None, contact=None, mail=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.contact = contact
|
||||
self.mail = mail
|
||||
|
||||
# PDNS configs
|
||||
self.PDNS_STATS_URL = Setting().get('pdns_api_url')
|
||||
self.PDNS_API_KEY = Setting().get('pdns_api_key')
|
||||
self.PDNS_VERSION = Setting().get('pdns_version')
|
||||
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
|
||||
|
||||
if self.name is not None:
|
||||
self.name = ''.join(c for c in self.name.lower()
|
||||
if c in "abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
def __repr__(self):
|
||||
return '<Account {0}r>'.format(self.name)
|
||||
|
||||
def get_name_by_id(self, account_id):
|
||||
"""
|
||||
Convert account_id to account_name
|
||||
"""
|
||||
account = Account.query.filter(Account.id == account_id).first()
|
||||
if account is None:
|
||||
return ''
|
||||
|
||||
return account.name
|
||||
|
||||
def get_id_by_name(self, account_name):
|
||||
"""
|
||||
Convert account_name to account_id
|
||||
"""
|
||||
# Skip actual database lookup for empty queries
|
||||
if account_name is None or account_name == "":
|
||||
return None
|
||||
|
||||
account = Account.query.filter(Account.name == account_name).first()
|
||||
if account is None:
|
||||
return None
|
||||
|
||||
return account.id
|
||||
|
||||
def create_account(self):
|
||||
"""
|
||||
Create a new account
|
||||
"""
|
||||
# Sanity check - account name
|
||||
if self.name == "":
|
||||
return {'status': False, 'msg': 'No account name specified'}
|
||||
|
||||
# check that account name is not already used
|
||||
account = Account.query.filter(Account.name == self.name).first()
|
||||
if account:
|
||||
return {'status': False, 'msg': 'Account already exists'}
|
||||
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return {'status': True, 'msg': 'Account created successfully'}
|
||||
|
||||
def update_account(self):
|
||||
"""
|
||||
Update an existing account
|
||||
"""
|
||||
# Sanity check - account name
|
||||
if self.name == "":
|
||||
return {'status': False, 'msg': 'No account name specified'}
|
||||
|
||||
# read account and check that it exists
|
||||
account = Account.query.filter(Account.name == self.name).first()
|
||||
if not account:
|
||||
return {'status': False, 'msg': 'Account does not exist'}
|
||||
|
||||
account.description = self.description
|
||||
account.contact = self.contact
|
||||
account.mail = self.mail
|
||||
|
||||
db.session.commit()
|
||||
return {'status': True, 'msg': 'Account updated successfully'}
|
||||
|
||||
def delete_account(self, commit=True):
|
||||
"""
|
||||
Delete an account
|
||||
"""
|
||||
# unassociate all users first
|
||||
self.grant_privileges([])
|
||||
|
||||
try:
|
||||
Account.query.filter(Account.name == self.name).delete()
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot delete account {0} from DB. DETAIL: {1}'.format(
|
||||
self.name, e))
|
||||
return False
|
||||
|
||||
def get_user(self):
|
||||
"""
|
||||
Get users (id) associated with this account
|
||||
"""
|
||||
user_ids = []
|
||||
query = db.session.query(
|
||||
AccountUser,
|
||||
Account).filter(User.id == AccountUser.user_id).filter(
|
||||
Account.id == AccountUser.account_id).filter(
|
||||
Account.name == self.name).all()
|
||||
for q in query:
|
||||
user_ids.append(q[0].user_id)
|
||||
return user_ids
|
||||
|
||||
def grant_privileges(self, new_user_list):
|
||||
"""
|
||||
Reconfigure account_user table
|
||||
"""
|
||||
account_id = self.get_id_by_name(self.name)
|
||||
|
||||
account_user_ids = self.get_user()
|
||||
new_user_ids = [
|
||||
u.id
|
||||
for u in User.query.filter(User.username.in_(new_user_list)).all()
|
||||
] if new_user_list else []
|
||||
|
||||
removed_ids = list(set(account_user_ids).difference(new_user_ids))
|
||||
added_ids = list(set(new_user_ids).difference(account_user_ids))
|
||||
|
||||
try:
|
||||
for uid in removed_ids:
|
||||
AccountUser.query.filter(AccountUser.user_id == uid).filter(
|
||||
AccountUser.account_id == account_id).delete()
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot revoke user privileges on account {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
|
||||
try:
|
||||
for uid in added_ids:
|
||||
au = AccountUser(account_id, uid)
|
||||
db.session.add(au)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot grant user privileges to account {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
|
||||
def revoke_privileges_by_id(self, user_id):
|
||||
"""
|
||||
Remove a single user from privilege list based on user_id
|
||||
"""
|
||||
new_uids = [u for u in self.get_user() if u != user_id]
|
||||
users = []
|
||||
for uid in new_uids:
|
||||
users.append(User(id=uid).get_user_info_by_id().username)
|
||||
|
||||
self.grant_privileges(users)
|
||||
|
||||
def add_user(self, user):
|
||||
"""
|
||||
Add a single user to Account by User
|
||||
"""
|
||||
try:
|
||||
au = AccountUser(self.id, user.id)
|
||||
db.session.add(au)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot add user privileges on account {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
return False
|
||||
|
||||
def remove_user(self, user):
|
||||
"""
|
||||
Remove a single user from Account by User
|
||||
"""
|
||||
# TODO: This func is currently used by SAML feature in a wrong way. Fix it
|
||||
try:
|
||||
AccountUser.query.filter(AccountUser.user_id == user.id).filter(
|
||||
AccountUser.account_id == self.id).delete()
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot revoke user privileges on account {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
return False
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Fetch accounts from PowerDNS and syncs them into DB
|
||||
"""
|
||||
db_accounts = Account.query.all()
|
||||
list_db_accounts = [d.name for d in db_accounts]
|
||||
current_app.logger.info("Found {} accounts in PowerDNS-Admin".format(
|
||||
len(list_db_accounts)))
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(self.PDNS_STATS_URL,
|
||||
self.API_EXTENDED_URL + '/servers/localhost/zones'),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
list_jaccount = set(d['account'] for d in jdata if d['account'])
|
||||
current_app.logger.info("Found {} accounts in PowerDNS".format(
|
||||
len(list_jaccount)))
|
||||
|
||||
try:
|
||||
# Remove accounts that don't exist any more
|
||||
should_removed_db_account = list(
|
||||
set(list_db_accounts).difference(list_jaccount))
|
||||
for account_name in should_removed_db_account:
|
||||
account_id = self.get_id_by_name(account_name)
|
||||
if not account_id:
|
||||
continue
|
||||
current_app.logger.info("Deleting account for {0}".format(account_name))
|
||||
account = Account.query.get(account_id)
|
||||
account.delete_account(commit=False)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Can not delete account from DB. DETAIL: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
for account_name in list_jaccount:
|
||||
account_id = self.get_id_by_name(account_name)
|
||||
if account_id:
|
||||
continue
|
||||
current_app.logger.info("Creating account for {0}".format(account_name))
|
||||
account = Account(name=account_name)
|
||||
db.session.add(account)
|
||||
|
||||
db.session.commit()
|
||||
current_app.logger.info('Update accounts finished')
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'Account table has been updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot update account table. Error: {0}'.format(e))
|
||||
return {'status': 'error', 'msg': 'Cannot update account table'}
|
17
powerdnsadmin/models/account_user.py
Normal file
17
powerdnsadmin/models/account_user.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from .base import db
|
||||
|
||||
|
||||
class AccountUser(db.Model):
|
||||
__tablename__ = 'account_user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey('account.id'),
|
||||
nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
def __init__(self, account_id, user_id):
|
||||
self.account_id = account_id
|
||||
self.user_id = user_id
|
||||
|
||||
def __repr__(self):
|
||||
return '<Account_User {0} {1}>'.format(self.account_id, self.user_id)
|
142
powerdnsadmin/models/api_key.py
Normal file
142
powerdnsadmin/models/api_key.py
Normal file
|
@ -0,0 +1,142 @@
|
|||
import secrets
|
||||
import string
|
||||
import bcrypt
|
||||
from flask import current_app
|
||||
|
||||
from .base import db
|
||||
from ..models.role import Role
|
||||
from ..models.domain import Domain
|
||||
from ..models.account import Account
|
||||
|
||||
class ApiKey(db.Model):
|
||||
__tablename__ = "apikey"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(255), unique=True, nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
|
||||
role = db.relationship('Role', back_populates="apikeys", lazy=True)
|
||||
domains = db.relationship("Domain",
|
||||
secondary="domain_apikey",
|
||||
back_populates="apikeys")
|
||||
accounts = db.relationship("Account",
|
||||
secondary="apikey_account",
|
||||
back_populates="apikeys")
|
||||
|
||||
def __init__(self, key=None, desc=None, role_name=None, domains=[], accounts=[]):
|
||||
self.id = None
|
||||
self.description = desc
|
||||
self.role_name = role_name
|
||||
self.domains[:] = domains
|
||||
self.accounts[:] = accounts
|
||||
if not key:
|
||||
rand_key = ''.join(
|
||||
secrets.choice(string.ascii_letters + string.digits)
|
||||
for _ in range(15))
|
||||
self.plain_key = rand_key
|
||||
self.key = self.get_hashed_password(rand_key).decode('utf-8')
|
||||
current_app.logger.debug("Hashed key: {0}".format(self.key))
|
||||
else:
|
||||
self.key = key
|
||||
|
||||
def create(self):
|
||||
try:
|
||||
self.role = Role.query.filter(Role.name == self.role_name).first()
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
current_app.logger.error('Can not update api key table. Error: {0}'.format(e))
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
def delete(self):
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
msg_str = 'Can not delete api key template. Error: {0}'
|
||||
current_app.logger.error(msg_str.format(e))
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
def update(self, role_name=None, description=None, domains=None, accounts=None):
|
||||
try:
|
||||
if role_name:
|
||||
role = Role.query.filter(Role.name == role_name).first()
|
||||
self.role_id = role.id
|
||||
|
||||
if description:
|
||||
self.description = description
|
||||
|
||||
if domains is not None:
|
||||
domain_object_list = Domain.query \
|
||||
.filter(Domain.name.in_(domains)) \
|
||||
.all()
|
||||
self.domains[:] = domain_object_list
|
||||
|
||||
if accounts is not None:
|
||||
account_object_list = Account.query \
|
||||
.filter(Account.name.in_(accounts)) \
|
||||
.all()
|
||||
self.accounts[:] = account_object_list
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
msg_str = 'Update of apikey failed. Error: {0}'
|
||||
current_app.logger.error(msg_str.format(e))
|
||||
db.session.rollback
|
||||
raise e
|
||||
|
||||
def get_hashed_password(self, plain_text_password=None):
|
||||
# Hash a password for the first time
|
||||
# (Using bcrypt, the salt is saved into the hash itself)
|
||||
if plain_text_password is None:
|
||||
return plain_text_password
|
||||
|
||||
if plain_text_password:
|
||||
pw = plain_text_password
|
||||
else:
|
||||
pw = self.plain_text_password
|
||||
|
||||
# The salt value is currently re-used here intentionally because
|
||||
# the implementation relies on just the API key's value itself
|
||||
# for database lookup: ApiKey.is_validate() would have no way of
|
||||
# discerning whether any given key is valid if bcrypt.gensalt()
|
||||
# was used. As far as is known, this is fine as long as the
|
||||
# value of new API keys is randomly generated in a
|
||||
# cryptographically secure fashion, as this then makes
|
||||
# expendable as an exception the otherwise vital protection of
|
||||
# proper salting as provided by bcrypt.gensalt().
|
||||
return bcrypt.hashpw(pw.encode('utf-8'),
|
||||
current_app.config.get('SALT').encode('utf-8'))
|
||||
|
||||
def check_password(self, hashed_password):
|
||||
# Check hashed password. Using bcrypt,
|
||||
# the salt is saved into the hash itself
|
||||
if self.plain_text_password:
|
||||
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8'))
|
||||
return False
|
||||
|
||||
def is_validate(self, method, src_ip=''):
|
||||
"""
|
||||
Validate user credential
|
||||
"""
|
||||
if method == 'LOCAL':
|
||||
passw_hash = self.get_hashed_password(self.plain_text_password)
|
||||
apikey = ApiKey.query \
|
||||
.filter(ApiKey.key == passw_hash.decode('utf-8')) \
|
||||
.first()
|
||||
|
||||
if not apikey:
|
||||
raise Exception("Unauthorized")
|
||||
|
||||
return apikey
|
||||
|
||||
def associate_account(self, account):
|
||||
return True
|
||||
|
||||
def dissociate_account(self, account):
|
||||
return True
|
||||
|
||||
def get_accounts(self):
|
||||
return True
|
20
powerdnsadmin/models/api_key_account.py
Normal file
20
powerdnsadmin/models/api_key_account.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from .base import db
|
||||
|
||||
|
||||
class ApiKeyAccount(db.Model):
|
||||
__tablename__ = 'apikey_account'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
apikey_id = db.Column(db.Integer,
|
||||
db.ForeignKey('apikey.id'),
|
||||
nullable=False)
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey('account.id'),
|
||||
nullable=False)
|
||||
db.UniqueConstraint('apikey_id', 'account_id', name='uniq_apikey_account')
|
||||
|
||||
def __init__(self, apikey_id, account_id):
|
||||
self.apikey_id = apikey_id
|
||||
self.account_id = account_id
|
||||
|
||||
def __repr__(self):
|
||||
return '<ApiKey_Account {0} {1}>'.format(self.apikey_id, self.account_id)
|
7
powerdnsadmin/models/base.py
Normal file
7
powerdnsadmin/models/base.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
domain_apikey = db.Table(
|
||||
'domain_apikey',
|
||||
db.Column('domain_id', db.Integer, db.ForeignKey('domain.id')),
|
||||
db.Column('apikey_id', db.Integer, db.ForeignKey('apikey.id')))
|
881
powerdnsadmin/models/domain.py
Normal file
881
powerdnsadmin/models/domain.py
Normal file
|
@ -0,0 +1,881 @@
|
|||
import re
|
||||
import traceback
|
||||
from flask import current_app
|
||||
from urllib.parse import urljoin
|
||||
from distutils.util import strtobool
|
||||
|
||||
from ..lib import utils
|
||||
from .base import db, domain_apikey
|
||||
from .setting import Setting
|
||||
from .user import User
|
||||
from .account import Account
|
||||
from .account import AccountUser
|
||||
from .domain_user import DomainUser
|
||||
from .domain_setting import DomainSetting
|
||||
from .history import History
|
||||
|
||||
|
||||
class Domain(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), index=True, unique=True)
|
||||
master = db.Column(db.String(128))
|
||||
type = db.Column(db.String(6), nullable=False)
|
||||
serial = db.Column(db.BigInteger)
|
||||
notified_serial = db.Column(db.BigInteger)
|
||||
last_check = db.Column(db.Integer)
|
||||
dnssec = db.Column(db.Integer)
|
||||
account_id = db.Column(db.Integer, db.ForeignKey('account.id'))
|
||||
account = db.relationship("Account", back_populates="domains")
|
||||
settings = db.relationship('DomainSetting', back_populates='domain')
|
||||
apikeys = db.relationship("ApiKey",
|
||||
secondary=domain_apikey,
|
||||
back_populates="domains")
|
||||
|
||||
def __init__(self,
|
||||
id=None,
|
||||
name=None,
|
||||
master=None,
|
||||
type='NATIVE',
|
||||
serial=None,
|
||||
notified_serial=None,
|
||||
last_check=None,
|
||||
dnssec=None,
|
||||
account_id=None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.master = master
|
||||
self.type = type
|
||||
self.serial = serial
|
||||
self.notified_serial = notified_serial
|
||||
self.last_check = last_check
|
||||
self.dnssec = dnssec
|
||||
self.account_id = account_id
|
||||
# PDNS configs
|
||||
self.PDNS_STATS_URL = Setting().get('pdns_api_url')
|
||||
self.PDNS_API_KEY = Setting().get('pdns_api_key')
|
||||
self.PDNS_VERSION = Setting().get('pdns_version')
|
||||
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Domain {0}>'.format(self.name)
|
||||
|
||||
def add_setting(self, setting, value):
|
||||
try:
|
||||
self.settings.append(DomainSetting(setting=setting, value=value))
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Can not create setting {0} for domain {1}. {2}'.format(
|
||||
setting, self.name, e))
|
||||
return False
|
||||
|
||||
def get_domain_info(self, domain_name):
|
||||
"""
|
||||
Get all domains which has in PowerDNS
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain_name)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
return jdata
|
||||
|
||||
def get_domains(self):
|
||||
"""
|
||||
Get all domains which has in PowerDNS
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(self.PDNS_STATS_URL,
|
||||
self.API_EXTENDED_URL + '/servers/localhost/zones'),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
return jdata
|
||||
|
||||
def get_id_by_name(self, name):
|
||||
"""
|
||||
Return domain id
|
||||
"""
|
||||
try:
|
||||
domain = Domain.query.filter(Domain.name == name).first()
|
||||
return domain.id
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Domain does not exist. ERROR: {0}'.format(e))
|
||||
return None
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Fetch zones (domains) from PowerDNS and update into DB
|
||||
"""
|
||||
db_domain = Domain.query.all()
|
||||
list_db_domain = [d.name for d in db_domain]
|
||||
dict_db_domain = dict((x.name, x) for x in db_domain)
|
||||
current_app.logger.info("Found {} domains in PowerDNS-Admin".format(
|
||||
len(list_db_domain)))
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(self.PDNS_STATS_URL,
|
||||
self.API_EXTENDED_URL + '/servers/localhost/zones'),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
list_jdomain = [d['name'].rstrip('.') for d in jdata]
|
||||
current_app.logger.info(
|
||||
"Found {} zones in PowerDNS server".format(len(list_jdomain)))
|
||||
|
||||
try:
|
||||
# domains should remove from db since it doesn't exist in powerdns anymore
|
||||
should_removed_db_domain = list(
|
||||
set(list_db_domain).difference(list_jdomain))
|
||||
for domain_name in should_removed_db_domain:
|
||||
self.delete_domain_from_pdnsadmin(domain_name, do_commit=False)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Can not delete domain from DB. DETAIL: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
# update/add new domain
|
||||
for data in jdata:
|
||||
if 'account' in data:
|
||||
account_id = Account().get_id_by_name(data['account'])
|
||||
else:
|
||||
current_app.logger.debug(
|
||||
"No 'account' data found in API result - Unsupported PowerDNS version?"
|
||||
)
|
||||
account_id = None
|
||||
domain = dict_db_domain.get(data['name'].rstrip('.'), None)
|
||||
if domain:
|
||||
self.update_pdns_admin_domain(domain, account_id, data, do_commit=False)
|
||||
else:
|
||||
# add new domain
|
||||
self.add_domain_to_powerdns_admin(domain=data, do_commit=False)
|
||||
|
||||
db.session.commit()
|
||||
current_app.logger.info('Update domain finished')
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'Domain table has been updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot update domain table. Error: {0}'.format(e))
|
||||
return {'status': 'error', 'msg': 'Cannot update domain table'}
|
||||
|
||||
def update_pdns_admin_domain(self, domain, account_id, data, do_commit=True):
|
||||
# existing domain, only update if something actually has changed
|
||||
if (domain.master != str(data['masters'])
|
||||
or domain.type != data['kind']
|
||||
or domain.serial != data['serial']
|
||||
or domain.notified_serial != data['notified_serial']
|
||||
or domain.last_check != (1 if data['last_check'] else 0)
|
||||
or domain.dnssec != data['dnssec']
|
||||
or domain.account_id != account_id):
|
||||
|
||||
domain.master = str(data['masters'])
|
||||
domain.type = data['kind']
|
||||
domain.serial = data['serial']
|
||||
domain.notified_serial = data['notified_serial']
|
||||
domain.last_check = 1 if data['last_check'] else 0
|
||||
domain.dnssec = 1 if data['dnssec'] else 0
|
||||
domain.account_id = account_id
|
||||
try:
|
||||
if do_commit:
|
||||
db.session.commit()
|
||||
current_app.logger.info("Updated PDNS-Admin domain {0}".format(
|
||||
domain.name))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.info("Rolled back Domain {0} {1}".format(
|
||||
domain.name, e))
|
||||
raise
|
||||
|
||||
def add(self,
|
||||
domain_name,
|
||||
domain_type,
|
||||
soa_edit_api,
|
||||
domain_ns=[],
|
||||
domain_master_ips=[],
|
||||
account_name=None):
|
||||
"""
|
||||
Add a domain to power dns
|
||||
"""
|
||||
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
domain_name = domain_name + '.'
|
||||
domain_ns = [ns + '.' for ns in domain_ns]
|
||||
|
||||
if soa_edit_api not in ["DEFAULT", "INCREASE", "EPOCH", "OFF"]:
|
||||
soa_edit_api = 'DEFAULT'
|
||||
|
||||
elif soa_edit_api == 'OFF':
|
||||
soa_edit_api = ''
|
||||
|
||||
post_data = {
|
||||
"name": domain_name,
|
||||
"kind": domain_type,
|
||||
"masters": domain_master_ips,
|
||||
"nameservers": domain_ns,
|
||||
"soa_edit_api": soa_edit_api,
|
||||
"account": account_name
|
||||
}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(self.PDNS_STATS_URL,
|
||||
self.API_EXTENDED_URL + '/servers/localhost/zones'),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='POST',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
if 'error' in jdata.keys():
|
||||
current_app.logger.error(jdata['error'])
|
||||
if jdata.get('http_code') == 409:
|
||||
return {'status': 'error', 'msg': 'Domain already exists'}
|
||||
return {'status': 'error', 'msg': jdata['error']}
|
||||
else:
|
||||
current_app.logger.info(
|
||||
'Added domain successfully to PowerDNS: {0}'.format(
|
||||
domain_name))
|
||||
self.add_domain_to_powerdns_admin(domain_dict=post_data)
|
||||
return {'status': 'ok', 'msg': 'Added domain successfully'}
|
||||
except Exception as e:
|
||||
current_app.logger.error('Cannot add domain {0} {1}'.format(
|
||||
domain_name, e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return {'status': 'error', 'msg': 'Cannot add this domain.'}
|
||||
|
||||
def add_domain_to_powerdns_admin(self, domain=None, domain_dict=None, do_commit=True):
|
||||
"""
|
||||
Read Domain from PowerDNS and add into PDNS-Admin
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
if not domain:
|
||||
try:
|
||||
domain = utils.fetch_json(
|
||||
urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(
|
||||
domain_dict['name'])),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
except Exception as e:
|
||||
current_app.logger.error('Can not read domain from PDNS')
|
||||
current_app.logger.error(e)
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
if 'account' in domain:
|
||||
account_id = Account().get_id_by_name(domain['account'])
|
||||
else:
|
||||
current_app.logger.debug(
|
||||
"No 'account' data found in API result - Unsupported PowerDNS version?"
|
||||
)
|
||||
account_id = None
|
||||
# add new domain
|
||||
d = Domain()
|
||||
d.name = domain['name'].rstrip('.') # lgtm [py/modification-of-default-value]
|
||||
d.master = str(domain['masters'])
|
||||
d.type = domain['kind']
|
||||
d.serial = domain['serial']
|
||||
d.notified_serial = domain['notified_serial']
|
||||
d.last_check = domain['last_check']
|
||||
d.dnssec = 1 if domain['dnssec'] else 0
|
||||
d.account_id = account_id
|
||||
db.session.add(d)
|
||||
try:
|
||||
if do_commit:
|
||||
db.session.commit()
|
||||
current_app.logger.info(
|
||||
"Synced PowerDNS Domain to PDNS-Admin: {0}".format(d.name))
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'Added Domain successfully to PowerDNS-Admin'
|
||||
}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.info("Rolled back Domain {0}".format(d.name))
|
||||
raise
|
||||
|
||||
def update_soa_setting(self, domain_name, soa_edit_api):
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
return {'status': 'error', 'msg': 'Domain does not exist.'}
|
||||
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
if soa_edit_api not in ["DEFAULT", "INCREASE", "EPOCH", "OFF"]:
|
||||
soa_edit_api = 'DEFAULT'
|
||||
|
||||
elif soa_edit_api == 'OFF':
|
||||
soa_edit_api = ''
|
||||
|
||||
post_data = {"soa_edit_api": soa_edit_api, "kind": domain.type}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='PUT',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
if 'error' in jdata.keys():
|
||||
current_app.logger.error(jdata['error'])
|
||||
return {'status': 'error', 'msg': jdata['error']}
|
||||
else:
|
||||
current_app.logger.info(
|
||||
'soa-edit-api changed for domain {0} successfully'.format(
|
||||
domain_name))
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'soa-edit-api changed successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.debug(e)
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
current_app.logger.error(
|
||||
'Cannot change soa-edit-api for domain {0}'.format(
|
||||
domain_name))
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Cannot change soa-edit-api for this domain.'
|
||||
}
|
||||
|
||||
def update_kind(self, domain_name, kind, masters=[]):
|
||||
"""
|
||||
Update zone kind: Native / Master / Slave
|
||||
"""
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
return {'status': 'error', 'msg': 'Domain does not exist.'}
|
||||
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
post_data = {"kind": kind, "masters": masters}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='PUT',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
if 'error' in jdata.keys():
|
||||
current_app.logger.error(jdata['error'])
|
||||
return {'status': 'error', 'msg': jdata['error']}
|
||||
else:
|
||||
current_app.logger.info(
|
||||
'Update domain kind for {0} successfully'.format(
|
||||
domain_name))
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'Domain kind changed successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot update kind for domain {0}. Error: {1}'.format(
|
||||
domain_name, e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Cannot update kind for this domain.'
|
||||
}
|
||||
|
||||
def create_reverse_domain(self, domain_name, domain_reverse_name):
|
||||
"""
|
||||
Check the existing reverse lookup domain,
|
||||
if not exists create a new one automatically
|
||||
"""
|
||||
domain_obj = Domain.query.filter(Domain.name == domain_name).first()
|
||||
domain_auto_ptr = DomainSetting.query.filter(
|
||||
DomainSetting.domain == domain_obj).filter(
|
||||
DomainSetting.setting == 'auto_ptr').first()
|
||||
domain_auto_ptr = strtobool(
|
||||
domain_auto_ptr.value) if domain_auto_ptr else False
|
||||
system_auto_ptr = Setting().get('auto_ptr')
|
||||
self.name = domain_name
|
||||
domain_id = self.get_id_by_name(domain_reverse_name)
|
||||
if domain_id is None and \
|
||||
(
|
||||
system_auto_ptr or
|
||||
domain_auto_ptr
|
||||
):
|
||||
result = self.add(domain_reverse_name, 'Master', 'DEFAULT', [], [])
|
||||
self.update()
|
||||
if result['status'] == 'ok':
|
||||
history = History(msg='Add reverse lookup domain {0}'.format(
|
||||
domain_reverse_name),
|
||||
detail=str({
|
||||
'domain_type': 'Master',
|
||||
'domain_master_ips': ''
|
||||
}),
|
||||
created_by='System')
|
||||
history.add()
|
||||
else:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Adding reverse lookup domain failed'
|
||||
}
|
||||
domain_user_ids = self.get_user()
|
||||
if len(domain_user_ids) > 0:
|
||||
self.name = domain_reverse_name
|
||||
self.grant_privileges(domain_user_ids)
|
||||
return {
|
||||
'status':
|
||||
'ok',
|
||||
'msg':
|
||||
'New reverse lookup domain created with granted privileges'
|
||||
}
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'New reverse lookup domain created without users'
|
||||
}
|
||||
return {'status': 'ok', 'msg': 'Reverse lookup domain already exists'}
|
||||
|
||||
def get_reverse_domain_name(self, reverse_host_address):
|
||||
c = 1
|
||||
if re.search('ip6.arpa', reverse_host_address):
|
||||
for i in range(1, 32, 1):
|
||||
address = re.search(
|
||||
'((([a-f0-9]\.){' + str(i) + '})(?P<ipname>.+6.arpa)\.?)',
|
||||
reverse_host_address)
|
||||
if None != self.get_id_by_name(address.group('ipname')):
|
||||
c = i
|
||||
break
|
||||
return re.search(
|
||||
'((([a-f0-9]\.){' + str(c) + '})(?P<ipname>.+6.arpa)\.?)',
|
||||
reverse_host_address).group('ipname')
|
||||
else:
|
||||
for i in range(1, 4, 1):
|
||||
address = re.search(
|
||||
'((([0-9]+\.){' + str(i) + '})(?P<ipname>.+r.arpa)\.?)',
|
||||
reverse_host_address)
|
||||
if None != self.get_id_by_name(address.group('ipname')):
|
||||
c = i
|
||||
break
|
||||
return re.search(
|
||||
'((([0-9]+\.){' + str(c) + '})(?P<ipname>.+r.arpa)\.?)',
|
||||
reverse_host_address).group('ipname')
|
||||
|
||||
def delete(self, domain_name):
|
||||
"""
|
||||
Delete a single domain name from powerdns
|
||||
"""
|
||||
try:
|
||||
self.delete_domain_from_powerdns(domain_name)
|
||||
self.delete_domain_from_pdnsadmin(domain_name)
|
||||
return {'status': 'ok', 'msg': 'Delete domain successfully'}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot delete domain {0}'.format(domain_name))
|
||||
current_app.logger.error(e)
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return {'status': 'error', 'msg': 'Cannot delete domain'}
|
||||
|
||||
def delete_domain_from_powerdns(self, domain_name):
|
||||
"""
|
||||
Delete a single domain name from powerdns
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain_name)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='DELETE',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
current_app.logger.info(
|
||||
'Deleted domain successfully from PowerDNS: {0}'.format(
|
||||
domain_name))
|
||||
return {'status': 'ok', 'msg': 'Delete domain successfully'}
|
||||
|
||||
def delete_domain_from_pdnsadmin(self, domain_name, do_commit=True):
|
||||
# Revoke permission before deleting domain
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
domain_user = DomainUser.query.filter(
|
||||
DomainUser.domain_id == domain.id)
|
||||
if domain_user:
|
||||
domain_user.delete()
|
||||
domain_setting = DomainSetting.query.filter(
|
||||
DomainSetting.domain_id == domain.id)
|
||||
if domain_setting:
|
||||
domain_setting.delete()
|
||||
domain.apikeys[:] = []
|
||||
|
||||
# Remove history for domain
|
||||
domain_history = History.query.filter(
|
||||
History.domain_id == domain.id
|
||||
)
|
||||
if domain_history:
|
||||
domain_history.delete()
|
||||
|
||||
# then remove domain
|
||||
Domain.query.filter(Domain.name == domain_name).delete()
|
||||
if do_commit:
|
||||
db.session.commit()
|
||||
current_app.logger.info(
|
||||
"Deleted domain successfully from pdnsADMIN: {}".format(
|
||||
domain_name))
|
||||
|
||||
def get_user(self):
|
||||
"""
|
||||
Get users (id) who have access to this domain name
|
||||
"""
|
||||
user_ids = []
|
||||
query = db.session.query(
|
||||
DomainUser, Domain).filter(User.id == DomainUser.user_id).filter(
|
||||
Domain.id == DomainUser.domain_id).filter(
|
||||
Domain.name == self.name).all()
|
||||
for q in query:
|
||||
user_ids.append(q[0].user_id)
|
||||
return user_ids
|
||||
|
||||
def grant_privileges(self, new_user_ids):
|
||||
"""
|
||||
Reconfigure domain_user table
|
||||
"""
|
||||
|
||||
domain_id = self.get_id_by_name(self.name)
|
||||
domain_user_ids = self.get_user()
|
||||
|
||||
removed_ids = list(set(domain_user_ids).difference(new_user_ids))
|
||||
added_ids = list(set(new_user_ids).difference(domain_user_ids))
|
||||
|
||||
try:
|
||||
for uid in removed_ids:
|
||||
DomainUser.query.filter(DomainUser.user_id == uid).filter(
|
||||
DomainUser.domain_id == domain_id).delete()
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot revoke user privileges on domain {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
current_app.logger.debug(print(traceback.format_exc()))
|
||||
|
||||
try:
|
||||
for uid in added_ids:
|
||||
du = DomainUser(domain_id, uid)
|
||||
db.session.add(du)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot grant user privileges to domain {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
current_app.logger.debug(print(traceback.format_exc()))
|
||||
|
||||
def revoke_privileges_by_id(self, user_id):
|
||||
"""
|
||||
Remove a single user from privilege list based on user_id
|
||||
"""
|
||||
new_uids = [u for u in self.get_user() if u != user_id]
|
||||
users = []
|
||||
for uid in new_uids:
|
||||
users.append(User(id=uid).get_user_info_by_id().username)
|
||||
|
||||
self.grant_privileges(users)
|
||||
|
||||
def add_user(self, user):
|
||||
"""
|
||||
Add a single user to Domain by User
|
||||
"""
|
||||
try:
|
||||
du = DomainUser(self.id, user.id)
|
||||
db.session.add(du)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot add user privileges on domain {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
return False
|
||||
|
||||
def update_from_master(self, domain_name):
|
||||
"""
|
||||
Update records from Master DNS server
|
||||
"""
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if domain:
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
r = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}/axfr-retrieve'.format(
|
||||
domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='PUT',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
return {'status': 'ok', 'msg': r.get('result')}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot update from master. DETAIL: {0}'.format(e))
|
||||
return {
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
else:
|
||||
return {'status': 'error', 'msg': 'This domain does not exist'}
|
||||
|
||||
def get_domain_dnssec(self, domain_name):
|
||||
"""
|
||||
Get domain DNSSEC information
|
||||
"""
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if domain:
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}/cryptokeys'.format(
|
||||
domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='GET',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
if 'error' in jdata:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'DNSSEC is not enabled for this domain'
|
||||
}
|
||||
else:
|
||||
return {'status': 'ok', 'dnssec': jdata}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot get domain dnssec. DETAIL: {0}'.format(e))
|
||||
return {
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
else:
|
||||
return {'status': 'error', 'msg': 'This domain does not exist'}
|
||||
|
||||
def enable_domain_dnssec(self, domain_name):
|
||||
"""
|
||||
Enable domain DNSSEC
|
||||
"""
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if domain:
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
# Enable API-RECTIFY for domain, BEFORE activating DNSSEC
|
||||
post_data = {"api_rectify": True}
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='PUT',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
if 'error' in jdata:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'API-RECTIFY could not be enabled for this domain',
|
||||
'jdata': jdata
|
||||
}
|
||||
|
||||
# Activate DNSSEC
|
||||
post_data = {"keytype": "ksk", "active": True}
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}/cryptokeys'.format(
|
||||
domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='POST',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
if 'error' in jdata:
|
||||
return {
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'Cannot enable DNSSEC for this domain. Error: {0}'.
|
||||
format(jdata['error']),
|
||||
'jdata':
|
||||
jdata
|
||||
}
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot enable dns sec. DETAIL: {}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return {
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
|
||||
else:
|
||||
return {'status': 'error', 'msg': 'This domain does not exist'}
|
||||
|
||||
def delete_dnssec_key(self, domain_name, key_id):
|
||||
"""
|
||||
Remove keys DNSSEC
|
||||
"""
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if domain:
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
# Deactivate DNSSEC
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}/cryptokeys/{1}'.format(
|
||||
domain.name, key_id)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='DELETE',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
if jdata != True:
|
||||
return {
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'Cannot disable DNSSEC for this domain. Error: {0}'.
|
||||
format(jdata['error']),
|
||||
'jdata':
|
||||
jdata
|
||||
}
|
||||
|
||||
# Disable API-RECTIFY for domain, AFTER deactivating DNSSEC
|
||||
post_data = {"api_rectify": False}
|
||||
jdata = utils.fetch_json(
|
||||
urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain.name)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='PUT',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
if 'error' in jdata:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'API-RECTIFY could not be disabled for this domain',
|
||||
'jdata': jdata
|
||||
}
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot delete dnssec key. DETAIL: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator',
|
||||
'domain': domain.name,
|
||||
'id': key_id
|
||||
}
|
||||
|
||||
else:
|
||||
return {'status': 'error', 'msg': 'This domain does not exist'}
|
||||
|
||||
def assoc_account(self, account_id):
|
||||
"""
|
||||
Associate domain with a domain, specified by account id
|
||||
"""
|
||||
domain_name = self.name
|
||||
|
||||
# Sanity check - domain name
|
||||
if domain_name == "":
|
||||
return {'status': False, 'msg': 'No domain name specified'}
|
||||
|
||||
# read domain and check that it exists
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
return {'status': False, 'msg': 'Domain does not exist'}
|
||||
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
account_name = Account().get_name_by_id(account_id)
|
||||
|
||||
post_data = {"account": account_name}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain_name)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='PUT',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=post_data)
|
||||
|
||||
if 'error' in jdata.keys():
|
||||
current_app.logger.error(jdata['error'])
|
||||
return {'status': 'error', 'msg': jdata['error']}
|
||||
else:
|
||||
self.update()
|
||||
msg_str = 'Account changed for domain {0} successfully'
|
||||
current_app.logger.info(msg_str.format(domain_name))
|
||||
return {'status': 'ok', 'msg': 'account changed successfully'}
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.debug(e)
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
msg_str = 'Cannot change account for domain {0}'
|
||||
current_app.logger.error(msg_str.format(domain_name))
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Cannot change account for this domain.'
|
||||
}
|
||||
|
||||
def get_account(self):
|
||||
"""
|
||||
Get current account associated with this domain
|
||||
"""
|
||||
domain = Domain.query.filter(Domain.name == self.name).first()
|
||||
|
||||
return domain.account
|
||||
|
||||
def is_valid_access(self, user_id):
|
||||
"""
|
||||
Check if the user is allowed to access this
|
||||
domain name
|
||||
"""
|
||||
return db.session.query(Domain) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.filter(
|
||||
db.or_(
|
||||
DomainUser.user_id == user_id,
|
||||
AccountUser.user_id == user_id
|
||||
)).filter(Domain.id == self.id).first()
|
37
powerdnsadmin/models/domain_setting.py
Normal file
37
powerdnsadmin/models/domain_setting.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import traceback
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from .base import db
|
||||
|
||||
|
||||
class DomainSetting(db.Model):
|
||||
__tablename__ = 'domain_setting'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'))
|
||||
domain = db.relationship('Domain', back_populates='settings')
|
||||
setting = db.Column(db.String(255), nullable=False)
|
||||
value = db.Column(db.String(255))
|
||||
|
||||
def __init__(self, id=None, setting=None, value=None):
|
||||
self.id = id
|
||||
self.setting = setting
|
||||
self.value = value
|
||||
|
||||
def __repr__(self):
|
||||
return '<DomainSetting {0} for {1}>'.format(setting, self.domain.name)
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self.setting == other.setting
|
||||
|
||||
def set(self, value):
|
||||
try:
|
||||
self.value = value
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Unable to set DomainSetting value. DETAIL: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
db.session.rollback()
|
||||
return False
|
65
powerdnsadmin/models/domain_template.py
Normal file
65
powerdnsadmin/models/domain_template.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
from flask import current_app
|
||||
from .base import db
|
||||
|
||||
|
||||
class DomainTemplate(db.Model):
|
||||
__tablename__ = "domain_template"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), index=True, unique=True)
|
||||
description = db.Column(db.String(255))
|
||||
records = db.relationship('DomainTemplateRecord',
|
||||
back_populates='template',
|
||||
cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return '<DomainTemplate {0}>'.format(self.name)
|
||||
|
||||
def __init__(self, name=None, description=None):
|
||||
self.id = None
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
def replace_records(self, records):
|
||||
try:
|
||||
self.records = []
|
||||
for record in records:
|
||||
self.records.append(record)
|
||||
db.session.commit()
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'Template records have been modified'
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot create template records Error: {0}'.format(e))
|
||||
db.session.rollback()
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Can not create template records'
|
||||
}
|
||||
|
||||
def create(self):
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return {'status': 'ok', 'msg': 'Template has been created'}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Can not update domain template table. Error: {0}'.format(e))
|
||||
db.session.rollback()
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Can not update domain template table'
|
||||
}
|
||||
|
||||
def delete_template(self):
|
||||
try:
|
||||
self.records = []
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
return {'status': 'ok', 'msg': 'Template has been deleted'}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Can not delete domain template. Error: {0}'.format(e))
|
||||
db.session.rollback()
|
||||
return {'status': 'error', 'msg': 'Can not delete domain template'}
|
47
powerdnsadmin/models/domain_template_record.py
Normal file
47
powerdnsadmin/models/domain_template_record.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
from flask import current_app
|
||||
|
||||
from .base import db
|
||||
|
||||
|
||||
class DomainTemplateRecord(db.Model):
|
||||
__tablename__ = "domain_template_record"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255))
|
||||
type = db.Column(db.String(64))
|
||||
ttl = db.Column(db.Integer)
|
||||
data = db.Column(db.Text)
|
||||
comment = db.Column(db.Text)
|
||||
status = db.Column(db.Boolean)
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('domain_template.id'))
|
||||
template = db.relationship('DomainTemplate', back_populates='records')
|
||||
|
||||
def __repr__(self):
|
||||
return '<DomainTemplateRecord {0}>'.format(self.id)
|
||||
|
||||
def __init__(self,
|
||||
id=None,
|
||||
name=None,
|
||||
type=None,
|
||||
ttl=None,
|
||||
data=None,
|
||||
comment=None,
|
||||
status=None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.ttl = ttl
|
||||
self.data = data
|
||||
self.comment = comment
|
||||
self.status = status
|
||||
|
||||
def apply(self):
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Can not update domain template table. Error: {0}'.format(e))
|
||||
db.session.rollback()
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': 'Can not update domain template table'
|
||||
}
|
17
powerdnsadmin/models/domain_user.py
Normal file
17
powerdnsadmin/models/domain_user.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from .base import db
|
||||
|
||||
|
||||
class DomainUser(db.Model):
|
||||
__tablename__ = 'domain_user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
domain_id = db.Column(db.Integer,
|
||||
db.ForeignKey('domain.id'),
|
||||
nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
def __init__(self, domain_id, user_id):
|
||||
self.domain_id = domain_id
|
||||
self.user_id = user_id
|
||||
|
||||
def __repr__(self):
|
||||
return '<Domain_User {0} {1}>'.format(self.domain_id, self.user_id)
|
56
powerdnsadmin/models/history.py
Normal file
56
powerdnsadmin/models/history.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
import traceback
|
||||
|
||||
from flask import current_app
|
||||
from datetime import datetime
|
||||
|
||||
from .base import db
|
||||
|
||||
|
||||
class History(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
# format of msg field must not change. History traversing is done using part of the msg field
|
||||
msg = db.Column(db.String(256))
|
||||
# detail = db.Column(db.Text().with_variant(db.Text(length=2**24-2), 'mysql'))
|
||||
detail = db.Column(db.Text())
|
||||
created_by = db.Column(db.String(128))
|
||||
created_on = db.Column(db.DateTime, index=True, default=datetime.utcnow)
|
||||
domain_id = db.Column(db.Integer,
|
||||
db.ForeignKey('domain.id'),
|
||||
nullable=True)
|
||||
|
||||
def __init__(self, id=None, msg=None, detail=None, created_by=None, domain_id=None):
|
||||
self.id = id
|
||||
self.msg = msg
|
||||
self.detail = detail
|
||||
self.created_by = created_by
|
||||
self.domain_id = domain_id
|
||||
|
||||
def __repr__(self):
|
||||
return '<History {0}>'.format(self.msg)
|
||||
|
||||
def add(self):
|
||||
"""
|
||||
Add an event to history table
|
||||
"""
|
||||
h = History()
|
||||
h.msg = self.msg
|
||||
h.detail = self.detail
|
||||
h.created_by = self.created_by
|
||||
h.domain_id = self.domain_id
|
||||
db.session.add(h)
|
||||
db.session.commit()
|
||||
|
||||
def remove_all(self):
|
||||
"""
|
||||
Remove all history from DB
|
||||
"""
|
||||
try:
|
||||
db.session.query(History).delete()
|
||||
db.session.commit()
|
||||
current_app.logger.info("Removed all history")
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error("Cannot remove history. DETAIL: {0}".format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return False
|
630
powerdnsadmin/models/record.py
Normal file
630
powerdnsadmin/models/record.py
Normal file
|
@ -0,0 +1,630 @@
|
|||
import re
|
||||
import traceback
|
||||
import dns.reversename
|
||||
import dns.inet
|
||||
import dns.name
|
||||
from flask import current_app
|
||||
from urllib.parse import urljoin
|
||||
from distutils.util import strtobool
|
||||
from itertools import groupby
|
||||
|
||||
from .. import utils
|
||||
from .base import db
|
||||
from .setting import Setting
|
||||
from .domain import Domain
|
||||
from .domain_setting import DomainSetting
|
||||
|
||||
|
||||
def by_record_content_pair(e):
|
||||
return e[0]['content']
|
||||
|
||||
|
||||
class Record(object):
|
||||
"""
|
||||
This is not a model, it's just an object
|
||||
which be assigned data from PowerDNS API
|
||||
"""
|
||||
def __init__(self,
|
||||
name=None,
|
||||
type=None,
|
||||
status=None,
|
||||
ttl=None,
|
||||
data=None,
|
||||
comment_data=None):
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.status = status
|
||||
self.ttl = ttl
|
||||
self.data = data
|
||||
self.comment_data = comment_data
|
||||
# PDNS configs
|
||||
self.PDNS_STATS_URL = Setting().get('pdns_api_url')
|
||||
self.PDNS_API_KEY = Setting().get('pdns_api_key')
|
||||
self.PDNS_VERSION = Setting().get('pdns_version')
|
||||
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
|
||||
self.PRETTY_IPV6_PTR = Setting().get('pretty_ipv6_ptr')
|
||||
|
||||
def get_rrsets(self, domain):
|
||||
"""
|
||||
Query domain's rrsets via PDNS API
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain)),
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
headers=headers,
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Cannot fetch domain's record data from remote powerdns api. DETAIL: {0}"
|
||||
.format(e))
|
||||
return []
|
||||
|
||||
rrsets=[]
|
||||
for r in jdata['rrsets']:
|
||||
if len(r['records']) == 0:
|
||||
continue
|
||||
|
||||
while len(r['comments'])<len(r['records']):
|
||||
r['comments'].append({"content": "", "account": ""})
|
||||
r['records'], r['comments'] = (list(t) for t in zip(*sorted(zip(r['records'], r['comments']), key=by_record_content_pair)))
|
||||
rrsets.append(r)
|
||||
|
||||
return rrsets
|
||||
|
||||
def add(self, domain_name, rrset):
|
||||
"""
|
||||
Add a record to a domain (Used by auto_ptr and DynDNS)
|
||||
|
||||
Args:
|
||||
domain_name(str): The zone name
|
||||
rrset(dict): The record in PDNS rrset format
|
||||
|
||||
Returns:
|
||||
(dict): A dict contains status code and message
|
||||
"""
|
||||
# Validate record first
|
||||
rrsets = self.get_rrsets(domain_name)
|
||||
check = list(filter(lambda check: check['name'] == self.name, rrsets))
|
||||
if check:
|
||||
r = check[0]
|
||||
if r['type'] in ('A', 'AAAA', 'CNAME'):
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'Record already exists with type "A", "AAAA" or "CNAME"'
|
||||
}
|
||||
|
||||
# Continue if the record is ready to be added
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain_name)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='PATCH',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=rrset)
|
||||
current_app.logger.debug(jdata)
|
||||
return {'status': 'ok', 'msg': 'Record was added successfully'}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Cannot add record to domain {}. Error: {}".format(
|
||||
domain_name, e))
|
||||
current_app.logger.debug("Submitted record rrset: \n{}".format(
|
||||
utils.pretty_json(rrset)))
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
|
||||
def merge_rrsets(self, rrsets):
|
||||
"""
|
||||
Merge the rrsets that has same "name" and
|
||||
"type".
|
||||
Return: a new rrset which has multiple "records"
|
||||
and "comments"
|
||||
"""
|
||||
if not rrsets:
|
||||
raise Exception("Empty rrsets to merge")
|
||||
elif len(rrsets) == 1:
|
||||
# It is unique rrset already
|
||||
return rrsets[0]
|
||||
else:
|
||||
# Merge rrsets into one
|
||||
rrset = rrsets[0]
|
||||
for r in rrsets[1:]:
|
||||
rrset['records'] = rrset['records'] + r['records']
|
||||
rrset['comments'] = rrset['comments'] + r['comments']
|
||||
while len(rrset['comments']) < len(rrset['records']):
|
||||
rrset['comments'].append({"content": "", "account": ""})
|
||||
zipped_list = zip(rrset['records'], rrset['comments'])
|
||||
tuples = zip(*sorted(zipped_list, key=by_record_content_pair))
|
||||
rrset['records'], rrset['comments'] = [list(t) for t in tuples]
|
||||
return rrset
|
||||
|
||||
def build_rrsets(self, domain_name, submitted_records):
|
||||
"""
|
||||
Build rrsets from the datatable's records
|
||||
|
||||
Args:
|
||||
domain_name(str): The zone name
|
||||
submitted_records(list): List of records submitted from PDA datatable
|
||||
|
||||
Returns:
|
||||
transformed_rrsets(list): List of rrsets converted from PDA datatable
|
||||
"""
|
||||
rrsets = []
|
||||
for record in submitted_records:
|
||||
# Format the record name
|
||||
#
|
||||
# Translate template placeholders into proper record data
|
||||
record['record_data'] = record['record_data'].replace('[ZONE]', domain_name)
|
||||
# Translate record name into punycode (IDN) as that's the only way
|
||||
# to convey non-ascii records to the dns server
|
||||
record['record_name'] = record['record_name'].encode('idna').decode()
|
||||
#TODO: error handling
|
||||
# If the record is an alias (CNAME), we will also make sure that
|
||||
# the target domain is properly converted to punycode (IDN)
|
||||
if record["record_type"] == 'CNAME':
|
||||
record['record_data'] = record['record_data'].encode('idna').decode()
|
||||
#TODO: error handling
|
||||
# If it is ipv6 reverse zone and PRETTY_IPV6_PTR is enabled,
|
||||
# We convert ipv6 address back to reverse record format
|
||||
# before submitting to PDNS API.
|
||||
if self.PRETTY_IPV6_PTR and re.search(
|
||||
r'ip6\.arpa', domain_name
|
||||
) and record['record_type'] == 'PTR' and ':' in record[
|
||||
'record_name']:
|
||||
record_name = dns.reversename.from_address(
|
||||
record['record_name']).to_text()
|
||||
# Else, it is forward zone, then record name should be
|
||||
# in format "<name>.<domain>.". If it is root
|
||||
# domain name (name == '@' or ''), the name should
|
||||
# be in format "<domain>."
|
||||
else:
|
||||
record_name = "{}.{}.".format(
|
||||
record["record_name"],
|
||||
domain_name) if record["record_name"] not in [
|
||||
'@', ''
|
||||
] else domain_name + '.'
|
||||
|
||||
# Format the record content, it musts end
|
||||
# with a dot character if in following types
|
||||
if record["record_type"] in [
|
||||
'MX', 'CNAME', 'SRV', 'NS', 'PTR'
|
||||
] and record["record_data"].strip()[-1:] != '.':
|
||||
record["record_data"] += '.'
|
||||
|
||||
record_content = {
|
||||
"content": record["record_data"],
|
||||
"disabled":
|
||||
False if record['record_status'] == 'Active' else True
|
||||
}
|
||||
|
||||
# Format the comment
|
||||
record_comments = [{
|
||||
"content": record["record_comment"],
|
||||
"account": ""
|
||||
}] if record.get("record_comment") else [{
|
||||
"content": "",
|
||||
"account": ""
|
||||
}]
|
||||
|
||||
# Add the formatted record to rrsets list
|
||||
rrsets.append({
|
||||
"name": record_name,
|
||||
"type": record["record_type"],
|
||||
"ttl": int(record["record_ttl"]),
|
||||
"records": [record_content],
|
||||
"comments": record_comments
|
||||
})
|
||||
|
||||
# Group the records which has the same name and type.
|
||||
# The rrset then has multiple records inside.
|
||||
transformed_rrsets = []
|
||||
|
||||
# Sort the list before using groupby
|
||||
rrsets = sorted(rrsets, key=lambda r: (r['name'], r['type']))
|
||||
groups = groupby(rrsets, key=lambda r: (r['name'], r['type']))
|
||||
for _k, v in groups:
|
||||
group = list(v)
|
||||
transformed_rrsets.append(self.merge_rrsets(group))
|
||||
|
||||
return transformed_rrsets
|
||||
|
||||
def compare(self, domain_name, submitted_records):
|
||||
"""
|
||||
Compare the submitted records with PDNS's actual data
|
||||
|
||||
Args:
|
||||
domain_name(str): The zone name
|
||||
submitted_records(list): List of records submitted from PDA datatable
|
||||
|
||||
Returns:
|
||||
new_rrsets(list): List of rrsets to be added
|
||||
del_rrsets(list): List of rrsets to be deleted
|
||||
"""
|
||||
# Create submitted rrsets from submitted records
|
||||
submitted_rrsets = self.build_rrsets(domain_name, submitted_records)
|
||||
current_app.logger.debug(
|
||||
"submitted_rrsets_data: \n{}".format(utils.pretty_json(submitted_rrsets)))
|
||||
|
||||
# Current domain's rrsets in PDNS
|
||||
current_rrsets = self.get_rrsets(domain_name)
|
||||
current_app.logger.debug("current_rrsets_data: \n{}".format(
|
||||
utils.pretty_json(current_rrsets)))
|
||||
|
||||
# Remove comment's 'modified_at' key
|
||||
# PDNS API always return the comments with modified_at
|
||||
# info, we have to remove it to be able to do the dict
|
||||
# comparison between current and submitted rrsets
|
||||
for r in current_rrsets:
|
||||
for comment in r['comments']:
|
||||
if 'modified_at' in comment:
|
||||
del comment['modified_at']
|
||||
|
||||
# List of rrsets to be added
|
||||
new_rrsets = {"rrsets": []}
|
||||
for r in submitted_rrsets:
|
||||
if r not in current_rrsets and r['type'] in Setting(
|
||||
).get_records_allow_to_edit():
|
||||
r['changetype'] = 'REPLACE'
|
||||
new_rrsets["rrsets"].append(r)
|
||||
|
||||
# List of rrsets to be removed
|
||||
del_rrsets = {"rrsets": []}
|
||||
for r in current_rrsets:
|
||||
if r not in submitted_rrsets and r['type'] in Setting(
|
||||
).get_records_allow_to_edit() and r['type'] != 'SOA':
|
||||
r['changetype'] = 'DELETE'
|
||||
del_rrsets["rrsets"].append(r)
|
||||
|
||||
current_app.logger.debug("new_rrsets: \n{}".format(utils.pretty_json(new_rrsets)))
|
||||
current_app.logger.debug("del_rrsets: \n{}".format(utils.pretty_json(del_rrsets)))
|
||||
|
||||
return new_rrsets, del_rrsets
|
||||
|
||||
def apply_rrsets(self, domain_name, rrsets):
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain_name)),
|
||||
headers=headers,
|
||||
method='PATCH',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=rrsets)
|
||||
return jdata
|
||||
|
||||
def apply(self, domain_name, submitted_records):
|
||||
"""
|
||||
Apply record changes to a domain. This function
|
||||
will make 2 calls to the PDNS API to DELETE and
|
||||
REPLACE records (rrsets)
|
||||
"""
|
||||
current_app.logger.debug(
|
||||
"submitted_records: {}".format(submitted_records))
|
||||
|
||||
# Get the list of rrsets to be added and deleted
|
||||
new_rrsets, del_rrsets = self.compare(domain_name, submitted_records)
|
||||
|
||||
# Remove blank comments from rrsets for compatibility with some backends
|
||||
def remove_blank_comments(rrset):
|
||||
if not rrset['comments']:
|
||||
del rrset['comments']
|
||||
elif isinstance(rrset['comments'], list):
|
||||
# Merge all non-blank comment values into a list
|
||||
merged_comments = [
|
||||
v
|
||||
for c in rrset['comments']
|
||||
for v in c.values()
|
||||
if v
|
||||
]
|
||||
# Delete comment if all values are blank (len(merged_comments) == 0)
|
||||
if not merged_comments:
|
||||
del rrset['comments']
|
||||
|
||||
for r in new_rrsets['rrsets']:
|
||||
remove_blank_comments(r)
|
||||
|
||||
for r in del_rrsets['rrsets']:
|
||||
remove_blank_comments(r)
|
||||
|
||||
# Submit the changes to PDNS API
|
||||
try:
|
||||
if del_rrsets["rrsets"]:
|
||||
result = self.apply_rrsets(domain_name, del_rrsets)
|
||||
if 'error' in result.keys():
|
||||
current_app.logger.error(
|
||||
'Cannot apply record changes with deleting rrsets step. PDNS error: {}'
|
||||
.format(result['error']))
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': result['error'].replace("'", "")
|
||||
}
|
||||
|
||||
if new_rrsets["rrsets"]:
|
||||
result = self.apply_rrsets(domain_name, new_rrsets)
|
||||
if 'error' in result.keys():
|
||||
current_app.logger.error(
|
||||
'Cannot apply record changes with adding rrsets step. PDNS error: {}'
|
||||
.format(result['error']))
|
||||
|
||||
# rollback - re-add the removed record if the adding operation is failed.
|
||||
if del_rrsets["rrsets"]:
|
||||
rollback_rrests = del_rrsets
|
||||
for r in del_rrsets["rrsets"]:
|
||||
r['changetype'] = 'REPLACE'
|
||||
rollback = self.apply_rrsets(domain_name, rollback_rrests)
|
||||
if 'error' in rollback.keys():
|
||||
return dict(status='error',
|
||||
msg='Failed to apply changes. Cannot rollback previous failed operation: {}'
|
||||
.format(rollback['error'].replace("'", "")))
|
||||
else:
|
||||
return dict(status='error',
|
||||
msg='Failed to apply changes. Rolled back previous failed operation: {}'
|
||||
.format(result['error'].replace("'", "")))
|
||||
else:
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg': result['error'].replace("'", "")
|
||||
}
|
||||
|
||||
self.auto_ptr(domain_name, new_rrsets, del_rrsets)
|
||||
self.update_db_serial(domain_name)
|
||||
current_app.logger.info('Record was applied successfully.')
|
||||
return {'status': 'ok', 'msg': 'Record was applied successfully', 'data': (new_rrsets, del_rrsets)}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Cannot apply record changes to domain {0}. Error: {1}".format(
|
||||
domain_name, e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
|
||||
def auto_ptr(self, domain_name, new_rrsets, del_rrsets):
|
||||
"""
|
||||
Add auto-ptr records
|
||||
"""
|
||||
# Check if auto_ptr is enabled for this domain
|
||||
auto_ptr_enabled = False
|
||||
if Setting().get('auto_ptr'):
|
||||
auto_ptr_enabled = True
|
||||
else:
|
||||
domain_obj = Domain.query.filter(Domain.name == domain_name).first()
|
||||
domain_setting = DomainSetting.query.filter(
|
||||
DomainSetting.domain == domain_obj).filter(
|
||||
DomainSetting.setting == 'auto_ptr').first()
|
||||
auto_ptr_enabled = strtobool(
|
||||
domain_setting.value) if domain_setting else False
|
||||
|
||||
# If it is enabled, we create/delete the PTR records automatically
|
||||
if auto_ptr_enabled:
|
||||
try:
|
||||
RECORD_TYPE_TO_PTR = ['A', 'AAAA']
|
||||
new_rrsets = new_rrsets['rrsets']
|
||||
del_rrsets = del_rrsets['rrsets']
|
||||
|
||||
if not new_rrsets and not del_rrsets:
|
||||
msg = 'No changes detected. Skipping auto ptr...'
|
||||
current_app.logger.info(msg)
|
||||
return {'status': 'ok', 'msg': msg}
|
||||
|
||||
new_rrsets = [
|
||||
r for r in new_rrsets if r['type'] in RECORD_TYPE_TO_PTR
|
||||
]
|
||||
del_rrsets = [
|
||||
r for r in del_rrsets if r['type'] in RECORD_TYPE_TO_PTR
|
||||
]
|
||||
|
||||
d = Domain()
|
||||
for r in new_rrsets:
|
||||
for record in r['records']:
|
||||
# Format the reverse record name
|
||||
# It is the reverse of forward record's content.
|
||||
reverse_host_address = dns.reversename.from_address(
|
||||
record['content']).to_text()
|
||||
|
||||
# Create the reverse domain name in PDNS
|
||||
domain_reverse_name = d.get_reverse_domain_name(
|
||||
reverse_host_address)
|
||||
d.create_reverse_domain(domain_name,
|
||||
domain_reverse_name)
|
||||
|
||||
# Build the rrset for reverse zone updating
|
||||
rrset_data = [{
|
||||
"changetype":
|
||||
"REPLACE",
|
||||
"name":
|
||||
reverse_host_address,
|
||||
"ttl":
|
||||
r['ttl'],
|
||||
"type":
|
||||
"PTR",
|
||||
"records": [{
|
||||
"content": r['name'],
|
||||
"disabled": record['disabled']
|
||||
}],
|
||||
"comments": []
|
||||
}]
|
||||
|
||||
# Format the rrset
|
||||
rrset = {"rrsets": rrset_data}
|
||||
self.add(domain_reverse_name, rrset)
|
||||
|
||||
for r in del_rrsets:
|
||||
for record in r['records']:
|
||||
# Format the reverse record name
|
||||
# It is the reverse of forward record's content.
|
||||
reverse_host_address = dns.reversename.from_address(
|
||||
record['content']).to_text()
|
||||
|
||||
# Create the reverse domain name in PDNS
|
||||
domain_reverse_name = d.get_reverse_domain_name(
|
||||
reverse_host_address)
|
||||
d.create_reverse_domain(domain_name,
|
||||
domain_reverse_name)
|
||||
|
||||
# Delete the reverse zone
|
||||
self.name = reverse_host_address
|
||||
self.type = 'PTR'
|
||||
self.data = record['content']
|
||||
self.delete(domain_reverse_name)
|
||||
return {
|
||||
'status': 'ok',
|
||||
'msg': 'Auto-PTR record was updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Cannot update auto-ptr record changes to domain {0}. Error: {1}"
|
||||
.format(domain_name, e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return {
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'Auto-PTR creation failed. There was something wrong, please contact administrator.'
|
||||
}
|
||||
|
||||
def delete(self, domain):
|
||||
"""
|
||||
Delete a record from domain
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
data = {
|
||||
"rrsets": [{
|
||||
"name": self.name.rstrip('.') + '.',
|
||||
"type": self.type,
|
||||
"changetype": "DELETE",
|
||||
"records": []
|
||||
}]
|
||||
}
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='PATCH',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=data)
|
||||
current_app.logger.debug(jdata)
|
||||
return {'status': 'ok', 'msg': 'Record was removed successfully'}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Cannot remove record {0}/{1}/{2} from domain {3}. DETAIL: {4}"
|
||||
.format(self.name, self.type, self.data, domain, e))
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
|
||||
def is_allowed_edit(self):
|
||||
"""
|
||||
Check if record is allowed to edit
|
||||
"""
|
||||
return self.type in Setting().get_records_allow_to_edit()
|
||||
|
||||
def is_allowed_delete(self):
|
||||
"""
|
||||
Check if record is allowed to removed
|
||||
"""
|
||||
return (self.type in Setting().get_records_allow_to_edit()
|
||||
and self.type != 'SOA')
|
||||
|
||||
def exists(self, domain):
|
||||
"""
|
||||
Check if record is present within domain records, and if it's present set self to found record
|
||||
"""
|
||||
rrsets = self.get_rrsets(domain)
|
||||
for r in rrsets:
|
||||
if r['name'].rstrip('.') == self.name and r['type'] == self.type and r['records']:
|
||||
self.type = r['type']
|
||||
self.status = r['records'][0]['disabled']
|
||||
self.ttl = r['ttl']
|
||||
self.data = r['records'][0]['content']
|
||||
return True
|
||||
return False
|
||||
|
||||
def update(self, domain, content):
|
||||
"""
|
||||
Update single record
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
data = {
|
||||
"rrsets": [{
|
||||
"name":
|
||||
self.name + '.',
|
||||
"type":
|
||||
self.type,
|
||||
"ttl":
|
||||
self.ttl,
|
||||
"changetype":
|
||||
"REPLACE",
|
||||
"records": [{
|
||||
"content": content,
|
||||
"disabled": self.status,
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
try:
|
||||
utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='PATCH',
|
||||
verify=Setting().get('verify_ssl_connections'),
|
||||
data=data)
|
||||
current_app.logger.debug("dyndns data: {0}".format(data))
|
||||
return {'status': 'ok', 'msg': 'Record was updated successfully'}
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Cannot add record {0}/{1}/{2} to domain {3}. DETAIL: {4}".
|
||||
format(self.name, self.type, self.data, domain, e))
|
||||
return {
|
||||
'status': 'error',
|
||||
'msg':
|
||||
'There was something wrong, please contact administrator'
|
||||
}
|
||||
|
||||
def update_db_serial(self, domain):
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/localhost/zones/{0}'.format(domain)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='GET',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
serial = jdata['serial']
|
||||
|
||||
domain = Domain.query.filter(Domain.name == domain).first()
|
||||
if domain:
|
||||
domain.serial = serial
|
||||
db.session.commit()
|
||||
return {
|
||||
'status': True,
|
||||
'msg': 'Synced local serial for domain name {0}'.format(domain)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'status': False,
|
||||
'msg':
|
||||
'Could not find domain name {0} in local db'.format(domain)
|
||||
}
|
27
powerdnsadmin/models/record_entry.py
Normal file
27
powerdnsadmin/models/record_entry.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
class RecordEntry(object):
|
||||
"""
|
||||
This is not a model, it's just an object
|
||||
which will store records entries from PowerDNS API
|
||||
"""
|
||||
def __init__(self,
|
||||
name=None,
|
||||
type=None,
|
||||
status=None,
|
||||
ttl=None,
|
||||
data=None,
|
||||
comment=None,
|
||||
is_allowed_edit=False):
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.status = status
|
||||
self.ttl = ttl
|
||||
self.data = data
|
||||
self.comment = comment
|
||||
self._is_allowed_edit = is_allowed_edit
|
||||
self._is_allowed_delete = is_allowed_edit and self.type != 'SOA'
|
||||
|
||||
def is_allowed_edit(self):
|
||||
return self._is_allowed_edit
|
||||
|
||||
def is_allowed_delete(self):
|
||||
return self._is_allowed_delete
|
23
powerdnsadmin/models/role.py
Normal file
23
powerdnsadmin/models/role.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from .base import db
|
||||
|
||||
|
||||
class Role(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(64), index=True, unique=True)
|
||||
description = db.Column(db.String(128))
|
||||
users = db.relationship('User', backref='role', lazy=True)
|
||||
apikeys = db.relationship('ApiKey', back_populates='role', lazy=True)
|
||||
|
||||
def __init__(self, id=None, name=None, description=None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
# allow database autoincrement to do its own ID assignments
|
||||
def __init__(self, name=None, description=None):
|
||||
self.id = None
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
def __repr__(self):
|
||||
return '<Role {0}r>'.format(self.name)
|
86
powerdnsadmin/models/server.py
Normal file
86
powerdnsadmin/models/server.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
import traceback
|
||||
from flask import current_app
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from ..lib import utils
|
||||
from .setting import Setting
|
||||
|
||||
|
||||
class Server(object):
|
||||
"""
|
||||
This is not a model, it's just an object
|
||||
which be assigned data from PowerDNS API
|
||||
"""
|
||||
def __init__(self, server_id=None, server_config=None):
|
||||
self.server_id = server_id
|
||||
self.server_config = server_config
|
||||
# PDNS configs
|
||||
self.PDNS_STATS_URL = Setting().get('pdns_api_url')
|
||||
self.PDNS_API_KEY = Setting().get('pdns_api_key')
|
||||
self.PDNS_VERSION = Setting().get('pdns_version')
|
||||
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
|
||||
|
||||
def get_config(self):
|
||||
"""
|
||||
Get server config
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/{0}/config'.format(self.server_id)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='GET',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
return jdata
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Can not get server configuration. DETAIL: {0}".format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return []
|
||||
|
||||
def get_statistic(self):
|
||||
"""
|
||||
Get server statistics
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/{0}/statistics'.format(self.server_id)),
|
||||
headers=headers,
|
||||
timeout=int(Setting().get('pdns_api_timeout')),
|
||||
method='GET',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
return jdata
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Can not get server statistics. DETAIL: {0}".format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return []
|
||||
|
||||
def global_search(self, object_type='all', query=''):
|
||||
"""
|
||||
Search zone/record/comment directly from PDNS API
|
||||
"""
|
||||
headers = {'X-API-Key': self.PDNS_API_KEY}
|
||||
|
||||
try:
|
||||
jdata = utils.fetch_json(urljoin(
|
||||
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
|
||||
'/servers/{}/search-data?object_type={}&q={}'.format(
|
||||
self.server_id, object_type, query)),
|
||||
headers=headers,
|
||||
timeout=int(
|
||||
Setting().get('pdns_api_timeout')),
|
||||
method='GET',
|
||||
verify=Setting().get('verify_ssl_connections'))
|
||||
return jdata
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
"Can not make global search. DETAIL: {0}".format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return []
|
320
powerdnsadmin/models/setting.py
Normal file
320
powerdnsadmin/models/setting.py
Normal file
|
@ -0,0 +1,320 @@
|
|||
import sys
|
||||
import traceback
|
||||
|
||||
import pytimeparse
|
||||
from ast import literal_eval
|
||||
from distutils.util import strtobool
|
||||
from flask import current_app
|
||||
|
||||
from .base import db
|
||||
|
||||
|
||||
class Setting(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(64))
|
||||
value = db.Column(db.Text())
|
||||
|
||||
defaults = {
|
||||
'maintenance': False,
|
||||
'fullscreen_layout': True,
|
||||
'record_helper': True,
|
||||
'login_ldap_first': True,
|
||||
'default_record_table_size': 15,
|
||||
'default_domain_table_size': 10,
|
||||
'auto_ptr': False,
|
||||
'record_quick_edit': True,
|
||||
'pretty_ipv6_ptr': False,
|
||||
'dnssec_admins_only': False,
|
||||
'allow_user_create_domain': False,
|
||||
'allow_user_remove_domain': False,
|
||||
'allow_user_view_history': False,
|
||||
'delete_sso_accounts': False,
|
||||
'bg_domain_updates': False,
|
||||
'enable_api_rr_history': True,
|
||||
'site_name': 'PowerDNS-Admin',
|
||||
'site_url': 'http://localhost:9191',
|
||||
'session_timeout': 10,
|
||||
'warn_session_timeout': True,
|
||||
'pdns_api_url': '',
|
||||
'pdns_api_key': '',
|
||||
'pdns_api_timeout': 30,
|
||||
'pdns_version': '4.1.1',
|
||||
'verify_ssl_connections': True,
|
||||
'local_db_enabled': True,
|
||||
'signup_enabled': True,
|
||||
'autoprovisioning': False,
|
||||
'urn_value':'',
|
||||
'autoprovisioning_attribute': '',
|
||||
'purge': False,
|
||||
'verify_user_email': False,
|
||||
'ldap_enabled': False,
|
||||
'ldap_type': 'ldap',
|
||||
'ldap_uri': '',
|
||||
'ldap_base_dn': '',
|
||||
'ldap_admin_username': '',
|
||||
'ldap_admin_password': '',
|
||||
'ldap_filter_basic': '',
|
||||
'ldap_filter_group': '',
|
||||
'ldap_filter_username': '',
|
||||
'ldap_filter_groupname': '',
|
||||
'ldap_sg_enabled': False,
|
||||
'ldap_admin_group': '',
|
||||
'ldap_operator_group': '',
|
||||
'ldap_user_group': '',
|
||||
'ldap_domain': '',
|
||||
'github_oauth_enabled': False,
|
||||
'github_oauth_key': '',
|
||||
'github_oauth_secret': '',
|
||||
'github_oauth_scope': 'email',
|
||||
'github_oauth_api_url': 'https://api.github.com/user',
|
||||
'github_oauth_token_url':
|
||||
'https://github.com/login/oauth/access_token',
|
||||
'github_oauth_authorize_url':
|
||||
'https://github.com/login/oauth/authorize',
|
||||
'google_oauth_enabled': False,
|
||||
'google_oauth_client_id': '',
|
||||
'google_oauth_client_secret': '',
|
||||
'google_token_url': 'https://oauth2.googleapis.com/token',
|
||||
'google_oauth_scope': 'openid email profile',
|
||||
'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
'google_base_url': 'https://www.googleapis.com/oauth2/v3/',
|
||||
'azure_oauth_enabled': False,
|
||||
'azure_oauth_key': '',
|
||||
'azure_oauth_secret': '',
|
||||
'azure_oauth_scope': 'User.Read openid email profile',
|
||||
'azure_oauth_api_url': 'https://graph.microsoft.com/v1.0/',
|
||||
'azure_oauth_token_url':
|
||||
'https://login.microsoftonline.com/[tenancy]/oauth2/v2.0/token',
|
||||
'azure_oauth_authorize_url':
|
||||
'https://login.microsoftonline.com/[tenancy]/oauth2/v2.0/authorize',
|
||||
'azure_sg_enabled': False,
|
||||
'azure_admin_group': '',
|
||||
'azure_operator_group': '',
|
||||
'azure_user_group': '',
|
||||
'azure_group_accounts_enabled': False,
|
||||
'azure_group_accounts_name': 'displayName',
|
||||
'azure_group_accounts_name_re': '',
|
||||
'azure_group_accounts_description': 'description',
|
||||
'azure_group_accounts_description_re': '',
|
||||
'oidc_oauth_enabled': False,
|
||||
'oidc_oauth_key': '',
|
||||
'oidc_oauth_secret': '',
|
||||
'oidc_oauth_scope': 'email',
|
||||
'oidc_oauth_api_url': '',
|
||||
'oidc_oauth_token_url': '',
|
||||
'oidc_oauth_authorize_url': '',
|
||||
'oidc_oauth_logout_url': '',
|
||||
'oidc_oauth_username': 'preferred_username',
|
||||
'oidc_oauth_firstname': 'given_name',
|
||||
'oidc_oauth_last_name': 'family_name',
|
||||
'oidc_oauth_email': 'email',
|
||||
'oidc_oauth_account_name_property': '',
|
||||
'oidc_oauth_account_description_property': '',
|
||||
'forward_records_allow_edit': {
|
||||
'A': True,
|
||||
'AAAA': True,
|
||||
'AFSDB': False,
|
||||
'ALIAS': False,
|
||||
'CAA': True,
|
||||
'CERT': False,
|
||||
'CDNSKEY': False,
|
||||
'CDS': False,
|
||||
'CNAME': True,
|
||||
'DNSKEY': False,
|
||||
'DNAME': False,
|
||||
'DS': False,
|
||||
'HINFO': False,
|
||||
'KEY': False,
|
||||
'LOC': True,
|
||||
'LUA': False,
|
||||
'MX': True,
|
||||
'NAPTR': False,
|
||||
'NS': True,
|
||||
'NSEC': False,
|
||||
'NSEC3': False,
|
||||
'NSEC3PARAM': False,
|
||||
'OPENPGPKEY': False,
|
||||
'PTR': True,
|
||||
'RP': False,
|
||||
'RRSIG': False,
|
||||
'SOA': False,
|
||||
'SPF': True,
|
||||
'SSHFP': False,
|
||||
'SRV': True,
|
||||
'TKEY': False,
|
||||
'TSIG': False,
|
||||
'TLSA': False,
|
||||
'SMIMEA': False,
|
||||
'TXT': True,
|
||||
'URI': False
|
||||
},
|
||||
'reverse_records_allow_edit': {
|
||||
'A': False,
|
||||
'AAAA': False,
|
||||
'AFSDB': False,
|
||||
'ALIAS': False,
|
||||
'CAA': False,
|
||||
'CERT': False,
|
||||
'CDNSKEY': False,
|
||||
'CDS': False,
|
||||
'CNAME': False,
|
||||
'DNSKEY': False,
|
||||
'DNAME': False,
|
||||
'DS': False,
|
||||
'HINFO': False,
|
||||
'KEY': False,
|
||||
'LOC': True,
|
||||
'LUA': False,
|
||||
'MX': False,
|
||||
'NAPTR': False,
|
||||
'NS': True,
|
||||
'NSEC': False,
|
||||
'NSEC3': False,
|
||||
'NSEC3PARAM': False,
|
||||
'OPENPGPKEY': False,
|
||||
'PTR': True,
|
||||
'RP': False,
|
||||
'RRSIG': False,
|
||||
'SOA': False,
|
||||
'SPF': False,
|
||||
'SSHFP': False,
|
||||
'SRV': False,
|
||||
'TKEY': False,
|
||||
'TSIG': False,
|
||||
'TLSA': False,
|
||||
'SMIMEA': False,
|
||||
'TXT': True,
|
||||
'URI': False
|
||||
},
|
||||
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
|
||||
'otp_field_enabled': True,
|
||||
'custom_css': '',
|
||||
'otp_force': False,
|
||||
'max_history_records': 1000
|
||||
}
|
||||
|
||||
def __init__(self, id=None, name=None, value=None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.value = value
|
||||
|
||||
# allow database autoincrement to do its own ID assignments
|
||||
def __init__(self, name=None, value=None):
|
||||
self.id = None
|
||||
self.name = name
|
||||
self.value = value
|
||||
|
||||
def set_maintenance(self, mode):
|
||||
maintenance = Setting.query.filter(
|
||||
Setting.name == 'maintenance').first()
|
||||
|
||||
if maintenance is None:
|
||||
value = self.defaults['maintenance']
|
||||
maintenance = Setting(name='maintenance', value=str(value))
|
||||
db.session.add(maintenance)
|
||||
|
||||
mode = str(mode)
|
||||
|
||||
try:
|
||||
if maintenance.value != mode:
|
||||
maintenance.value = mode
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error('Cannot set maintenance to {0}. DETAIL: {1}'.format(
|
||||
mode, e))
|
||||
current_app.logger.debug(traceback.format_exec())
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
def toggle(self, setting):
|
||||
current_setting = Setting.query.filter(Setting.name == setting).first()
|
||||
|
||||
if current_setting is None:
|
||||
value = self.defaults[setting]
|
||||
current_setting = Setting(name=setting, value=str(value))
|
||||
db.session.add(current_setting)
|
||||
|
||||
try:
|
||||
if current_setting.value == "True":
|
||||
current_setting.value = "False"
|
||||
else:
|
||||
current_setting.value = "True"
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error('Cannot toggle setting {0}. DETAIL: {1}'.format(
|
||||
setting, e))
|
||||
current_app.logger.debug(traceback.format_exec())
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
def set(self, setting, value):
|
||||
current_setting = Setting.query.filter(Setting.name == setting).first()
|
||||
|
||||
if current_setting is None:
|
||||
current_setting = Setting(name=setting, value=None)
|
||||
db.session.add(current_setting)
|
||||
|
||||
value = str(value)
|
||||
|
||||
try:
|
||||
current_setting.value = value
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error('Cannot edit setting {0}. DETAIL: {1}'.format(
|
||||
setting, e))
|
||||
current_app.logger.debug(traceback.format_exec())
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
def get(self, setting):
|
||||
if setting in self.defaults:
|
||||
|
||||
if setting.upper() in current_app.config:
|
||||
result = current_app.config[setting.upper()]
|
||||
else:
|
||||
result = self.query.filter(Setting.name == setting).first()
|
||||
|
||||
if result is not None:
|
||||
if hasattr(result,'value'):
|
||||
result = result.value
|
||||
return strtobool(result) if result in [
|
||||
'True', 'False'
|
||||
] else result
|
||||
else:
|
||||
return self.defaults[setting]
|
||||
else:
|
||||
current_app.logger.error('Unknown setting queried: {0}'.format(setting))
|
||||
|
||||
def get_records_allow_to_edit(self):
|
||||
return list(
|
||||
set(self.get_forward_records_allow_to_edit() +
|
||||
self.get_reverse_records_allow_to_edit()))
|
||||
|
||||
def get_forward_records_allow_to_edit(self):
|
||||
records = self.get('forward_records_allow_edit')
|
||||
f_records = literal_eval(records) if isinstance(records,
|
||||
str) else records
|
||||
r_name = [r for r in f_records if f_records[r]]
|
||||
# Sort alphabetically if python version is smaller than 3.6
|
||||
if sys.version_info[0] < 3 or (sys.version_info[0] == 3
|
||||
and sys.version_info[1] < 6):
|
||||
r_name.sort()
|
||||
return r_name
|
||||
|
||||
def get_reverse_records_allow_to_edit(self):
|
||||
records = self.get('reverse_records_allow_edit')
|
||||
r_records = literal_eval(records) if isinstance(records,
|
||||
str) else records
|
||||
r_name = [r for r in r_records if r_records[r]]
|
||||
# Sort alphabetically if python version is smaller than 3.6
|
||||
if sys.version_info[0] < 3 or (sys.version_info[0] == 3
|
||||
and sys.version_info[1] < 6):
|
||||
r_name.sort()
|
||||
return r_name
|
||||
|
||||
def get_ttl_options(self):
|
||||
return [(pytimeparse.parse(ttl), ttl)
|
||||
for ttl in self.get('ttl_options').split(',')]
|
807
powerdnsadmin/models/user.py
Normal file
807
powerdnsadmin/models/user.py
Normal file
|
@ -0,0 +1,807 @@
|
|||
import os
|
||||
import base64
|
||||
import traceback
|
||||
import bcrypt
|
||||
import pyotp
|
||||
import ldap
|
||||
import ldap.filter
|
||||
from flask import current_app
|
||||
from flask_login import AnonymousUserMixin
|
||||
from sqlalchemy import orm
|
||||
import qrcode as qrc
|
||||
import qrcode.image.svg as qrc_svg
|
||||
from io import BytesIO
|
||||
|
||||
from .base import db
|
||||
from .role import Role
|
||||
from .setting import Setting
|
||||
from .domain_user import DomainUser
|
||||
from .account_user import AccountUser
|
||||
|
||||
|
||||
class Anonymous(AnonymousUserMixin):
|
||||
def __init__(self):
|
||||
self.username = 'Anonymous'
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), index=True, unique=True)
|
||||
password = db.Column(db.String(64))
|
||||
firstname = db.Column(db.String(64))
|
||||
lastname = db.Column(db.String(64))
|
||||
email = db.Column(db.String(128))
|
||||
otp_secret = db.Column(db.String(16))
|
||||
confirmed = db.Column(db.SmallInteger, nullable=False, default=0)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
|
||||
accounts = None
|
||||
|
||||
def __init__(self,
|
||||
id=None,
|
||||
username=None,
|
||||
password=None,
|
||||
plain_text_password=None,
|
||||
firstname=None,
|
||||
lastname=None,
|
||||
role_id=None,
|
||||
email=None,
|
||||
otp_secret=None,
|
||||
confirmed=False,
|
||||
reload_info=True):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.plain_text_password = plain_text_password
|
||||
self.firstname = firstname
|
||||
self.lastname = lastname
|
||||
self.role_id = role_id
|
||||
self.email = email
|
||||
self.otp_secret = otp_secret
|
||||
self.confirmed = confirmed
|
||||
|
||||
if reload_info:
|
||||
user_info = self.get_user_info_by_id(
|
||||
) if id else self.get_user_info_by_username()
|
||||
|
||||
if user_info:
|
||||
self.id = user_info.id
|
||||
self.username = user_info.username
|
||||
self.firstname = user_info.firstname
|
||||
self.lastname = user_info.lastname
|
||||
self.email = user_info.email
|
||||
self.role_id = user_info.role_id
|
||||
self.otp_secret = user_info.otp_secret
|
||||
self.confirmed = user_info.confirmed
|
||||
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
def get_id(self):
|
||||
try:
|
||||
return unicode(self.id) # python 2
|
||||
except NameError:
|
||||
return str(self.id) # python 3
|
||||
|
||||
def __repr__(self):
|
||||
return '<User {0}>'.format(self.username)
|
||||
|
||||
def get_totp_uri(self):
|
||||
return "otpauth://totp/PowerDNS-Admin:{0}?secret={1}&issuer=PowerDNS-Admin".format(
|
||||
self.username, self.otp_secret)
|
||||
|
||||
def verify_totp(self, token):
|
||||
totp = pyotp.TOTP(self.otp_secret)
|
||||
return totp.verify(token)
|
||||
|
||||
def get_hashed_password(self, plain_text_password=None):
|
||||
# Hash a password for the first time
|
||||
# (Using bcrypt, the salt is saved into the hash itself)
|
||||
if plain_text_password is None:
|
||||
return plain_text_password
|
||||
|
||||
pw = plain_text_password if plain_text_password else self.plain_text_password
|
||||
return bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
|
||||
|
||||
def check_password(self, hashed_password):
|
||||
# Check hashed password. Using bcrypt, the salt is saved into the hash itself
|
||||
if (self.plain_text_password):
|
||||
return bcrypt.checkpw(self.plain_text_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8'))
|
||||
return False
|
||||
|
||||
def get_user_info_by_id(self):
|
||||
user_info = User.query.get(int(self.id))
|
||||
return user_info
|
||||
|
||||
def get_user_info_by_username(self):
|
||||
user_info = User.query.filter(User.username == self.username).first()
|
||||
return user_info
|
||||
|
||||
def ldap_init_conn(self):
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
conn = ldap.initialize(Setting().get('ldap_uri'))
|
||||
conn.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
|
||||
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||
conn.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
|
||||
conn.set_option(ldap.OPT_X_TLS_DEMAND, True)
|
||||
conn.set_option(ldap.OPT_DEBUG_LEVEL, 255)
|
||||
conn.protocol_version = ldap.VERSION3
|
||||
return conn
|
||||
|
||||
def ldap_search(self, searchFilter, baseDN, retrieveAttributes=None):
|
||||
searchScope = ldap.SCOPE_SUBTREE
|
||||
|
||||
try:
|
||||
conn = self.ldap_init_conn()
|
||||
if Setting().get('ldap_type') == 'ad':
|
||||
conn.simple_bind_s(
|
||||
"{0}@{1}".format(self.username,
|
||||
Setting().get('ldap_domain')),
|
||||
self.password)
|
||||
else:
|
||||
conn.simple_bind_s(Setting().get('ldap_admin_username'),
|
||||
Setting().get('ldap_admin_password'))
|
||||
ldap_result_id = conn.search(baseDN, searchScope, searchFilter,
|
||||
retrieveAttributes)
|
||||
result_set = []
|
||||
|
||||
while 1:
|
||||
result_type, result_data = conn.result(ldap_result_id, 0)
|
||||
if (result_data == []):
|
||||
break
|
||||
else:
|
||||
if result_type == ldap.RES_SEARCH_ENTRY:
|
||||
result_set.append(result_data)
|
||||
return result_set
|
||||
|
||||
except ldap.LDAPError as e:
|
||||
current_app.logger.error(e)
|
||||
current_app.logger.debug('baseDN: {0}'.format(baseDN))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
def ldap_auth(self, ldap_username, password):
|
||||
try:
|
||||
conn = self.ldap_init_conn()
|
||||
conn.simple_bind_s(ldap_username, password)
|
||||
return True
|
||||
except ldap.LDAPError as e:
|
||||
current_app.logger.error(e)
|
||||
return False
|
||||
|
||||
def ad_recursive_groups(self, groupDN):
|
||||
"""
|
||||
Recursively list groups belonging to a group. It will allow checking deep in the Active Directory
|
||||
whether a user is allowed to enter or not
|
||||
"""
|
||||
LDAP_BASE_DN = Setting().get('ldap_base_dn')
|
||||
groupSearchFilter = "(&(objectcategory=group)(member=%s))" % ldap.filter.escape_filter_chars(
|
||||
groupDN)
|
||||
result = [groupDN]
|
||||
try:
|
||||
groups = self.ldap_search(groupSearchFilter, LDAP_BASE_DN)
|
||||
for group in groups:
|
||||
result += [group[0][0]]
|
||||
if 'memberOf' in group[0][1]:
|
||||
for member in group[0][1]['memberOf']:
|
||||
result += self.ad_recursive_groups(
|
||||
member.decode("utf-8"))
|
||||
return result
|
||||
except ldap.LDAPError as e:
|
||||
current_app.logger.exception("Recursive AD Group search error")
|
||||
return result
|
||||
|
||||
def is_validate(self, method, src_ip='', trust_user=False):
|
||||
"""
|
||||
Validate user credential
|
||||
"""
|
||||
role_name = 'User'
|
||||
|
||||
if method == 'LOCAL':
|
||||
user_info = User.query.filter(
|
||||
User.username == self.username).first()
|
||||
|
||||
if user_info:
|
||||
if trust_user or (user_info.password and self.check_password(
|
||||
user_info.password)):
|
||||
current_app.logger.info(
|
||||
'User "{0}" logged in successfully. Authentication request from {1}'
|
||||
.format(self.username, src_ip))
|
||||
return True
|
||||
current_app.logger.error(
|
||||
'User "{0}" inputted a wrong password. Authentication request from {1}'
|
||||
.format(self.username, src_ip))
|
||||
return False
|
||||
|
||||
current_app.logger.warning(
|
||||
'User "{0}" does not exist. Authentication request from {1}'.
|
||||
format(self.username, src_ip))
|
||||
return False
|
||||
|
||||
if method == 'LDAP':
|
||||
LDAP_TYPE = Setting().get('ldap_type')
|
||||
LDAP_BASE_DN = Setting().get('ldap_base_dn')
|
||||
LDAP_FILTER_BASIC = Setting().get('ldap_filter_basic')
|
||||
LDAP_FILTER_USERNAME = Setting().get('ldap_filter_username')
|
||||
LDAP_FILTER_GROUP = Setting().get('ldap_filter_group')
|
||||
LDAP_FILTER_GROUPNAME = Setting().get('ldap_filter_groupname')
|
||||
LDAP_ADMIN_GROUP = Setting().get('ldap_admin_group')
|
||||
LDAP_OPERATOR_GROUP = Setting().get('ldap_operator_group')
|
||||
LDAP_USER_GROUP = Setting().get('ldap_user_group')
|
||||
LDAP_GROUP_SECURITY_ENABLED = Setting().get('ldap_sg_enabled')
|
||||
|
||||
# validate AD user password
|
||||
if Setting().get('ldap_type') == 'ad' and not trust_user:
|
||||
ldap_username = "{0}@{1}".format(self.username,
|
||||
Setting().get('ldap_domain'))
|
||||
if not self.ldap_auth(ldap_username, self.password):
|
||||
current_app.logger.error(
|
||||
'User "{0}" input a wrong LDAP password. Authentication request from {1}'
|
||||
.format(self.username, src_ip))
|
||||
return False
|
||||
|
||||
searchFilter = "(&({0}={1}){2})".format(LDAP_FILTER_USERNAME,
|
||||
self.username,
|
||||
LDAP_FILTER_BASIC)
|
||||
current_app.logger.debug('Ldap searchFilter {0}'.format(searchFilter))
|
||||
|
||||
ldap_result = self.ldap_search(searchFilter, LDAP_BASE_DN)
|
||||
current_app.logger.debug('Ldap search result: {0}'.format(ldap_result))
|
||||
|
||||
if not ldap_result:
|
||||
current_app.logger.warning(
|
||||
'LDAP User "{0}" does not exist. Authentication request from {1}'
|
||||
.format(self.username, src_ip))
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
ldap_username = ldap.filter.escape_filter_chars(
|
||||
ldap_result[0][0][0])
|
||||
|
||||
if Setting().get('ldap_type') != 'ad' and not trust_user:
|
||||
# validate ldap user password
|
||||
if not self.ldap_auth(ldap_username, self.password):
|
||||
current_app.logger.error(
|
||||
'User "{0}" input a wrong LDAP password. Authentication request from {1}'
|
||||
.format(self.username, src_ip))
|
||||
return False
|
||||
|
||||
# check if LDAP_GROUP_SECURITY_ENABLED is True
|
||||
# user can be assigned to ADMIN or USER role.
|
||||
if LDAP_GROUP_SECURITY_ENABLED:
|
||||
try:
|
||||
if LDAP_TYPE == 'ldap':
|
||||
groupSearchFilter = "(&({0}={1}){2})".format(LDAP_FILTER_GROUPNAME, ldap_username, LDAP_FILTER_GROUP)
|
||||
current_app.logger.debug('Ldap groupSearchFilter {0}'.format(groupSearchFilter))
|
||||
if (self.ldap_search(groupSearchFilter,
|
||||
LDAP_ADMIN_GROUP)):
|
||||
role_name = 'Administrator'
|
||||
current_app.logger.info(
|
||||
'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_ADMIN_GROUP))
|
||||
elif (self.ldap_search(groupSearchFilter,
|
||||
LDAP_OPERATOR_GROUP)):
|
||||
role_name = 'Operator'
|
||||
current_app.logger.info(
|
||||
'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_OPERATOR_GROUP))
|
||||
elif (self.ldap_search(groupSearchFilter,
|
||||
LDAP_USER_GROUP)):
|
||||
current_app.logger.info(
|
||||
'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_USER_GROUP))
|
||||
else:
|
||||
current_app.logger.error(
|
||||
'User {0} is not part of the "{1}", "{2}" or "{3}" groups that allow access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_ADMIN_GROUP,
|
||||
LDAP_OPERATOR_GROUP,
|
||||
LDAP_USER_GROUP))
|
||||
return False
|
||||
elif LDAP_TYPE == 'ad':
|
||||
user_ldap_groups = []
|
||||
user_ad_member_of = ldap_result[0][0][1].get(
|
||||
'memberOf')
|
||||
|
||||
if not user_ad_member_of:
|
||||
current_app.logger.error(
|
||||
'User {0} does not belong to any group while LDAP_GROUP_SECURITY_ENABLED is ON'
|
||||
.format(self.username))
|
||||
return False
|
||||
|
||||
for group in [
|
||||
g.decode("utf-8")
|
||||
for g in user_ad_member_of
|
||||
]:
|
||||
user_ldap_groups += self.ad_recursive_groups(
|
||||
group)
|
||||
|
||||
if (LDAP_ADMIN_GROUP in user_ldap_groups):
|
||||
role_name = 'Administrator'
|
||||
current_app.logger.info(
|
||||
'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_ADMIN_GROUP))
|
||||
elif (LDAP_OPERATOR_GROUP in user_ldap_groups):
|
||||
role_name = 'Operator'
|
||||
current_app.logger.info(
|
||||
'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_OPERATOR_GROUP))
|
||||
elif (LDAP_USER_GROUP in user_ldap_groups):
|
||||
current_app.logger.info(
|
||||
'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_USER_GROUP))
|
||||
else:
|
||||
current_app.logger.error(
|
||||
'User {0} is not part of the "{1}", "{2}" or "{3}" groups that allow access to PowerDNS-Admin'
|
||||
.format(self.username,
|
||||
LDAP_ADMIN_GROUP,
|
||||
LDAP_OPERATOR_GROUP,
|
||||
LDAP_USER_GROUP))
|
||||
return False
|
||||
else:
|
||||
current_app.logger.error('Invalid LDAP type')
|
||||
return False
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'LDAP group lookup for user "{0}" has failed. Authentication request from {1}'
|
||||
.format(self.username, src_ip))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error('Wrong LDAP configuration. {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return False
|
||||
|
||||
# create user if not exist in the db
|
||||
if not User.query.filter(User.username == self.username).first():
|
||||
self.firstname = self.username
|
||||
self.lastname = ''
|
||||
try:
|
||||
# try to get user's firstname, lastname and email address from LDAP attributes
|
||||
if LDAP_TYPE == 'ldap':
|
||||
self.firstname = ldap_result[0][0][1]['givenName'][
|
||||
0].decode("utf-8")
|
||||
self.lastname = ldap_result[0][0][1]['sn'][0].decode(
|
||||
"utf-8")
|
||||
self.email = ldap_result[0][0][1]['mail'][0].decode(
|
||||
"utf-8")
|
||||
elif LDAP_TYPE == 'ad':
|
||||
self.firstname = ldap_result[0][0][1]['name'][
|
||||
0].decode("utf-8")
|
||||
self.email = ldap_result[0][0][1]['userPrincipalName'][
|
||||
0].decode("utf-8")
|
||||
except Exception as e:
|
||||
current_app.logger.warning(
|
||||
"Reading ldap data threw an exception {0}".format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
|
||||
# first register user will be in Administrator role
|
||||
if User.query.count() == 0:
|
||||
self.role_id = Role.query.filter_by(
|
||||
name='Administrator').first().id
|
||||
else:
|
||||
self.role_id = Role.query.filter_by(
|
||||
name=role_name).first().id
|
||||
|
||||
self.create_user()
|
||||
current_app.logger.info('Created user "{0}" in the DB'.format(
|
||||
self.username))
|
||||
|
||||
# user already exists in database, set their role based on group membership (if enabled)
|
||||
if LDAP_GROUP_SECURITY_ENABLED:
|
||||
self.set_role(role_name)
|
||||
|
||||
return True
|
||||
else:
|
||||
current_app.logger.error('Unsupported authentication method')
|
||||
return False
|
||||
|
||||
def create_user(self):
|
||||
"""
|
||||
If user logged in successfully via LDAP in the first time
|
||||
We will create a local user (in DB) in order to manage user
|
||||
profile such as name, roles,...
|
||||
"""
|
||||
|
||||
# Set an invalid password hash for non local users
|
||||
self.password = '*'
|
||||
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
def create_local_user(self):
|
||||
"""
|
||||
Create local user witch stores username / password in the DB
|
||||
"""
|
||||
# check if username existed
|
||||
user = User.query.filter(User.username == self.username).first()
|
||||
if user:
|
||||
return {'status': False, 'msg': 'Username is already in use'}
|
||||
|
||||
# check if email existed
|
||||
user = User.query.filter(User.email == self.email).first()
|
||||
if user:
|
||||
return {'status': False, 'msg': 'Email address is already in use'}
|
||||
|
||||
# first register user will be in Administrator role
|
||||
if self.role_id is None:
|
||||
self.role_id = Role.query.filter_by(name='User').first().id
|
||||
if User.query.count() == 0:
|
||||
self.role_id = Role.query.filter_by(
|
||||
name='Administrator').first().id
|
||||
|
||||
self.password = self.get_hashed_password(
|
||||
self.plain_text_password) if self.plain_text_password else '*'
|
||||
|
||||
if self.password and self.password != '*':
|
||||
self.password = self.password.decode("utf-8")
|
||||
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return {'status': True, 'msg': 'Created user successfully'}
|
||||
|
||||
def update_local_user(self):
|
||||
"""
|
||||
Update local user
|
||||
"""
|
||||
# Sanity check - account name
|
||||
if self.username == "":
|
||||
return {'status': False, 'msg': 'No user name specified'}
|
||||
|
||||
# read user and check that it exists
|
||||
user = User.query.filter(User.username == self.username).first()
|
||||
if not user:
|
||||
return {'status': False, 'msg': 'User does not exist'}
|
||||
|
||||
# check if new email exists (only if changed)
|
||||
if user.email != self.email:
|
||||
checkuser = User.query.filter(User.email == self.email).first()
|
||||
if checkuser:
|
||||
return {
|
||||
'status': False,
|
||||
'msg': 'New email address is already in use'
|
||||
}
|
||||
|
||||
user.firstname = self.firstname
|
||||
user.lastname = self.lastname
|
||||
user.email = self.email
|
||||
|
||||
# store new password hash (only if changed)
|
||||
if self.plain_text_password:
|
||||
user.password = self.get_hashed_password(
|
||||
self.plain_text_password).decode("utf-8")
|
||||
|
||||
db.session.commit()
|
||||
return {'status': True, 'msg': 'User updated successfully'}
|
||||
|
||||
def update_profile(self, enable_otp=None):
|
||||
"""
|
||||
Update user profile
|
||||
"""
|
||||
user = User.query.filter(User.username == self.username).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.firstname = self.firstname if self.firstname else user.firstname
|
||||
user.lastname = self.lastname if self.lastname else user.lastname
|
||||
user.password = self.get_hashed_password(
|
||||
self.plain_text_password).decode(
|
||||
"utf-8") if self.plain_text_password else user.password
|
||||
|
||||
if self.email:
|
||||
# Can not update to a new email that
|
||||
# already been used.
|
||||
existing_email = User.query.filter(
|
||||
User.email == self.email,
|
||||
User.username != self.username).first()
|
||||
if existing_email:
|
||||
return False
|
||||
# If need to verify new email,
|
||||
# update the "confirmed" status.
|
||||
if user.email != self.email:
|
||||
user.email = self.email
|
||||
if Setting().get('verify_user_email'):
|
||||
user.confirmed = 0
|
||||
|
||||
if enable_otp is not None:
|
||||
user.otp_secret = ""
|
||||
|
||||
if enable_otp == True:
|
||||
# generate the opt secret key
|
||||
user.otp_secret = base64.b32encode(os.urandom(10)).decode('utf-8')
|
||||
|
||||
try:
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
def update_confirmed(self, confirmed):
|
||||
"""
|
||||
Update user email confirmation status
|
||||
"""
|
||||
self.confirmed = confirmed
|
||||
db.session.commit()
|
||||
|
||||
def get_domains(self):
|
||||
"""
|
||||
Get list of domains which the user is granted to have
|
||||
access.
|
||||
|
||||
Note: This doesn't include the permission granting from Account
|
||||
which user belong to
|
||||
"""
|
||||
return self.get_domain_query().all()
|
||||
|
||||
def get_user_domains(self):
|
||||
from ..models.base import db
|
||||
from .account import Account
|
||||
from .domain import Domain
|
||||
from .account_user import AccountUser
|
||||
from .domain_user import DomainUser
|
||||
|
||||
domains = db.session.query(Domain) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.filter(
|
||||
db.or_(
|
||||
DomainUser.user_id == self.id,
|
||||
AccountUser.user_id == self.id
|
||||
)).all()
|
||||
return domains
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete a user
|
||||
"""
|
||||
# revoke all user privileges first
|
||||
self.revoke_privilege()
|
||||
|
||||
try:
|
||||
User.query.filter(User.username == self.username).delete()
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error('Cannot delete user {0} from DB. DETAIL: {1}'.format(
|
||||
self.username, e))
|
||||
return False
|
||||
|
||||
def revoke_privilege(self, update_user=False):
|
||||
"""
|
||||
Revoke all privileges from a user
|
||||
"""
|
||||
user = User.query.filter(User.username == self.username).first()
|
||||
|
||||
if user:
|
||||
user_id = user.id
|
||||
try:
|
||||
DomainUser.query.filter(DomainUser.user_id == user_id).delete()
|
||||
if (update_user)==True:
|
||||
AccountUser.query.filter(AccountUser.user_id == user_id).delete()
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot revoke user {0} privileges. DETAIL: {1}'.format(
|
||||
self.username, e))
|
||||
return False
|
||||
return False
|
||||
|
||||
def set_role(self, role_name):
|
||||
role = Role.query.filter(Role.name == role_name).first()
|
||||
if role:
|
||||
user = User.query.filter(User.username == self.username).first()
|
||||
user.role_id = role.id
|
||||
db.session.commit()
|
||||
return {'status': True, 'msg': 'Set user role successfully'}
|
||||
else:
|
||||
return {'status': False, 'msg': 'Role does not exist'}
|
||||
|
||||
@orm.reconstructor
|
||||
def set_account(self):
|
||||
self.accounts = self.get_accounts()
|
||||
|
||||
def get_accounts(self):
|
||||
"""
|
||||
Get accounts associated with this user
|
||||
"""
|
||||
from .account import Account
|
||||
from .account_user import AccountUser
|
||||
accounts = []
|
||||
query = db.session\
|
||||
.query(
|
||||
AccountUser,
|
||||
Account)\
|
||||
.filter(self.id == AccountUser.user_id)\
|
||||
.filter(Account.id == AccountUser.account_id)\
|
||||
.order_by(Account.name)\
|
||||
.all()
|
||||
for q in query:
|
||||
accounts.append(q[1])
|
||||
return accounts
|
||||
|
||||
def get_qrcode_value(self):
|
||||
img = qrc.make(self.get_totp_uri(),
|
||||
image_factory=qrc_svg.SvgPathImage)
|
||||
stream = BytesIO()
|
||||
img.save(stream)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def read_entitlements(self, key):
|
||||
"""
|
||||
Get entitlements from ldap server associated with this user
|
||||
"""
|
||||
LDAP_BASE_DN = Setting().get('ldap_base_dn')
|
||||
LDAP_FILTER_USERNAME = Setting().get('ldap_filter_username')
|
||||
LDAP_FILTER_BASIC = Setting().get('ldap_filter_basic')
|
||||
searchFilter = "(&({0}={1}){2})".format(LDAP_FILTER_USERNAME,
|
||||
self.username,
|
||||
LDAP_FILTER_BASIC)
|
||||
current_app.logger.debug('Ldap searchFilter {0}'.format(searchFilter))
|
||||
ldap_result = self.ldap_search(searchFilter, LDAP_BASE_DN, [key])
|
||||
current_app.logger.debug('Ldap search result: {0}'.format(ldap_result))
|
||||
entitlements=[]
|
||||
if ldap_result:
|
||||
dict=ldap_result[0][0][1]
|
||||
if len(dict)!=0:
|
||||
for entitlement in dict[key]:
|
||||
entitlements.append(entitlement.decode("utf-8"))
|
||||
else:
|
||||
e="Not found value in the autoprovisioning attribute field "
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
return entitlements
|
||||
|
||||
def updateUser(self, Entitlements):
|
||||
"""
|
||||
Update user associations based on ldap attribute
|
||||
"""
|
||||
entitlements= getCorrectEntitlements(Entitlements)
|
||||
if len(entitlements)!=0:
|
||||
self.revoke_privilege(True)
|
||||
for entitlement in entitlements:
|
||||
arguments=entitlement.split(':')
|
||||
entArgs=arguments[arguments.index('powerdns-admin')+1:]
|
||||
role= entArgs[0]
|
||||
self.set_role(role)
|
||||
if (role=="User") and len(entArgs)>1:
|
||||
current_domains=getUserInfo(self.get_user_domains())
|
||||
current_accounts=getUserInfo(self.get_accounts())
|
||||
domain=entArgs[1]
|
||||
self.addMissingDomain(domain, current_domains)
|
||||
if len(entArgs)>2:
|
||||
account=entArgs[2]
|
||||
self.addMissingAccount(account, current_accounts)
|
||||
|
||||
def addMissingDomain(self, autoprovision_domain, current_domains):
|
||||
"""
|
||||
Add domain gathered by autoprovisioning to the current domains list of a user
|
||||
"""
|
||||
from ..models.domain import Domain
|
||||
user = db.session.query(User).filter(User.username == self.username).first()
|
||||
if autoprovision_domain not in current_domains:
|
||||
domain= db.session.query(Domain).filter(Domain.name == autoprovision_domain).first()
|
||||
if domain!=None:
|
||||
domain.add_user(user)
|
||||
|
||||
def addMissingAccount(self, autoprovision_account, current_accounts):
|
||||
"""
|
||||
Add account gathered by autoprovisioning to the current accounts list of a user
|
||||
"""
|
||||
from ..models.account import Account
|
||||
user = db.session.query(User).filter(User.username == self.username).first()
|
||||
if autoprovision_account not in current_accounts:
|
||||
account= db.session.query(Account).filter(Account.name == autoprovision_account).first()
|
||||
if account!=None:
|
||||
account.add_user(user)
|
||||
|
||||
def getCorrectEntitlements(Entitlements):
|
||||
"""
|
||||
Gather a list of valid records from the ldap attribute given
|
||||
"""
|
||||
from ..models.role import Role
|
||||
urn_value=Setting().get('urn_value')
|
||||
urnArgs=[x.lower() for x in urn_value.split(':')]
|
||||
entitlements=[]
|
||||
for Entitlement in Entitlements:
|
||||
arguments=Entitlement.split(':')
|
||||
|
||||
if ('powerdns-admin' in arguments):
|
||||
prefix=arguments[0:arguments.index('powerdns-admin')]
|
||||
prefix=[x.lower() for x in prefix]
|
||||
if (prefix!=urnArgs):
|
||||
e= "Typo in first part of urn value"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
else:
|
||||
e="Entry not a PowerDNS-Admin record"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
if len(arguments)<=len(urnArgs)+1: #prefix:powerdns-admin
|
||||
e="No value given after the prefix"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
entArgs=arguments[arguments.index('powerdns-admin')+1:]
|
||||
role=entArgs[0]
|
||||
roles= Role.query.all()
|
||||
role_names=get_role_names(roles)
|
||||
|
||||
if role not in role_names:
|
||||
e="Role given by entry not a role availabe in PowerDNS-Admin. Check for spelling errors"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
if len(entArgs)>1:
|
||||
if (role!="User"):
|
||||
e="Too many arguments for Admin or Operator"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
else:
|
||||
if len(entArgs)<=3:
|
||||
if entArgs[1] and not checkIfDomainExists(entArgs[1]):
|
||||
continue
|
||||
if len(entArgs)==3:
|
||||
if entArgs[2] and not checkIfAccountExists(entArgs[2]):
|
||||
continue
|
||||
else:
|
||||
e="Too many arguments"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
entitlements.append(Entitlement)
|
||||
|
||||
return entitlements
|
||||
|
||||
|
||||
def checkIfDomainExists(domainName):
|
||||
from ..models.domain import Domain
|
||||
domain= db.session.query(Domain).filter(Domain.name == domainName)
|
||||
if len(domain.all())==0:
|
||||
e= domainName + " is not found in the database"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
return False
|
||||
return True
|
||||
|
||||
def checkIfAccountExists(accountName):
|
||||
from ..models.account import Account
|
||||
account= db.session.query(Account).filter(Account.name == accountName)
|
||||
if len(account.all())==0:
|
||||
e= accountName + " is not found in the database"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_role_names(roles):
|
||||
"""
|
||||
returns all the roles available in database in string format
|
||||
"""
|
||||
roles_list=[]
|
||||
for role in roles:
|
||||
roles_list.append(role.name)
|
||||
return roles_list
|
||||
|
||||
def getUserInfo(DomainsOrAccounts):
|
||||
current=[]
|
||||
for DomainOrAccount in DomainsOrAccounts:
|
||||
current.append(DomainOrAccount.name)
|
||||
return current
|
25
powerdnsadmin/routes/__init__.py
Normal file
25
powerdnsadmin/routes/__init__.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from .base import login_manager, handle_bad_request, handle_unauthorized_access, handle_access_forbidden, handle_page_not_found, handle_internal_server_error
|
||||
|
||||
from .index import index_bp
|
||||
from .user import user_bp
|
||||
from .dashboard import dashboard_bp
|
||||
from .domain import domain_bp
|
||||
from .admin import admin_bp
|
||||
from .api import api_bp
|
||||
|
||||
|
||||
def init_app(app):
|
||||
login_manager.init_app(app)
|
||||
|
||||
app.register_blueprint(index_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(domain_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
app.register_error_handler(400, handle_bad_request)
|
||||
app.register_error_handler(401, handle_unauthorized_access)
|
||||
app.register_error_handler(403, handle_access_forbidden)
|
||||
app.register_error_handler(404, handle_page_not_found)
|
||||
app.register_error_handler(500, handle_internal_server_error)
|
1955
powerdnsadmin/routes/admin.py
Normal file
1955
powerdnsadmin/routes/admin.py
Normal file
File diff suppressed because it is too large
Load diff
1184
powerdnsadmin/routes/api.py
Normal file
1184
powerdnsadmin/routes/api.py
Normal file
File diff suppressed because it is too large
Load diff
91
powerdnsadmin/routes/base.py
Normal file
91
powerdnsadmin/routes/base.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
import base64
|
||||
from flask import render_template, url_for, redirect, session, request, current_app
|
||||
from flask_login import LoginManager
|
||||
|
||||
from ..models.user import User
|
||||
|
||||
login_manager = LoginManager()
|
||||
|
||||
|
||||
def handle_bad_request(e):
|
||||
return render_template('errors/400.html', code=400, message=e), 400
|
||||
|
||||
|
||||
def handle_unauthorized_access(e):
|
||||
session['next'] = request.script_root + request.path
|
||||
return redirect(url_for('index.login'))
|
||||
|
||||
|
||||
def handle_access_forbidden(e):
|
||||
return render_template('errors/403.html', code=403, message=e), 403
|
||||
|
||||
|
||||
def handle_page_not_found(e):
|
||||
return render_template('errors/404.html', code=404, message=e), 404
|
||||
|
||||
|
||||
def handle_internal_server_error(e):
|
||||
return render_template('errors/500.html', code=500, message=e), 500
|
||||
|
||||
|
||||
def load_if_valid(user, method, src_ip, trust_user = False):
|
||||
try:
|
||||
auth = user.is_validate(method, src_ip, trust_user)
|
||||
if auth == False:
|
||||
return None
|
||||
else:
|
||||
# login_user(user, remember=False)
|
||||
return User.query.filter(User.id==user.id).first()
|
||||
except Exception as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
return None
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(id):
|
||||
"""
|
||||
This will be current_user
|
||||
"""
|
||||
return User.query.get(int(id))
|
||||
|
||||
|
||||
@login_manager.request_loader
|
||||
def login_via_authorization_header_or_remote_user(request):
|
||||
# Try to login using Basic Authentication
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header:
|
||||
auth_method = request.args.get('auth_method', 'LOCAL')
|
||||
auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL'
|
||||
auth_header = auth_header.replace('Basic ', '', 1)
|
||||
try:
|
||||
auth_header = str(base64.b64decode(auth_header), 'utf-8')
|
||||
username, password = auth_header.split(":")
|
||||
except TypeError as e:
|
||||
return None
|
||||
|
||||
user = User(username=username,
|
||||
password=password,
|
||||
plain_text_password=password)
|
||||
return load_if_valid(user, method=auth_method, src_ip=request.remote_addr)
|
||||
|
||||
# Try login by checking a REMOTE_USER environment variable
|
||||
remote_user = request.remote_user
|
||||
if remote_user and current_app.config.get('REMOTE_USER_ENABLED'):
|
||||
session_remote_user = session.get('remote_user')
|
||||
|
||||
# If we already validated a remote user against an authorization method
|
||||
# a local user should have been created in the database, so we force a 'LOCAL' auth_method
|
||||
auth_method = 'LOCAL' if session_remote_user else current_app.config.get('REMOTE_AUTH_METHOD', 'LDAP')
|
||||
current_app.logger.debug(
|
||||
'REMOTE_USER environment variable found: attempting {0} authentication for username "{1}"'
|
||||
.format(auth_method, remote_user))
|
||||
user = User(username=remote_user.strip())
|
||||
valid_remote_user = load_if_valid(user, method=auth_method, src_ip=request.remote_addr, trust_user=True)
|
||||
|
||||
if valid_remote_user:
|
||||
# If we were successful in authenticating a trusted remote user, store it in session
|
||||
session['remote_user'] = valid_remote_user.username
|
||||
|
||||
return valid_remote_user
|
||||
|
||||
return None
|
225
powerdnsadmin/routes/dashboard.py
Normal file
225
powerdnsadmin/routes/dashboard.py
Normal file
|
@ -0,0 +1,225 @@
|
|||
import datetime
|
||||
from flask import Blueprint, render_template, url_for, current_app, request, jsonify, redirect, g, session
|
||||
from flask_login import login_required, current_user, login_manager
|
||||
from sqlalchemy import not_
|
||||
|
||||
from ..decorators import operator_role_required
|
||||
from ..lib.utils import customBoxes
|
||||
from ..models.user import User, Anonymous
|
||||
from ..models.account import Account
|
||||
from ..models.account_user import AccountUser
|
||||
from ..models.domain import Domain
|
||||
from ..models.domain_user import DomainUser
|
||||
from ..models.setting import Setting
|
||||
from ..models.history import History
|
||||
from ..models.server import Server
|
||||
from ..models.base import db
|
||||
|
||||
dashboard_bp = Blueprint('dashboard',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
url_prefix='/dashboard')
|
||||
|
||||
|
||||
@dashboard_bp.before_request
|
||||
def before_request():
|
||||
# Check if user is anonymous
|
||||
g.user = current_user
|
||||
login_manager.anonymous_user = Anonymous
|
||||
|
||||
# Check site is in maintenance mode
|
||||
maintenance = Setting().get('maintenance')
|
||||
if maintenance and current_user.is_authenticated and current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
]:
|
||||
return render_template('maintenance.html')
|
||||
|
||||
# Manage session timeout
|
||||
session.permanent = True
|
||||
current_app.permanent_session_lifetime = datetime.timedelta(
|
||||
minutes=int(Setting().get('session_timeout')))
|
||||
session.modified = True
|
||||
|
||||
|
||||
@dashboard_bp.route('/domains-custom/<path:boxId>', methods=['GET'])
|
||||
@login_required
|
||||
def domains_custom(boxId):
|
||||
if current_user.role.name in ['Administrator', 'Operator']:
|
||||
domains = Domain.query
|
||||
else:
|
||||
# Get query for domain to which the user has access permission.
|
||||
# This includes direct domain permission AND permission through
|
||||
# account membership
|
||||
domains = db.session.query(Domain) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.filter(
|
||||
db.or_(
|
||||
DomainUser.user_id == current_user.id,
|
||||
AccountUser.user_id == current_user.id
|
||||
))
|
||||
|
||||
template = current_app.jinja_env.get_template("dashboard_domain.html")
|
||||
render = template.make_module(vars={"current_user": current_user, "allow_user_view_history": Setting().get('allow_user_view_history')})
|
||||
|
||||
columns = [
|
||||
Domain.name, Domain.dnssec, Domain.type, Domain.serial, Domain.master,
|
||||
Domain.account_id
|
||||
]
|
||||
|
||||
order_by = []
|
||||
for i in range(len(columns)):
|
||||
column_index = request.args.get("order[{0}][column]".format(i))
|
||||
sort_direction = request.args.get("order[{0}][dir]".format(i))
|
||||
if column_index is None:
|
||||
break
|
||||
if sort_direction != "asc" and sort_direction != "desc":
|
||||
sort_direction = "asc"
|
||||
|
||||
column = columns[int(column_index)]
|
||||
order_by.append(getattr(column, sort_direction)())
|
||||
|
||||
if order_by:
|
||||
domains = domains.order_by(*order_by)
|
||||
|
||||
if boxId == "reverse":
|
||||
for boxId in customBoxes.order:
|
||||
if boxId == "reverse": continue
|
||||
domains = domains.filter(
|
||||
not_(Domain.name.ilike(customBoxes.boxes[boxId][1])))
|
||||
else:
|
||||
domains = domains.filter(Domain.name.ilike(
|
||||
customBoxes.boxes[boxId][1]))
|
||||
|
||||
total_count = domains.count()
|
||||
|
||||
search = request.args.get("search[value]")
|
||||
if search:
|
||||
start = "" if search.startswith("^") else "%"
|
||||
end = "" if search.endswith("$") else "%"
|
||||
|
||||
if current_user.role.name in ['Administrator', 'Operator']:
|
||||
domains = domains.outerjoin(Account).filter(
|
||||
Domain.name.ilike(start + search.strip("^$") + end)
|
||||
| Account.name.ilike(start + search.strip("^$") + end)
|
||||
| Account.description.ilike(start + search.strip("^$") + end))
|
||||
else:
|
||||
domains = domains.filter(
|
||||
Domain.name.ilike(start + search.strip("^$") + end))
|
||||
|
||||
filtered_count = domains.count()
|
||||
|
||||
start = int(request.args.get("start", 0))
|
||||
length = min(int(request.args.get("length", 0)), 100)
|
||||
|
||||
if length != -1:
|
||||
domains = domains[start:start + length]
|
||||
|
||||
data = []
|
||||
for domain in domains:
|
||||
data.append([
|
||||
render.name(domain),
|
||||
render.dnssec(domain),
|
||||
render.type(domain),
|
||||
render.serial(domain),
|
||||
render.master(domain),
|
||||
render.account(domain),
|
||||
render.actions(domain),
|
||||
])
|
||||
|
||||
response_data = {
|
||||
"draw": int(request.args.get("draw", 0)),
|
||||
"recordsTotal": total_count,
|
||||
"recordsFiltered": filtered_count,
|
||||
"data": data,
|
||||
}
|
||||
return jsonify(response_data)
|
||||
|
||||
|
||||
@dashboard_bp.route('/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def dashboard():
|
||||
if not Setting().get('pdns_api_url') or not Setting().get(
|
||||
'pdns_api_key') or not Setting().get('pdns_version'):
|
||||
return redirect(url_for('admin.setting_pdns'))
|
||||
|
||||
BG_DOMAIN_UPDATE = Setting().get('bg_domain_updates')
|
||||
if not BG_DOMAIN_UPDATE:
|
||||
current_app.logger.info('Updating domains in foreground...')
|
||||
Domain().update()
|
||||
else:
|
||||
current_app.logger.info('Updating domains in background...')
|
||||
|
||||
show_bg_domain_button = BG_DOMAIN_UPDATE
|
||||
if BG_DOMAIN_UPDATE and current_user.role.name not in ['Administrator', 'Operator']:
|
||||
show_bg_domain_button = False
|
||||
|
||||
# Stats for dashboard
|
||||
domain_count = 0
|
||||
history_number = 0
|
||||
history = []
|
||||
user_num = User.query.count()
|
||||
if current_user.role.name in ['Administrator', 'Operator']:
|
||||
domain_count = Domain.query.count()
|
||||
history_number = History.query.count()
|
||||
history = History.query.order_by(History.created_on.desc()).limit(4).all()
|
||||
elif Setting().get('allow_user_view_history'):
|
||||
history = db.session.query(History) \
|
||||
.join(Domain, History.domain_id == Domain.id) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.order_by(History.created_on.desc()) \
|
||||
.filter(
|
||||
db.or_(
|
||||
DomainUser.user_id == current_user.id,
|
||||
AccountUser.user_id == current_user.id
|
||||
)).all()
|
||||
history_number = len(history) # history.count()
|
||||
history = history[:4]
|
||||
domain_count = db.session.query(Domain) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.filter(
|
||||
db.or_(
|
||||
DomainUser.user_id == current_user.id,
|
||||
AccountUser.user_id == current_user.id
|
||||
)).count()
|
||||
|
||||
from .admin import convert_histories, DetailedHistory
|
||||
detailedHistories = convert_histories(history)
|
||||
|
||||
server = Server(server_id='localhost')
|
||||
statistics = server.get_statistic()
|
||||
if statistics:
|
||||
uptime = list([
|
||||
uptime for uptime in statistics if uptime['name'] == 'uptime'
|
||||
])[0]['value']
|
||||
else:
|
||||
uptime = 0
|
||||
|
||||
# Add custom boxes to render_template
|
||||
return render_template('dashboard.html',
|
||||
custom_boxes=customBoxes,
|
||||
domain_count=domain_count,
|
||||
user_num=user_num,
|
||||
history_number=history_number,
|
||||
uptime=uptime,
|
||||
histories=detailedHistories,
|
||||
show_bg_domain_button=show_bg_domain_button,
|
||||
pdns_version=Setting().get('pdns_version'))
|
||||
|
||||
|
||||
@dashboard_bp.route('/domains-updater', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def domains_updater():
|
||||
current_app.logger.debug('Update domains in background')
|
||||
d = Domain().update()
|
||||
|
||||
response_data = {
|
||||
"result": d,
|
||||
}
|
||||
return jsonify(response_data)
|
878
powerdnsadmin/routes/domain.py
Normal file
878
powerdnsadmin/routes/domain.py
Normal file
|
@ -0,0 +1,878 @@
|
|||
import re
|
||||
import json
|
||||
import datetime
|
||||
import traceback
|
||||
import dns.name
|
||||
import dns.reversename
|
||||
from distutils.version import StrictVersion
|
||||
from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, abort, jsonify, g, session
|
||||
from flask_login import login_required, current_user, login_manager
|
||||
|
||||
from ..lib.utils import pretty_domain_name
|
||||
from ..lib.utils import pretty_json
|
||||
from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec, can_remove_domain
|
||||
from ..models.user import User, Anonymous
|
||||
from ..models.account import Account
|
||||
from ..models.setting import Setting
|
||||
from ..models.history import History
|
||||
from ..models.domain import Domain
|
||||
from ..models.record import Record
|
||||
from ..models.record_entry import RecordEntry
|
||||
from ..models.domain_template import DomainTemplate
|
||||
from ..models.domain_template_record import DomainTemplateRecord
|
||||
from ..models.domain_setting import DomainSetting
|
||||
from ..models.base import db
|
||||
from ..models.domain_user import DomainUser
|
||||
from ..models.account_user import AccountUser
|
||||
from .admin import extract_changelogs_from_a_history_entry
|
||||
from ..decorators import history_access_required
|
||||
domain_bp = Blueprint('domain',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
url_prefix='/domain')
|
||||
|
||||
|
||||
@domain_bp.before_request
|
||||
def before_request():
|
||||
# Check if user is anonymous
|
||||
g.user = current_user
|
||||
login_manager.anonymous_user = Anonymous
|
||||
|
||||
# Check site is in maintenance mode
|
||||
maintenance = Setting().get('maintenance')
|
||||
if maintenance and current_user.is_authenticated and current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
]:
|
||||
return render_template('maintenance.html')
|
||||
|
||||
# Manage session timeout
|
||||
session.permanent = True
|
||||
current_app.permanent_session_lifetime = datetime.timedelta(
|
||||
minutes=int(Setting().get('session_timeout')))
|
||||
session.modified = True
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>', methods=['GET'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
def domain(domain_name):
|
||||
# Validate the domain existing in the local DB
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
|
||||
# Query domain's rrsets from PowerDNS API
|
||||
rrsets = Record().get_rrsets(domain.name)
|
||||
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
|
||||
|
||||
# API server might be down, misconfigured
|
||||
if not rrsets and domain.type != 'Slave':
|
||||
abort(500)
|
||||
|
||||
quick_edit = Setting().get('record_quick_edit')
|
||||
records_allow_to_edit = Setting().get_records_allow_to_edit()
|
||||
forward_records_allow_to_edit = Setting(
|
||||
).get_forward_records_allow_to_edit()
|
||||
reverse_records_allow_to_edit = Setting(
|
||||
).get_reverse_records_allow_to_edit()
|
||||
ttl_options = Setting().get_ttl_options()
|
||||
records = []
|
||||
|
||||
# Render the "records" to display in HTML datatable
|
||||
#
|
||||
# BUG: If we have multiple records with the same name
|
||||
# and each record has its own comment, the display of
|
||||
# [record-comment] may not consistent because PDNS API
|
||||
# returns the rrsets (records, comments) has different
|
||||
# order than its database records.
|
||||
# TODO:
|
||||
# - Find a way to make it consistent, or
|
||||
# - Only allow one comment for that case
|
||||
if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'):
|
||||
for r in rrsets:
|
||||
if r['type'] in records_allow_to_edit:
|
||||
r_name = r['name'].rstrip('.')
|
||||
|
||||
# If it is reverse zone and pretty_ipv6_ptr setting
|
||||
# is enabled, we reformat the name for ipv6 records.
|
||||
if Setting().get('pretty_ipv6_ptr') and r[
|
||||
'type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name:
|
||||
r_name = dns.reversename.to_address(
|
||||
dns.name.from_text(r_name))
|
||||
|
||||
# Create the list of records in format that
|
||||
# PDA jinja2 template can understand.
|
||||
index = 0
|
||||
for record in r['records']:
|
||||
if (len(r['comments'])>index):
|
||||
c=r['comments'][index]['content']
|
||||
else:
|
||||
c=''
|
||||
record_entry = RecordEntry(
|
||||
name=r_name,
|
||||
type=r['type'],
|
||||
status='Disabled' if record['disabled'] else 'Active',
|
||||
ttl=r['ttl'],
|
||||
data=record['content'],
|
||||
comment=c,
|
||||
is_allowed_edit=True)
|
||||
index += 1
|
||||
records.append(record_entry)
|
||||
else:
|
||||
# Unsupported version
|
||||
abort(500)
|
||||
|
||||
if not re.search(r'ip6\.arpa|in-addr\.arpa$', domain_name):
|
||||
editable_records = forward_records_allow_to_edit
|
||||
else:
|
||||
editable_records = reverse_records_allow_to_edit
|
||||
|
||||
return render_template('domain.html',
|
||||
domain=domain,
|
||||
records=records,
|
||||
editable_records=editable_records,
|
||||
quick_edit=quick_edit,
|
||||
ttl_options=ttl_options,
|
||||
current_user=current_user)
|
||||
|
||||
|
||||
@domain_bp.route('/remove', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@can_remove_domain
|
||||
def remove():
|
||||
# domains is a list of all the domains a User may access
|
||||
# Admins may access all
|
||||
# Regular users only if they are associated with the domain
|
||||
if current_user.role.name in ['Administrator', 'Operator']:
|
||||
domains = Domain.query.order_by(Domain.name).all()
|
||||
else:
|
||||
# Get query for domain to which the user has access permission.
|
||||
# This includes direct domain permission AND permission through
|
||||
# account membership
|
||||
domains = db.session.query(Domain) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.filter(
|
||||
db.or_(
|
||||
DomainUser.user_id == current_user.id,
|
||||
AccountUser.user_id == current_user.id
|
||||
)).order_by(Domain.name)
|
||||
|
||||
if request.method == 'POST':
|
||||
# TODO Change name from 'domainid' to something else, its confusing
|
||||
domain_name = request.form['domainid']
|
||||
|
||||
# Get domain from Database, might be None
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
|
||||
# Check if the domain is in domains before removal
|
||||
if domain not in domains:
|
||||
abort(403)
|
||||
|
||||
# Delete
|
||||
d = Domain()
|
||||
result = d.delete(domain_name)
|
||||
|
||||
if result['status'] == 'error':
|
||||
abort(500)
|
||||
|
||||
history = History(msg='Delete domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
else:
|
||||
# On GET return the domains we got earlier
|
||||
return render_template('domain_remove.html',
|
||||
domainss=domains)
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/changelog', methods=['GET'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
@history_access_required
|
||||
def changelog(domain_name):
|
||||
g.user = current_user
|
||||
login_manager.anonymous_user = Anonymous
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
|
||||
# Query domain's rrsets from PowerDNS API
|
||||
rrsets = Record().get_rrsets(domain.name)
|
||||
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
|
||||
|
||||
# API server might be down, misconfigured
|
||||
if not rrsets and domain.type != 'Slave':
|
||||
abort(500)
|
||||
|
||||
records_allow_to_edit = Setting().get_records_allow_to_edit()
|
||||
records = []
|
||||
|
||||
# get all changelogs for this domain, in descening order
|
||||
if current_user.role.name in [ 'Administrator', 'Operator' ]:
|
||||
histories = History.query.filter(History.domain_id == domain.id).order_by(History.created_on.desc()).all()
|
||||
else:
|
||||
# if the user isn't an administrator or operator,
|
||||
# allow_user_view_history must be enabled to get here,
|
||||
# so include history for the domains for the user
|
||||
histories = db.session.query(History) \
|
||||
.join(Domain, History.domain_id == Domain.id) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.order_by(History.created_on.desc()) \
|
||||
.filter(
|
||||
db.and_(db.or_(
|
||||
DomainUser.user_id == current_user.id,
|
||||
AccountUser.user_id == current_user.id
|
||||
),
|
||||
History.domain_id == domain.id
|
||||
)
|
||||
).all()
|
||||
|
||||
if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'):
|
||||
for r in rrsets:
|
||||
if r['type'] in records_allow_to_edit:
|
||||
r_name = r['name'].rstrip('.')
|
||||
|
||||
# If it is reverse zone and pretty_ipv6_ptr setting
|
||||
# is enabled, we reformat the name for ipv6 records.
|
||||
if Setting().get('pretty_ipv6_ptr') and r[
|
||||
'type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name:
|
||||
r_name = dns.reversename.to_address(
|
||||
dns.name.from_text(r_name))
|
||||
|
||||
# Create the list of records in format that
|
||||
# PDA jinja2 template can understand.
|
||||
index = 0
|
||||
for record in r['records']:
|
||||
if (len(r['comments'])>index):
|
||||
c=r['comments'][index]['content']
|
||||
else:
|
||||
c=''
|
||||
record_entry = RecordEntry(
|
||||
name=r_name,
|
||||
type=r['type'],
|
||||
status='Disabled' if record['disabled'] else 'Active',
|
||||
ttl=r['ttl'],
|
||||
data=record['content'],
|
||||
comment=c,
|
||||
is_allowed_edit=True)
|
||||
index += 1
|
||||
records.append(record_entry)
|
||||
else:
|
||||
# Unsupported version
|
||||
abort(500)
|
||||
|
||||
changes_set = dict()
|
||||
for i in range(len(histories)):
|
||||
extract_changelogs_from_a_history_entry(changes_set, histories[i], i)
|
||||
if i in changes_set and len(changes_set[i]) == 0: # if empty, then remove the key
|
||||
changes_set.pop(i)
|
||||
return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set)
|
||||
|
||||
"""
|
||||
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'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
@history_access_required
|
||||
def record_changelog(domain_name, record_name, record_type):
|
||||
|
||||
g.user = current_user
|
||||
login_manager.anonymous_user = Anonymous
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
# Query domain's rrsets from PowerDNS API
|
||||
rrsets = Record().get_rrsets(domain.name)
|
||||
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
|
||||
|
||||
# API server might be down, misconfigured
|
||||
if not rrsets and domain.type != 'Slave':
|
||||
abort(500)
|
||||
|
||||
# get all changelogs for this domain, in descening order
|
||||
if current_user.role.name in [ 'Administrator', 'Operator' ]:
|
||||
histories = History.query.filter(History.domain_id == domain.id).order_by(History.created_on.desc()).all()
|
||||
else:
|
||||
# if the user isn't an administrator or operator,
|
||||
# allow_user_view_history must be enabled to get here,
|
||||
# so include history for the domains for the user
|
||||
histories = db.session.query(History) \
|
||||
.join(Domain, History.domain_id == Domain.id) \
|
||||
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
|
||||
.outerjoin(Account, Domain.account_id == Account.id) \
|
||||
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
|
||||
.order_by(History.created_on.desc()) \
|
||||
.filter(
|
||||
db.and_(db.or_(
|
||||
DomainUser.user_id == current_user.id,
|
||||
AccountUser.user_id == current_user.id
|
||||
),
|
||||
History.domain_id == domain.id
|
||||
)
|
||||
).all()
|
||||
|
||||
changes_set_of_record = dict()
|
||||
for i in range(len(histories)):
|
||||
extract_changelogs_from_a_history_entry(changes_set_of_record, histories[i], i, record_name, record_type)
|
||||
if i in changes_set_of_record and len(changes_set_of_record[i]) == 0: # if empty, then remove the key
|
||||
changes_set_of_record.pop(i)
|
||||
|
||||
indexes_to_pop = []
|
||||
for change_num in changes_set_of_record:
|
||||
changes_i = changes_set_of_record[change_num]
|
||||
for hre in changes_i: # for each history record entry in changes_i
|
||||
if 'type' in hre.add_rrest and hre.add_rrest['name'] == record_name and hre.add_rrest['type'] == record_type:
|
||||
continue
|
||||
elif 'type' in hre.del_rrest and hre.del_rrest['name'] == record_name and hre.del_rrest['type'] == record_type:
|
||||
continue
|
||||
else:
|
||||
changes_set_of_record[change_num].remove(hre)
|
||||
if change_num in changes_set_of_record and len(changes_set_of_record[change_num]) == 0: # if empty, then remove the key
|
||||
indexes_to_pop.append(change_num)
|
||||
|
||||
for i in indexes_to_pop:
|
||||
changes_set_of_record.pop(i)
|
||||
|
||||
return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set_of_record,
|
||||
record_name = record_name, record_type = record_type)
|
||||
|
||||
|
||||
|
||||
@domain_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@can_create_domain
|
||||
def add():
|
||||
templates = DomainTemplate.query.all()
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
domain_name = request.form.getlist('domain_name')[0]
|
||||
domain_type = request.form.getlist('radio_type')[0]
|
||||
domain_template = request.form.getlist('domain_template')[0]
|
||||
soa_edit_api = request.form.getlist('radio_type_soa_edit_api')[0]
|
||||
account_id = request.form.getlist('accountid')[0]
|
||||
|
||||
if ' ' in domain_name or not domain_name or not domain_type:
|
||||
return render_template(
|
||||
'errors/400.html',
|
||||
msg="Please enter a valid domain name"), 400
|
||||
|
||||
# If User creates the domain, check some additional stuff
|
||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||
# Get all the account_ids of the user
|
||||
user_accounts_ids = current_user.get_accounts()
|
||||
user_accounts_ids = [x.id for x in user_accounts_ids]
|
||||
# User may not create domains without Account
|
||||
if int(account_id) == 0 or int(account_id) not in user_accounts_ids:
|
||||
return render_template(
|
||||
'errors/400.html',
|
||||
msg="Please use a valid Account"), 400
|
||||
|
||||
|
||||
#TODO: Validate ip addresses input
|
||||
|
||||
# Encode domain name into punycode (IDN)
|
||||
try:
|
||||
domain_name = domain_name.encode('idna').decode()
|
||||
except:
|
||||
current_app.logger.error("Cannot encode the domain name {}".format(domain_name))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return render_template(
|
||||
'errors/400.html',
|
||||
msg="Please enter a valid domain name"), 400
|
||||
|
||||
if domain_type == 'slave':
|
||||
if request.form.getlist('domain_master_address'):
|
||||
domain_master_string = request.form.getlist(
|
||||
'domain_master_address')[0]
|
||||
domain_master_string = domain_master_string.replace(
|
||||
' ', '')
|
||||
domain_master_ips = domain_master_string.split(',')
|
||||
else:
|
||||
domain_master_ips = []
|
||||
|
||||
account_name = Account().get_name_by_id(account_id)
|
||||
|
||||
d = Domain()
|
||||
result = d.add(domain_name=domain_name,
|
||||
domain_type=domain_type,
|
||||
soa_edit_api=soa_edit_api,
|
||||
domain_master_ips=domain_master_ips,
|
||||
account_name=account_name)
|
||||
if result['status'] == 'ok':
|
||||
domain_id = Domain().get_id_by_name(domain_name)
|
||||
history = History(msg='Add domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({
|
||||
'domain_type': domain_type,
|
||||
'domain_master_ips': domain_master_ips,
|
||||
'account_id': account_id
|
||||
}),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain_id)
|
||||
history.add()
|
||||
|
||||
# grant user access to the domain
|
||||
Domain(name=domain_name).grant_privileges([current_user.id])
|
||||
|
||||
# apply template if needed
|
||||
if domain_template != '0':
|
||||
template = DomainTemplate.query.filter(
|
||||
DomainTemplate.id == domain_template).first()
|
||||
template_records = DomainTemplateRecord.query.filter(
|
||||
DomainTemplateRecord.template_id ==
|
||||
domain_template).all()
|
||||
record_data = []
|
||||
for template_record in template_records:
|
||||
record_row = {
|
||||
'record_data': template_record.data,
|
||||
'record_name': template_record.name,
|
||||
'record_status': 'Active' if template_record.status else 'Disabled',
|
||||
'record_ttl': template_record.ttl,
|
||||
'record_type': template_record.type,
|
||||
'comment_data': [{'content': template_record.comment, 'account': ''}]
|
||||
}
|
||||
record_data.append(record_row)
|
||||
r = Record()
|
||||
result = r.apply(domain_name, record_data)
|
||||
if result['status'] == 'ok':
|
||||
history = History(
|
||||
msg='Applying template {0} to {1} successfully.'.
|
||||
format(template.name, domain_name),
|
||||
detail=str(
|
||||
json.dumps({
|
||||
"domain":
|
||||
domain_name,
|
||||
"template":
|
||||
template.name,
|
||||
"add_rrests":
|
||||
result['data'][0]['rrsets'],
|
||||
"del_rrests":
|
||||
result['data'][1]['rrsets']
|
||||
})),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain_id)
|
||||
history.add()
|
||||
else:
|
||||
history = History(
|
||||
msg=
|
||||
'Failed to apply template {0} to {1}.'
|
||||
.format(template.name, domain_name),
|
||||
detail=str(result),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
else:
|
||||
return render_template('errors/400.html',
|
||||
msg=result['msg']), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error('Cannot add domain. Error: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
abort(500)
|
||||
|
||||
# Get
|
||||
else:
|
||||
# Admins and Operators can set to any account
|
||||
if current_user.role.name in ['Administrator', 'Operator']:
|
||||
accounts = Account.query.order_by(Account.name).all()
|
||||
else:
|
||||
accounts = current_user.get_accounts()
|
||||
return render_template('domain_add.html',
|
||||
templates=templates,
|
||||
accounts=accounts)
|
||||
|
||||
|
||||
|
||||
@domain_bp.route('/setting/<path:domain_name>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def delete(domain_name):
|
||||
d = Domain()
|
||||
result = d.delete(domain_name)
|
||||
|
||||
if result['status'] == 'error':
|
||||
abort(500)
|
||||
|
||||
history = History(msg='Delete domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
|
||||
@domain_bp.route('/setting/<path:domain_name>/manage', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def setting(domain_name):
|
||||
if request.method == 'GET':
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
users = User.query.all()
|
||||
accounts = Account.query.order_by(Account.name).all()
|
||||
|
||||
# get list of user ids to initialize selection data
|
||||
d = Domain(name=domain_name)
|
||||
domain_user_ids = d.get_user()
|
||||
account = d.get_account()
|
||||
|
||||
return render_template('domain_setting.html',
|
||||
domain=domain,
|
||||
users=users,
|
||||
domain_user_ids=domain_user_ids,
|
||||
accounts=accounts,
|
||||
domain_account=account)
|
||||
|
||||
if request.method == 'POST':
|
||||
# username in right column
|
||||
new_user_list = request.form.getlist('domain_multi_user[]')
|
||||
new_user_ids = [
|
||||
user.id for user in User.query.filter(
|
||||
User.username.in_(new_user_list)).all() if user
|
||||
]
|
||||
|
||||
# grant/revoke user privileges
|
||||
d = Domain(name=domain_name)
|
||||
d.grant_privileges(new_user_ids)
|
||||
|
||||
history = History(
|
||||
msg='Change domain {0} access control'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({'user_has_access': new_user_list}),
|
||||
created_by=current_user.username,
|
||||
domain_id=d.id)
|
||||
history.add()
|
||||
|
||||
return redirect(url_for('domain.setting', domain_name=domain_name))
|
||||
|
||||
|
||||
@domain_bp.route('/setting/<path:domain_name>/change_type',
|
||||
methods=['POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def change_type(domain_name):
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
domain_type = request.form.get('domain_type')
|
||||
if domain_type is None:
|
||||
abort(500)
|
||||
if domain_type == '0':
|
||||
return redirect(url_for('domain.setting', domain_name=domain_name))
|
||||
|
||||
#TODO: Validate ip addresses input
|
||||
domain_master_ips = []
|
||||
if domain_type == 'slave' and request.form.getlist('domain_master_address'):
|
||||
domain_master_string = request.form.getlist(
|
||||
'domain_master_address')[0]
|
||||
domain_master_string = domain_master_string.replace(
|
||||
' ', '')
|
||||
domain_master_ips = domain_master_string.split(',')
|
||||
|
||||
d = Domain()
|
||||
status = d.update_kind(domain_name=domain_name,
|
||||
kind=domain_type,
|
||||
masters=domain_master_ips)
|
||||
if status['status'] == 'ok':
|
||||
history = History(msg='Update type for domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({
|
||||
"domain": domain_name,
|
||||
"type": domain_type,
|
||||
"masters": domain_master_ips
|
||||
}),
|
||||
created_by=current_user.username,
|
||||
domain_id=Domain().get_id_by_name(domain_name))
|
||||
history.add()
|
||||
return redirect(url_for('domain.setting', domain_name = domain_name))
|
||||
else:
|
||||
abort(500)
|
||||
|
||||
|
||||
@domain_bp.route('/setting/<path:domain_name>/change_soa_setting',
|
||||
methods=['POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def change_soa_edit_api(domain_name):
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
new_setting = request.form.get('soa_edit_api')
|
||||
if new_setting is None:
|
||||
abort(500)
|
||||
if new_setting == '0':
|
||||
return redirect(url_for('domain.setting', domain_name=domain_name))
|
||||
|
||||
d = Domain()
|
||||
status = d.update_soa_setting(domain_name=domain_name,
|
||||
soa_edit_api=new_setting)
|
||||
if status['status'] == 'ok':
|
||||
history = History(
|
||||
msg='Update soa_edit_api for domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({
|
||||
"domain": domain_name,
|
||||
"soa_edit_api": new_setting
|
||||
}),
|
||||
created_by=current_user.username,
|
||||
domain_id=d.get_id_by_name(domain_name))
|
||||
history.add()
|
||||
return redirect(url_for('domain.setting', domain_name = domain_name))
|
||||
else:
|
||||
abort(500)
|
||||
|
||||
|
||||
@domain_bp.route('/setting/<path:domain_name>/change_account',
|
||||
methods=['POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def change_account(domain_name):
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
if not domain:
|
||||
abort(404)
|
||||
|
||||
account_id = request.form.get('accountid')
|
||||
status = Domain(name=domain.name).assoc_account(account_id)
|
||||
if status['status']:
|
||||
return redirect(url_for('domain.setting', domain_name=domain.name))
|
||||
else:
|
||||
abort(500)
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/apply',
|
||||
methods=['POST'],
|
||||
strict_slashes=False)
|
||||
@login_required
|
||||
@can_access_domain
|
||||
def record_apply(domain_name):
|
||||
try:
|
||||
jdata = request.json
|
||||
submitted_serial = jdata['serial']
|
||||
submitted_record = jdata['record']
|
||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||
|
||||
if domain:
|
||||
current_app.logger.debug('Current domain serial: {0}'.format(
|
||||
domain.serial))
|
||||
|
||||
if int(submitted_serial) != domain.serial:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'The zone has been changed by another session or user. Please refresh this web page to load updated records.'
|
||||
}), 500)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'Domain name {0} does not exist'.format(pretty_domain_name(domain_name))
|
||||
}), 404)
|
||||
|
||||
r = Record()
|
||||
result = r.apply(domain_name, submitted_record)
|
||||
if result['status'] == 'ok':
|
||||
history = History(
|
||||
msg='Apply record changes to domain {0}'.format(pretty_domain_name(domain_name)),
|
||||
detail=str(
|
||||
json.dumps({
|
||||
"domain": domain_name,
|
||||
"add_rrests": result['data'][0]['rrsets'],
|
||||
"del_rrests": result['data'][1]['rrsets']
|
||||
})),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
return make_response(jsonify(result), 200)
|
||||
else:
|
||||
history = History(
|
||||
msg='Failed to apply record changes to domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str(
|
||||
json.dumps({
|
||||
"domain": domain_name,
|
||||
"msg": result['msg'],
|
||||
})),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
return make_response(jsonify(result), 400)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot apply record changes. Error: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'error',
|
||||
'msg': 'Error when applying new changes'
|
||||
}), 500)
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/update',
|
||||
methods=['POST'],
|
||||
strict_slashes=False)
|
||||
@login_required
|
||||
@can_access_domain
|
||||
def record_update(domain_name):
|
||||
"""
|
||||
This route is used for domain work as Slave Zone only
|
||||
Pulling the records update from its Master
|
||||
"""
|
||||
try:
|
||||
jdata = request.json
|
||||
|
||||
domain_name = jdata['domain']
|
||||
d = Domain()
|
||||
result = d.update_from_master(domain_name)
|
||||
if result['status'] == 'ok':
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'ok',
|
||||
'msg': result['msg']
|
||||
}), 200)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'error',
|
||||
'msg': result['msg']
|
||||
}), 500)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Cannot update record. Error: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'error',
|
||||
'msg': 'Error when applying new changes'
|
||||
}), 500)
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/info', methods=['GET'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
def info(domain_name):
|
||||
domain = Domain()
|
||||
domain_info = domain.get_domain_info(domain_name)
|
||||
return make_response(jsonify(domain_info), 200)
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/dnssec', methods=['GET'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
def dnssec(domain_name):
|
||||
domain = Domain()
|
||||
dnssec = domain.get_domain_dnssec(domain_name)
|
||||
return make_response(jsonify(dnssec), 200)
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/dnssec/enable', methods=['POST'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
@can_configure_dnssec
|
||||
def dnssec_enable(domain_name):
|
||||
domain = Domain()
|
||||
dnssec = domain.enable_domain_dnssec(domain_name)
|
||||
return make_response(jsonify(dnssec), 200)
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/dnssec/disable', methods=['POST'])
|
||||
@login_required
|
||||
@can_access_domain
|
||||
@can_configure_dnssec
|
||||
def dnssec_disable(domain_name):
|
||||
domain = Domain()
|
||||
dnssec = domain.get_domain_dnssec(domain_name)
|
||||
|
||||
for key in dnssec['dnssec']:
|
||||
domain.delete_dnssec_key(domain_name, key['id'])
|
||||
|
||||
return make_response(jsonify({'status': 'ok', 'msg': 'DNSSEC removed.'}))
|
||||
|
||||
|
||||
@domain_bp.route('/<path:domain_name>/manage-setting', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
def admin_setdomainsetting(domain_name):
|
||||
if request.method == 'POST':
|
||||
#
|
||||
# post data should in format
|
||||
# {'action': 'set_setting', 'setting': 'default_action, 'value': 'True'}
|
||||
#
|
||||
try:
|
||||
jdata = request.json
|
||||
data = jdata['data']
|
||||
|
||||
if jdata['action'] == 'set_setting':
|
||||
new_setting = data['setting']
|
||||
new_value = str(data['value'])
|
||||
domain = Domain.query.filter(
|
||||
Domain.name == domain_name).first()
|
||||
setting = DomainSetting.query.filter(
|
||||
DomainSetting.domain == domain).filter(
|
||||
DomainSetting.setting == new_setting).first()
|
||||
|
||||
if setting:
|
||||
if setting.set(new_value):
|
||||
history = History(
|
||||
msg='Setting {0} changed value to {1} for {2}'.
|
||||
format(new_setting, new_value,
|
||||
pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'ok',
|
||||
'msg': 'Setting updated.'
|
||||
}))
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'error',
|
||||
'msg': 'Unable to set value of setting.'
|
||||
}))
|
||||
else:
|
||||
if domain.add_setting(new_setting, new_value):
|
||||
history = History(
|
||||
msg=
|
||||
'New setting {0} with value {1} for {2} has been created'
|
||||
.format(new_setting, new_value, pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'ok',
|
||||
'msg': 'New setting created and updated.'
|
||||
}))
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'error',
|
||||
'msg': 'Unable to create new setting.'
|
||||
}))
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status': 'error',
|
||||
'msg': 'Action not supported.'
|
||||
}), 400)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
'Cannot change domain setting. Error: {0}'.format(e))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'There is something wrong, please contact Administrator.'
|
||||
}), 400)
|
1157
powerdnsadmin/routes/index.py
Normal file
1157
powerdnsadmin/routes/index.py
Normal file
File diff suppressed because it is too large
Load diff
99
powerdnsadmin/routes/user.py
Normal file
99
powerdnsadmin/routes/user.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
import datetime
|
||||
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
|
||||
from flask_login import current_user, login_required, login_manager
|
||||
|
||||
from ..models.user import User, Anonymous
|
||||
from ..models.setting import Setting
|
||||
|
||||
user_bp = Blueprint('user',
|
||||
__name__,
|
||||
template_folder='templates',
|
||||
url_prefix='/user')
|
||||
|
||||
|
||||
@user_bp.before_request
|
||||
def before_request():
|
||||
# Check if user is anonymous
|
||||
g.user = current_user
|
||||
login_manager.anonymous_user = Anonymous
|
||||
|
||||
# Check site is in maintenance mode
|
||||
maintenance = Setting().get('maintenance')
|
||||
if maintenance and current_user.is_authenticated and current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
]:
|
||||
return render_template('maintenance.html')
|
||||
|
||||
# Manage session timeout
|
||||
session.permanent = True
|
||||
current_app.permanent_session_lifetime = datetime.timedelta(
|
||||
minutes=int(Setting().get('session_timeout')))
|
||||
session.modified = True
|
||||
|
||||
|
||||
@user_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
if request.method == 'GET':
|
||||
return render_template('user_profile.html')
|
||||
if request.method == 'POST':
|
||||
if session['authentication_type'] == 'LOCAL':
|
||||
firstname = request.form.get('firstname', '').strip()
|
||||
lastname = request.form.get('lastname', '').strip()
|
||||
email = request.form.get('email', '').strip()
|
||||
new_password = request.form.get('password', '')
|
||||
else:
|
||||
firstname = lastname = email = new_password = ''
|
||||
current_app.logger.warning(
|
||||
'Authenticated externally. User {0} information will not allowed to update the profile'
|
||||
.format(current_user.username))
|
||||
|
||||
if request.data:
|
||||
jdata = request.json
|
||||
data = jdata['data']
|
||||
if jdata['action'] == 'enable_otp':
|
||||
if session['authentication_type'] in ['LOCAL', 'LDAP']:
|
||||
enable_otp = data['enable_otp']
|
||||
user = User(username=current_user.username)
|
||||
user.update_profile(enable_otp=enable_otp)
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status':
|
||||
'ok',
|
||||
'msg':
|
||||
'Change OTP Authentication successfully. Status: {0}'
|
||||
.format(enable_otp)
|
||||
}), 200)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({
|
||||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'User {0} is externally. You are not allowed to update the OTP'
|
||||
.format(current_user.username)
|
||||
}), 400)
|
||||
|
||||
user = User(username=current_user.username,
|
||||
plain_text_password=new_password,
|
||||
firstname=firstname,
|
||||
lastname=lastname,
|
||||
email=email,
|
||||
reload_info=False)
|
||||
user.update_profile()
|
||||
|
||||
return render_template('user_profile.html')
|
||||
|
||||
|
||||
@user_bp.route('/qrcode')
|
||||
@login_required
|
||||
def qrcode():
|
||||
if not current_user:
|
||||
return redirect(url_for('index'))
|
||||
|
||||
return current_user.get_qrcode_value(), 200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
4
powerdnsadmin/services/__init__.py
Normal file
4
powerdnsadmin/services/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from .base import authlib_oauth_client
|
||||
|
||||
def init_app(app):
|
||||
authlib_oauth_client.init_app(app)
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue