Compare commits

...

467 commits
v0.1 ... master

Author SHA1 Message Date
dapillc cd94b5c0ac
Update API.md (#1100)
armless > harmless
2022-01-19 17:49:30 +02:00
zoeller-freinet 0b2ad520b7 History table: relocate HTML for modal window (#1090)
- Store HTML for modal window inside an invisible <div> element instead
  of inside the <button> element's value attribute
- Mark history.detailed_msg as safe as it is already manually run
  through the template engine beforehand and would be broken if escaped
  a second time
2022-01-01 21:20:01 +01:00
Christian 302e793665
Add button for admin page in single Domain view (#1076)
* Added button for admin page in domain overview
2021-12-31 00:55:59 +01:00
RGanor 328780e2d4 Revert "Merge branch 'master' into master"
This reverts commit ca4c145a18, reversing
changes made to 7808febad8.
2021-12-25 16:17:54 +02:00
RGanor ca4c145a18
Merge branch 'master' into master 2021-12-25 16:10:18 +02:00
zoeller-freinet 7808febad8 login.html: don't suggest previous OTP tokens
This change has been tested to work with:
- Chromium 96.0.4664.93
- Firefox 95.0
- Edge 96.0.1054.57
2021-12-17 12:48:11 +01:00
dependabot[bot] 9ef0f2b8d6 Bump python-ldap from 3.3.1 to 3.4.0
Bumps [python-ldap](https://github.com/python-ldap/python-ldap) from 3.3.1 to 3.4.0.
- [Release notes](https://github.com/python-ldap/python-ldap/releases)
- [Commits](https://github.com/python-ldap/python-ldap/compare/python-ldap-3.3.1...python-ldap-3.4.0)

---
updated-dependencies:
- dependency-name: python-ldap
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-17 12:08:19 +01:00
Vasileios Markopoulos 94a923a965
Add 'otp_force' basic setting (#1051)
If the 'otp_force' and 'otp_field_enabled' basic settings are both enabled, automatically enable 2FA for the user after login or signup, if needed, by setting a new OTP secret. Redirect the user to a welcome page for scanning the QR code.

Also show the secret key in ASCII form on the user profile page for easier copying into other applications.
2021-12-17 11:41:51 +01:00
Jérôme BECOT 0da9b2185e fix: Error in the swagger AccountSummary definition 2021-12-08 23:11:13 +01:00
zoeller-freinet 07f0d215a7 PDNS-API: factor in 'dnssec_admins_only' basic setting (#1055)
`GET cryptokeys/{cryptokey_id}` returns the private key, which justifies
that the setting is honored in this case.
2021-12-06 22:38:16 +01:00
Khanh Ngo fc8367535b
chore: remove funding and sponsor badges (#1073) 2021-12-08 17:44:44 +01:00
Jérôme BECOT d2f35a4059 fix: Check user zone create/delete permission
Co-authored-by: zoeller-freinet <86965592+zoeller-freinet@users.noreply.github.com>
2021-12-05 14:16:45 +01:00
zoeller-freinet 737e1fb93b routes/admin.py: DetailedHistory: backward-compatibility
See https://github.com/ngoduykhanh/PowerDNS-Admin/pull/1066
2021-12-04 17:38:48 +01:00
zoeller-freinet f0008ce401 routes/admin.py: refactor DetailedHistory
- Run HTML through the template engine, preventing XSS from various
  vectors
- Fix uncaught exception when a history entry about domain template
  deletion is processed
- Adapt indentation to 4 space characters per level
2021-12-04 16:09:53 +01:00
Dominic Zöller 6f12b783a8 models.user: get_accounts(): order by name
The order of account names returned by User.get_accounts() affects the
order account names are displyed in on /domain/add if the current user
neither has the Administrator role nor the Operator role and the
`allow_user_create_domain` setting is enabled at the same time.

If the current user does have the Administrator or Operator role,
routes.domain.add() already returns accounts ordered by name, so this
change makes it consistent.
2021-12-04 16:09:15 +01:00
Dominic Zöller 51a7f636b0 Use secrets module for generating new API keys and passwords
The implementation of `random.choice()` uses the Mersenne Twister, the
output of which is predictable by observing previous output, and is as
such unsuitable for security-sensitive applications. A cryptographically
secure pseudorandom number generator - which the `secrets` module relies
on - should be used instead in those instances.
2021-12-04 16:08:07 +01:00
ManosKoukoularis 9f46188c7e
Quotes fix (#1066)
* minor fix in history
* made key access more generic
2021-12-03 20:14:14 +02:00
root caa48b7fe5 Merge branch 'quotes-fix'
Conflicts:
	powerdnsadmin/routes/admin.py
2021-12-03 14:17:39 +00:00
root 591055d4aa Merge branch 'master' of https://github.com/ngoduykhanh/PowerDNS-Admin 2021-12-03 14:12:32 +00:00
root 940551e99e feat: Associate an API Key with accounts (#1044) 2021-12-03 14:12:11 +00:00
jbe-dw f45ff2ce03
feat: Associate an API Key with accounts (#1044) 2021-12-03 15:35:15 +02:00
ManosKoukoularis 6c1dfd2408
Datepicker replace (#1059)
* replaced jquery-ui-datepicker with bootstrap-datepicker

* removed obsolete static files
2021-12-02 11:59:36 +01:00
Dominic Zöller 701a442d12 default config: add exemplary URL encoding step for SQLA DB URL params
SQLAlchemy database URLs follow RFC-1738, so parameters like username
and password need to be encoded accordingly.

https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
2021-11-30 22:29:00 +01:00
Nick Bouwhuis a3b70a8f47
Add Keycloak documentation (#1053) 2021-11-30 12:26:58 +02:00
ManosKoukoularis 1332c8d29d
History Tab Overhaul & Domain Record Modifications Changelog (#1042)
Co-authored-by: Konstantinos Kouris <85997752+konkourgr@users.noreply.github.com>
Co-authored-by: vmarkop <billy.mark.b.m.10@gmail.com>
Co-authored-by: KostasMparmparousis <mparmparousis.kostas@gmail.com>
Co-authored-by: dimpapac <demispapa@gmail.com>
2021-11-30 11:02:37 +02:00
benshalev849 b3f9b4a2b0
OIDC list accounts (#994)
Added the function to use lists instead of a single string in account autoprovision.
2021-11-19 17:53:17 +02:00
zoeller-freinet bfaf5655ae
Clarify salt re-use for API keys (#1037) 2021-11-09 22:09:15 +02:00
Khanh Ngo dd04a837bb
Update docker image build script 2021-11-06 15:44:20 +01:00
Khanh Ngo 5bb1a7ee29
Update docker image build script 2021-11-06 15:37:13 +01:00
Khanh Ngo c85a5dac24
Update docker image build script 2021-11-06 15:25:20 +01:00
benshalev849 3081036c2c
Env oauth url (#1030)
Overriding settings in DB using environment variable in docker
2021-11-05 18:22:38 +02:00
Daniel Molkentin c7b4aa3434
fix: actually store OIDC logout URL (#988) 2021-11-05 17:28:21 +02:00
Vitali Quiering e7d5a3aba0
feat: enable_api_rr_history setting (#998)
* feat: introduce enable_api_rr_history setting to disable api record
changes
2021-11-05 17:26:38 +02:00
zoeller-freinet 20b866a784
strip() whitespace from new local user master data (#1019)
When creating a new local user, there is a chance that, due to a copy &
paste or typing error, whitespace will be introduced at the start or end
of the username. This can lead to issues when trying to log in using the
affected username, as such a condition can easily be overlooked - no
user will be found in the database if entering the username without the
aforementioned whitespace. This commit therefore strip()s the username
string within routes/{admin,index}.py.

The firstname, lastname and email strings within
routes/{admin,index,user}.py are also strip()ped on this occasion.
2021-11-05 17:04:35 +02:00
Khanh Ngo 1662a812ba Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:34:35 +01:00
Khanh Ngo c49df09ac8 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:31:14 +01:00
Khanh Ngo 924537b468 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:25:22 +01:00
Khanh Ngo 4f8a547d47 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:23:49 +01:00
Khanh Ngo ee9f568a8d
Update README.md 2021-10-31 13:16:42 +01:00
Khanh Ngo d7ae34ed53 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 13:08:22 +01:00
jbe-dw 1c9ca60508
fix: jsmin 2.2.2 no longer available. Use 3.0.0 (#1021) 2021-10-30 21:30:53 +02:00
zoeller-freinet 0e655c1357
user_profile tpl: set email input type attr to "email" (#1020)
It is then consistent with the email address input elements declared in
admin_edit_account.html, admin_edit_user.html and register.html.
2021-10-30 21:30:26 +02:00
steschuser ba2423d6f5
fix if condition in pretty_domain_name (#1008) 2021-10-30 21:29:55 +02:00
Andreas Dirnberger 46e51f16cb
Remove unnecessary build step (#1003)
The builder image does not need to cleanup itself, 
the whole purpose of it is to be dropped after the final artifacts are copied out.
2021-10-30 21:29:23 +02:00
jbe-dw b8ee91ab9a
fix: Accounts API is broken (#996) 2021-10-30 21:28:36 +02:00
RGanor c246775ffe
bg_domain button for operators and higher (#993) 2021-10-30 21:26:46 +02:00
Hidde f96103db79
Replace [ZONE] placeholder with domain_name (#960) 2021-10-30 21:24:16 +02:00
steschuser bf83662108
allow users to remove domain (#952) 2021-10-30 21:21:45 +02:00
steschuser 1f34dbf810
fix for api key (#950) 2021-10-30 21:19:49 +02:00
Khanh Ngo b7197948c1 Reslove conflicts
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-30 21:19:01 +02:00
Khanh Ngo ddf2d4788b Reslove conflicts
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-30 21:15:04 +02:00
steschuser 1ec6b76f89
Remove otp field (#942) 2021-10-30 21:09:04 +02:00
Mark Zealey 4ce1b71c57
Fix when no records returned by API (#923)
For some reason when some programs delete a record we get an entry returned with records: []
2021-10-30 21:07:42 +02:00
steschuser 79457bdc85
Bug domain parse (#936) 2021-10-30 21:06:44 +02:00
RoeiGanor 10dc2b0273 bg_domain button for operators and higher 2021-08-13 20:03:06 +03:00
steschuser 993e02b635
limit user to only create domains for the accounts he belongs to (#970) 2021-08-05 19:42:58 +02:00
steschuser 07c71fb0bf
setting account_user_ids to empty list on GET /account/edit (#966) 2021-08-05 19:41:28 +02:00
steschuser c4a9498898
respect_bg_domain_updates in routes/api (#962) 2021-08-05 19:39:26 +02:00
Kostas Mparmparousis 6e04d0419b
Provision PDA user privileges based On LDAP Attributes (#980) 2021-08-05 19:37:48 +02:00
Carsten Rosenberg d6e64dce8e fix some jinja typos 2021-06-04 15:24:49 +02:00
Steffen Schwebel b069cea8d1 add css to base as well 2021-06-02 09:44:15 +02:00
Steffen Schwebel fd933f8dbc remove unrelated files and changes as best as possible 2021-06-02 09:41:08 +02:00
Steffen Schwebel 0505b934a1 remove unrelated files and changes as best as possible 2021-06-02 09:39:39 +02:00
Steffen Schwebel 083a023e57 fix include 2021-06-01 16:41:26 +02:00
Steffen Schwebel 054e0e6eba add rule for 'custom_css' setting 2021-06-01 16:24:07 +02:00
Steffen Schwebel c13dd2d835 add 'custom_css' setting to model; check for 'custom_css' in template; create custom css dir in dockerfile 2021-06-01 16:15:31 +02:00
steschuser 567f66fbde
Merge pull request #4 from uvensys/remove_otp_field
Remove otp field
2021-06-01 15:28:41 +02:00
steschuser ff5270fbad
Merge pull request #3 from uvensys/add_background_jobs_to_docker
add environment to cron
2021-06-01 15:21:22 +02:00
Steffen Schwebel 92bad7b11c add environment to cron 2021-06-01 14:02:01 +02:00
Steffen Schwebel 43a6e46e66 add setting to hide otp_token field on login page 2021-05-27 22:51:07 +02:00
Steffen Schwebel ee72fdf9c2 Merge branch 'master' of github.com:uvensys/PowerDNS-Admin into remove_otp_field 2021-05-27 21:56:01 +02:00
steschuser 8f73512d2e
Merge pull request #2 from uvensys/add_background_jobs_to_docker
Add background jobs to docker
2021-05-27 21:33:27 +02:00
Steffen Schwebel 700fa0d9ce add new dockerfile with s6 overlay and multiple proccesses to have background jobs updating accounts and zones 2021-05-27 21:32:00 +02:00
Steffen Schwebel 00dc23f21b added new Dockerfile, to support more than one process running in docker, using s6 overlay 2021-05-27 16:39:51 +02:00
Steffen Schwebel 36fdb3733f Merge remote-tracking branch 'origin/master' into remove_otp_field 2021-05-25 15:30:32 +02:00
steschuser ce60ca0b9d
Merge pull request #1 from uvensys/bug_domain_parse
Bug domain parse
2021-05-25 12:53:57 +02:00
Steffen Schwebel b197491a86 remove traceback 2021-05-25 12:44:07 +02:00
Steffen Schwebel d23a57da50 handle decode error, output warning 2021-05-25 12:35:53 +02:00
Steffen Schwebel 4180882fb7 show traceback 2021-05-21 15:10:17 +02:00
root bbbcf271fe remove otp token from login page, depending on Setting 2021-05-20 15:21:56 +02:00
jyoung15 32983635c6
Delete blank comments. Fix for ngoduykhanh/PowerDNS-Admin#919 (#920) 2021-05-07 23:43:44 +02:00
Jay Linski f3a98eb692
Emphasize importance of using a custom SECRET_KEY (#931)
This project provides a default SECRET_KEY for signing session-cookies:
https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY

By using the default SECRET_KEY, everyone will be able to create valid session-cookies.
So users should be aware that it is very important to set a custom SECRET_KEY.
2021-05-07 23:40:54 +02:00
Ian Bobbitt 39cddd3b34
SAML improvements for Docker (#929)
* Fix typo in managing user account membership with SAML assertion

* Support more config options from Docker env.

* Improve support for SAML key and cert from Docker secrets

Co-authored-by: Ian Bobbitt <ibobbitt@globalnoc.iu.edu>
2021-05-07 23:36:55 +02:00
jodygilbert b66b37ecfd
delete history records when a domain is deleted (#916)
Co-authored-by: Jody <jody.gilbert@edftrading.com>
2021-05-07 22:55:45 +02:00
dependabot[bot] 5f10f739ea
Bump pyyaml from 5.3.1 to 5.4 (#912) 2021-03-27 19:33:49 +01:00
jodygilbert 98db953820
Allow user role to view history (#890) 2021-03-27 19:33:11 +01:00
dependabot[bot] 44c4531f02
Bump elliptic from 6.5.3 to 6.5.4 (#896)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-16 19:41:46 +01:00
jbe-dw 86700f8fd7
upd: improve user api (#878) 2021-03-16 19:39:53 +01:00
R. Daneel Olivaw 46993e08c0
Add punycode (IDN) support (#879) 2021-03-16 19:37:05 +01:00
jodygilbert 4c19f95928
Improve account creation/permission handling based on Azure oAuth group membership (#877) 2021-01-31 11:31:56 +01:00
jbe-dw 3a4efebf95
enh: display b64 encoded apikey on creation through the API (#870) 2021-01-24 09:43:51 +01:00
jodygilbert 7f86730909
allow-server-side-sessions (#855) 2021-01-24 09:09:53 +01:00
jbe-dw 8f6a800836
fix: account API output^ (#874) 2021-01-24 09:08:32 +01:00
jbe-dw 3cd98251b3
fix: API (apikeys) behaviour does not match swagger definition (#868) 2021-01-24 09:06:51 +01:00
jbe-dw 54b257768f
feat: Implement apikeys/<id> endpoint from swagger spec. (#864) 2021-01-16 20:49:41 +01:00
jbe-dw 718b41e3d1
feat: limit zone list for users on servers endpoint (#862) 2021-01-16 20:45:02 +01:00
jbe-dw dd0a5f6326
feat: Allow sync domain with basic auth (#861) 2021-01-16 20:37:11 +01:00
jbe-dw c3d438842f
fix: user jsonify to set response headers to json (#863) 2021-01-16 20:29:40 +01:00
jbe-dw 33e7ffb747
fix: Follow PDNS Api return format (#858) 2021-01-07 23:26:48 +01:00
jbe-dw 2c18e5c88f
fix: User role was not assigned upon creation (#860) 2021-01-07 23:07:20 +01:00
mrsrvman 2917c47fd1
Update entrypoint.sh (#852)
Fix typo
2020-12-23 17:23:32 +01:00
WhatshallIbreaktoday c6e0293177
Tweaks to allow user apikey usage with powerdns terraform provider (#845) 2020-12-07 22:06:37 +01:00
Attila DEBRECZENI 942482b706
set chown to /app docker workdir (#841) 2020-12-07 19:46:08 +01:00
Khanh Ngo 4d1db72699
Merge pull request #828 from andrewnimmo/patch-1
Avoid Safari telephone number detection
2020-10-14 17:49:51 +02:00
Andrew Nimmo 680e4cf431
Avoid Safari telephone number detection
Using PowerDNS-Admin on an iPad with Safari can cause incorrect identification of some record data as a telephone number. When submitted, the record with the incorrectly identified data causes an error because of the additional markup present on the submitted data. This was noted in particular with the SOA record. 

The proposed change is to add the Safari meta tag to disable format detection:
https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html#//apple_ref/doc/uid/TP40008193-SW5
2020-10-14 17:21:59 +02:00
Khanh Ngo 1604494f1d
Create stale.yml 2020-10-12 22:50:39 +02:00
Khanh Ngo 710cb75bfe
Create FUNDING.yml 2020-10-12 21:48:26 +02:00
Khanh Ngo 70b1accaa0
Merge pull request #801 from cyso/pr/sync-accounts
Implement account update method
2020-10-12 12:48:22 +02:00
Khanh Ngo 7254a94497
Merge pull request #825 from ngoduykhanh/adjustment
Add index on history table
2020-10-11 13:11:09 +02:00
Khanh Ngo 3034630bc0
Merge pull request #761 from ngoduykhanh/record_rollback
Fix #752 - Rollback the removed record if apply operation failed
2020-10-11 13:01:26 +02:00
Khanh Ngo d72709e0f4
Add index on history table 2020-10-11 12:49:28 +02:00
Khanh Ngo a1c1b35696
Merge pull request #824 from ngoduykhanh/pip-packages-fix
requirements.txt update
2020-10-10 22:11:48 +02:00
Khanh Ngo 94eeae0cad
Downgrade to dnspython>=1.16.0 2020-10-10 21:52:46 +02:00
Khanh Ngo 75a30f14fb
Dockerfile and requirements.txt update
- Fix bug in python requests module missing after delete py3-pip from build stage
- Downgrade the requirement of bcrypt to >=3.1.7 for older python3 version compatibility
2020-10-10 21:47:58 +02:00
Khanh Ngo 76562f8a46
Fix typo
Remove space from oidc_oauth_last_name default value
2020-10-10 21:03:34 +02:00
Khanh Ngo 6455189c32
Merge pull request #823 from ngoduykhanh/upgrade-pip-packages
Upgrade pip packages
2020-10-10 20:31:10 +02:00
Khanh Ngo 7e6d5d2e4a Merge branch 'master' into upgrade-pip-packages 2020-10-10 20:30:18 +02:00
Khanh Ngo 372fdd7bd0 Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2020-10-10 20:29:01 +02:00
Khanh Ngo 0dfced4968
Upgrade pip packages 2020-10-10 20:27:09 +02:00
Khanh Ngo 33282ae4af
Merge pull request #797 from ngoduykhanh/dependabot/npm_and_yarn/elliptic-6.5.3
Bump elliptic from 6.4.0 to 6.5.3
2020-10-10 14:43:42 +02:00
Khanh Ngo 078b0b2f4d Merge remote-tracking branch 'birkb/master' 2020-10-10 14:39:43 +02:00
Khanh Ngo 55ad73d92e
Merge pull request #800 from cyso/pr/oidc-account
OIDC User and Account management during login
2020-10-10 14:32:14 +02:00
Khanh Ngo a679073928
Merge pull request #773 from terbolous/azure-oauth
Add Account creation/permission handling based on Azure oAuth group membership
2020-10-10 14:20:26 +02:00
Khanh Ngo b5fc9045f2
Merge pull request #766 from frei-style/sort_accounts
Sort accounts by name on 'New Domain' and domain 'Admin'
2020-10-10 14:18:02 +02:00
Khanh Ngo f3bcf1b834
Merge pull request #763 from RoeiGanor/fix_gravatar
Fix gravatar offline mode
2020-10-10 14:16:57 +02:00
Khanh Ngo b8ffb1dae9
Merge pull request #804 from RoeiGanor/api_key
Add API Key to the UI
2020-10-10 14:11:09 +02:00
Khanh Ngo b10a706e15
Merge pull request #817 from virtualox/patch-1
Update README.md
2020-10-10 14:03:45 +02:00
Khanh Ngo b12377796b
Merge pull request #814 from TomHetmer/master
do not fail with wildcard PTRs
2020-10-10 14:03:02 +02:00
Khanh Ngo 58f3c241b4
Merge pull request #815 from vlche/master
Alpine 3.12
2020-10-10 14:01:42 +02:00
Birk Bohne 9228128907 USER instruction enabled in Dockerfile
- avoid running gunicorn as root user
- use setcap to allow gunicorn starting with a privileged port
- write permission only on /app/configs folder
- su command removed from entrypoint.sh, because it runs as pda user
2020-09-19 19:58:51 +02:00
VirtualOx 3167e50f65
Update README.md
Missing \ in docker run script at option 1.
2020-09-16 08:43:41 +02:00
Vladimir Che 810f773a5b
bumped up alpine to 3.12 2020-09-10 21:26:14 +03:00
Tom Hetmer cf62658e19
do not fail with wildcard PTRs 2020-09-10 01:18:07 +07:00
Vladimir Che 352e7d388a
Merge pull request #1 from vlche/whitesource/configure
Configure WhiteSource Bolt for GitHub
2020-09-01 00:06:47 +03:00
whitesource-bolt-for-github[bot] 8735f1e273
Add .whitesource configuration file 2020-08-31 20:52:34 +00:00
root 74b89b1b7e Add API Key to the UI 2020-08-08 13:41:18 +00:00
Nick Douma 70c2744f29 Log amount of accounts found in PowerDNS 2020-08-06 17:45:05 +02:00
Nick Douma 3c59ba6f84 Account does not have username 2020-08-06 17:44:54 +02:00
Nick Douma b4d7f66e29 Use Account.delete_account to also handle unlinking of Users 2020-08-06 16:33:00 +02:00
Nick Douma 9632898b40 Domains should not be updated in update_accounts.py 2020-08-06 15:40:11 +02:00
Nick Douma f9f966df75 Allow for configuration of logout url 2020-08-06 15:29:02 +02:00
Nick Douma 27f5c89f70 Manage Account membership on oidc login 2020-08-06 15:28:54 +02:00
Nick Douma 7ef6f5db4e Check if plain_text_password is falsish instead of empty string 2020-08-06 15:28:45 +02:00
Nick Douma ab6480a4b4 Update user with info from oidc during login 2020-08-06 15:28:27 +02:00
Nick Douma 0ef57b2f9f Implement account update method
Allow syncing of all known accounts from PowerDNS, in the same
way that Domain().update() does for domains.
2020-08-06 15:22:28 +02:00
dependabot[bot] 8377f08d3b
Bump elliptic from 6.4.0 to 6.5.3
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.4.0 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.4.0...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-30 11:54:11 +00:00
Erik Weber 22eabef06a
Use the correct matching group 2020-07-03 11:01:17 +02:00
Erik Weber e993422106
Add regex matching for group/account description 2020-07-03 10:55:06 +02:00
Erik Weber 25db119d02
Add Account creation/permission handling based on Azure oAuth group membership 2020-07-03 08:55:31 +02:00
Manuel Frei 9946f72a85 Order accounts by name on 'New Domain' and domain admin 2020-06-28 01:59:12 +02:00
RoeiGanor 5125c9764c
Delete FETCH_HEAD 2020-06-24 11:21:45 +03:00
Roei Ganor 0f9a5f8652 Fix gravatar offline mode 2020-06-24 11:17:45 +03:00
Khanh Ngo f3f91d56e2
Adjust html code 2020-06-20 11:08:14 +07:00
Khanh Ngo 1b4fe8935d Merge branch 'master' into record_rollback 2020-06-20 10:17:52 +07:00
Khanh Ngo 4e63f8380b
Merge pull request #756 from RoeiGanor/master
Changed default TTL
2020-06-20 09:55:41 +07:00
Khanh Ngo 1f4580a27a
Log failed domain apply operation 2020-06-20 09:51:30 +07:00
Khanh Ngo 5123d542e4
Fix #752 - Rollback the removed record if apply operation failed 2020-06-19 09:34:33 +07:00
Khanh Ngo 94da9198c0
Merge pull request #760 from ngoduykhanh/refactoring
Code refactoring and bug fixes
2020-06-19 09:16:08 +07:00
Khanh Ngo a3fd856dd8
Code refactoring and bug fixes 2020-06-19 08:47:51 +07:00
Roei 84ae753db2 Changed default TTL 2020-06-14 09:13:01 +03:00
Khanh Ngo 5eb2edee2c
Merge pull request #747 from ymage/fix_logout_746
Fix session clearing
2020-06-08 10:24:43 +07:00
Ymage 4e39d5a461 Fix session clearing 2020-05-29 17:41:20 +02:00
Khanh Ngo cfc8567180
Merge pull request #742 from nfantone/feat/remote-user
Support authenticating using REMOTE_USER environment variable
2020-05-26 15:36:47 +07:00
Nicolás Fantone 39db31b5ae Add missing ensure_list util function 2020-05-25 14:16:33 +01:00
Nicolás Fantone eb730be8f9 Add remote user config settings 2020-05-25 14:12:32 +01:00
Khanh Ngo 831fbf3cb3
Merge pull request #738 from ymage/url_for_static_assets
Add url_for() for static assets
2020-05-24 21:22:33 +07:00
Khanh Ngo 125883330e Merge branch 'pull/679' 2020-05-24 21:19:04 +07:00
Khanh Ngo 73c267848c
Merge branch 'master' into url_for_static_assets 2020-05-24 21:16:00 +07:00
Khanh Ngo 5ac126f349
Merge pull request #739 from sshipway/sorting
Sort rrset members by content before comparison to ensure correct compare
2020-05-24 21:06:51 +07:00
Nicolás Fantone 52298f8289 Support login in through REMOTE_USER environment variable
Support redirecting remote users to logout URL and clearing remote login cookies
2020-05-22 15:31:24 +01:00
Khanh Ngo a598c52729
Fix base template
current_user is Anonymous when we are not authenticated. Need to check if
email attribute is defined before using it
2020-05-22 17:37:39 +07:00
Steve Shipway 3476c8a9ec SJS Sort rrset members by content before comparison to ensure correct compare. Also default comment to blank entry rather than missing entry, to allow sorting to work correctly. 2020-05-20 16:21:28 +12:00
Ymage 99f12df748 Add url_for() for static assets 2020-05-20 00:03:36 +02:00
Khanh Ngo 0ef132a7be
Update swagger yaml 2020-05-17 21:00:13 +07:00
Khanh Ngo fd0485d897 Merge remote-tracking branch 'keesbos/mgmt' 2020-05-17 19:43:45 +07:00
Khanh Ngo 9f4b6ffcdb Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2020-05-17 16:12:03 +07:00
Khanh Ngo ab7e1eb71b
Fix email confirmation template 2020-05-17 16:11:52 +07:00
Khanh Ngo d43c9a581f
Update README.md 2020-05-17 14:37:15 +07:00
Khanh Ngo edb2a354d1
Merge pull request #725 from Monogramm/ui/favicon
fix: Add favicon to fix #724
2020-05-17 13:47:18 +07:00
Khanh Ngo 78245d339f
Merge pull request #717 from RoeiGanor/master
OIDC custom claims
2020-05-17 13:44:52 +07:00
Khanh Ngo f442fef3d6
Update gitignore 2020-05-17 13:40:24 +07:00
mathieu.brunot 9f562714f2
fix: Add favicon to fix #724
Signed-off-by: mathieu.brunot <mathieu.brunot@monogramm.io>
2020-05-09 23:56:19 +02:00
root 2044ce4737 oidc custom claims 2020-05-04 07:12:48 +00:00
Khanh Ngo 33eff6313f
Fix loading zone with Slave type 2020-05-02 09:41:01 +07:00
Khanh Ngo 978c0b6c62
Merge pull request #711 from terbolous/azure_oauth
Azure oauth fixes
2020-05-01 10:25:27 +07:00
Khanh Ngo 5bd8990a54 Merge remote-tracking branch 'RoeiGanor/master' 2020-05-01 10:07:25 +07:00
Roei Ganor 483c767d26 Offline installation and searchable inputs 2020-04-30 17:20:37 +00:00
Erik Weber b03cbdea65
Azure oauth: Graph api calls it mail, not email 2020-04-30 12:15:19 +02:00
Erik Weber b8442c4c5c
Azure oauth: remove preferredName from query 2020-04-30 12:14:32 +02:00
Khanh Ngo bd1eb3cce9
Merge pull request #709 from saschaludwig/master
fixed typo
2020-04-30 10:24:38 +07:00
Khanh Ngo 27d1de3a34
Merge pull request #703 from kaechele/persistent-sqlite-docker
Use sqlite by default in docker
2020-04-30 10:23:01 +07:00
Khanh Ngo 9e8401ae46
Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2020-04-30 10:17:32 +07:00
Khanh Ngo 02692c7759
Code formating 2020-04-30 10:17:08 +07:00
Khanh Ngo de6d8c3dce Merge remote-tracking branch 'ixaustralia/master' 2020-04-30 09:15:26 +07:00
Khanh Ngo bbd00865dc
Merge pull request #681 from eht16/fix_create_template_from_zone_url
Remove $SCRIPT_ROOT from 'admin.create_template_from_zone' URL
2020-04-30 09:12:04 +07:00
Sascha Ludwig ccb27e21b3
fixed typo 2020-04-29 18:20:21 +02:00
Felix Kaechele 06266846ec Use sqlite by default in docker
This allows for easy setup with no extra configuration.
Also update docs to reflect the new and easy way to run PowerDNS-Admin.

Signed-off-by: Felix Kaechele <felix@kaechele.ca>
2020-04-20 18:32:36 -04:00
Marcus Knight 0e093d4eec Support only having one auth method enabled (which isn't ldap/local) 2020-04-16 16:04:10 +08:00
Khanh Ngo 452c4a02d8
Merge pull request #690 from Atisom/master
SAML debug attributes
2020-04-06 09:40:03 +07:00
Attila DEBRECZENI def06bee41 set SAML wantAssertionsEncrypted from config 2020-04-03 13:44:03 +00:00
Khanh Ngo b2d72d6603
Fix docker bool config value 2020-04-02 09:41:26 +07:00
Khanh Ngo 403f063977
Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2020-03-28 10:49:25 +07:00
Khanh Ngo 3ae8fce3b1
Merge pull request #686 from christianbur/master
Update Alpine 3.10 -> 3.11; update pip, tzdata, gunicorn
2020-03-28 10:49:01 +07:00
Khanh Ngo c8490146ed Merge remote-tracking branch 'christianbur/master' 2020-03-28 09:56:24 +07:00
Khanh Ngo ee8ab848e3
Merge pull request #691 from athalonis/feature/secure-files
Add docker secrets support
2020-03-28 09:52:18 +07:00
Attila DEBRECZENI cc26174a48
wantAssertionsEncrypted to false 2020-03-27 02:19:06 +01:00
Benjamin Bässler 7e97bec07f
Add docker secrets support 2020-03-27 00:59:28 +01:00
Attila DEBRECZENI a581aa3cf2 add SAML_ASSERTION_ENCRYPTED envrionment 2020-03-25 21:35:20 +00:00
Attila DEBRECZENI e550b0a109 SAML debug attributes 2020-03-25 21:05:30 +00:00
Christian Burmeister 4b4509d839
Update gunicorn 19.7.1 -> 20.0.4 2020-03-13 00:30:26 +01:00
Christian Burmeister 058d475f05
Update requirements.txt 2020-03-13 00:28:32 +01:00
Christian Burmeister 795896d968
Update alpine 3.10 -> 3.11 2020-03-13 00:27:19 +01:00
Christian Burmeister 6381d87ec3
Update gunicorn 19.7.1 -> 20.0.4 2020-03-13 00:22:32 +01:00
Enrico Tröger bc2e7c9786 Remove $SCRIPT_ROOT from 'admin.create_template_from_zone' URL
As the URL here is contructed from Flask's "url_for" which already
takes the script root into account, we do not need to add it here
explicitly. This would result in a duplicate script root otherwise.
2020-03-09 17:08:27 +01:00
Scott Howard 417338d826 Remove blank comments from rrsets before sending for compatibility with some pdns backends 2020-03-07 02:07:41 -06:00
Khanh Ngo f2704649f1
Merge pull request #668 from pprietosanchez/fix_search_API_request
Correct request method
2020-03-06 14:08:39 +07:00
Khanh Ngo 026dcead7f
Merge pull request #671 from eht16/add_missing_format_arguments
Add missing format arguments for error message
2020-03-06 13:58:10 +07:00
Khanh Ngo 7024404f73
Merge pull request #674 from sshipway/patch-1
Corrections to Azure auth service definition after refactor
2020-03-06 13:57:09 +07:00
Khanh Ngo 65a59d2e59
Merge pull request #675 from eht16/fix_openid_update_token_typo
Fix token name in session for OIDC and GitHub authentication
2020-03-06 13:54:59 +07:00
Enrico Tröger b421b9b38a Fix token name in session for OIDC and GitHub authentication 2020-03-05 17:07:13 +01:00
Steve Shipway 7ac6529e0a
Corrections to Azure auth service definition after refactor 2020-03-04 17:34:01 +13:00
Enrico Tröger 2d55832729 Add missing format arguments for error message 2020-03-04 00:27:48 +01:00
pprietosanchez a2d98d38e2 Correct request method 2020-02-28 16:25:19 +01:00
Khanh Ngo 52eb62a911
Fix unit tests
The python float number formatting causes unit tests failed
2020-02-13 16:22:07 +07:00
Khanh Ngo d497fdee8e
Merge pull request #655 from eht16/issue428_sync_performance
Perform domain sync within a single transaction
2020-02-12 09:56:41 +07:00
Khanh Ngo 13aab7f6ee
Merge pull request #645 from eht16/outgoing_request_logging
Add log message after remote server request
2020-02-12 09:45:12 +07:00
Enrico Tröger 81bfde5a79 Add log message after remote server request
This might be handy to detect remote server problems or debugging
timeouts and the like.
2020-02-02 15:23:00 +01:00
Enrico Tröger eabbcedf08 Perform domain sync within a single transaction
This increases data inconsistency and also performance of the whole
update process: SqlAlchemy can use its own caching better with a single
transaction as it does not need to mark already fetched objects as dirty
and so re-fetch them again on later use. So after deleting a domain,
we can still use the previously fetched domain objects for the update
operations afterwards. Otherwise SqlAlchemy had to fetch each domain
object again with single SELECT statements which leads to bad
performance with many domains (>> 1000).

The introduced "do_commit" variable controls whether the database
changes are to be committed by the caller or in the called method
itself. So we can use a single transaction for the Domain.update()
method while still preserving the previous behavior for other callers.

Closes #428.
2020-02-02 15:15:20 +01:00
Khanh Ngo 598397bec1
Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2020-01-29 22:37:04 +07:00
Khanh Ngo e2207fd8d7
Merge pull request #644 from eht16/add_ssl_verify_setting
Add new setting to verify outgoing SSL connections
2020-01-29 22:36:47 +07:00
Khanh Ngo 4cfb6ef81f
Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2020-01-29 22:33:32 +07:00
Khanh Ngo 31d19b19ab Merge remote-tracking branch 'eht16/add_ssl_verify_setting' 2020-01-29 22:33:24 +07:00
Khanh Ngo faeb8d1f94
Merge pull request #652 from ngoduykhanh/fix_import
Fix logging in models
2020-01-29 22:31:56 +07:00
Khanh Ngo a069fdd8cb
Merge pull request #648 from eht16/adjustable_log_level
Introduce PDNS_ADMIN_LOG_LEVEL to change the log level
2020-01-29 22:28:03 +07:00
Khanh Ngo dbb181d575
Merge pull request #647 from eht16/fix_typos
Fiy typos in logging messages
2020-01-29 22:21:29 +07:00
Khanh Ngo 23c73f6c52
Fix logging in models 2020-01-29 22:18:15 +07:00
Khanh Ngo 2de8bf55f8 Merge branch 'dashboard_search_placeholder' 2020-01-29 22:00:17 +07:00
Khanh Ngo bb3829c8a1 Merge remote-tracking branch 'Monogramm/feat/groupofnames' 2020-01-29 20:42:07 +07:00
Kees Bos 4d391ccb34 Extend api with account and user management 2020-01-27 14:04:15 +00:00
Enrico Tröger 0f6b7bdcf6 Introduce PDNS_ADMIN_LOG_LEVEL to change the log level
By setting the environment variable PDNS_ADMIN_LOG_LEVEL to a Python
support log level, PowerDNS-Admin will use that log level.
2020-01-27 00:20:32 +01:00
Enrico Tröger ccaf74646d Fiy typos in logging messages 2020-01-27 00:05:28 +01:00
Enrico Tröger 68843d9664 Add new setting to verify outgoing SSL connections
The new setting 'verify_ssl_connections' tells the requests library to
verify secured outgoing HTTP connections.
Usually verifying is desired and helps to reveal configuration
problems. It also disables an ugly warning when HTTPS connections
are made without verification.
2020-01-25 19:44:11 +01:00
Enrico Tröger 737531d23f Add dashboard search placeholder to describe search patterns
Since ^ and $ for searching from start and to end is supported, let's
tell the user about it.
2020-01-25 19:26:13 +01:00
mathieu.brunot acef820c54
🐛 Fix logger for LDAP group filter
Signed-off-by: mathieu.brunot <mathieu.brunot@monogramm.io>
2020-01-08 23:40:14 +01:00
mathieu.brunot 0ea188f8d6
✏️ Fix copy/paste error in div id
Signed-off-by: mathieu.brunot <mathieu.brunot@monogramm.io>
2020-01-08 23:31:51 +01:00
mathieu.brunot 66ff3426e0
👌 Update LDAP selection flip
Signed-off-by: mathieu.brunot <mathieu.brunot@monogramm.io>
2020-01-08 23:23:40 +01:00
mathieu.brunot b4b5673cf1
Merge branch 'master' of github.com:johnwarburton/PowerDNS-Admin into feat/groupofnames
Signed-off-by: mathieu.brunot <mathieu.brunot@monogramm.io>
2020-01-08 23:19:51 +01:00
Khanh Ngo 1cd423041c Fix #632 2020-01-07 14:30:28 +07:00
Khanh Ngo b1ba339a9c Merge remote-tracking branch 'sshipway/azure' 2020-01-03 21:07:57 +07:00
Khanh Ngo e1c0b4a1b4
Fix missing import 2020-01-03 10:03:20 +07:00
Steve Shipway cff4d0af53 Fix some formatting issues 2020-01-03 15:57:07 +13:00
Steve Shipway 765eab999a Azure OAuth - add Group mappings to Roles 2020-01-03 15:36:38 +13:00
Khanh Ngo 7f8ae003fc
Fix #631 Ipv6 reverse dns with pretty_ipv6_ptr enabled 2020-01-03 09:32:04 +07:00
Khanh Ngo 3bf6e6e9f1
Merge pull request #622 from ngoduykhanh/email_verification
Add user email verification
2019-12-22 10:06:43 +07:00
Khanh Ngo 462190a913
Fix API key query 2019-12-22 10:06:02 +07:00
Khanh Ngo 9533d8a5aa
Adjustment in user profile email
- Don't allow to update to new email address that currently used by another user
- Reset the confirmed status if verify_user_email setting is enabled
2019-12-22 09:19:35 +07:00
Khanh Ngo d09a645300
Fix migration script and user model 2019-12-22 08:40:05 +07:00
Khanh Ngo 4bdd433079
Fix migration script and LGTM 2019-12-21 22:26:56 +07:00
Khanh Ngo 7739bf7cfc
Add user email verification 2019-12-21 21:43:03 +07:00
Khanh Ngo 49908b9039 Merge remote-tracking branch 'chrisss404/master' 2019-12-21 08:35:52 +07:00
Christian Hofer 451626f4af fixes #619, improve sed replace regex 2019-12-20 17:22:51 +01:00
Khanh Ngo 1ea460fc71 Merge remote-tracking branch 'Neven1986/saml_requested_attrs' 2019-12-20 21:08:06 +07:00
Khanh Ngo 8723b7d56b
Fix #529. Update domain serial columns type 2019-12-20 11:36:27 +07:00
Neven1986 3688cec91a Support for SAML metadata Requested Attributes
Enhancements:

- More robust check when creating self-signed certificates
- Added support for SAML Requested Attributes through "SAML_SP_REQUESTED_ATTRIBUTES" parameter
2019-12-20 03:24:26 +01:00
Khanh Ngo 5567886aa3
Fix #614. Can not enable dnssec 2019-12-19 21:50:15 +07:00
Khanh Ngo 93cd64ac75
Fix #613. Create domain from template with SOA record enabled 2019-12-19 21:43:53 +07:00
Khanh Ngo 59110432a0
Merge pull request #612 from Neven1986/saml_certificate_fix
SAML certificate fix and enhancement
2019-12-19 09:11:15 +07:00
Khanh Ngo 88991cdde9
Merge pull request #610 from ngoduykhanh/warn_session_timeout
Warn session timeout
2019-12-19 09:07:44 +07:00
Neven1986 567430790c SAML certificate fix and enhancement
Problems resolved:

- Method create_self_signed_cert() was invoked nowhere. This puts parameter "SAML_SIGN_REQUEST" description in configs/development.py as incorrect
- Method create_self_signed_cert() was returning error while trying to write out certificate and private key. File handler was opened for writing out TEXT instead of BINARY data

Enhancements:

- Two new parameters are introduced SAML_CERT_FILE and SAML_KEY_FILE. User can now explicitly define own certificate and key file anywhere on file-system.
- If parameters mentioned in previous bullet aren't explicitly defined, in PowerDNS-Admin root directory self-signed certificate will be created.
- Certificates will be used or generated in any case, because in saml.py there are explicit parameters defined which require certificate/key in order to work normally. If they aren't, exception will be thrown. Examples of parameters defined in saml.py requiring certificate: wantAssertionsEncrypted, signMetadata, wantAssertionsSigned.
2019-12-19 00:40:25 +01:00
Khanh Ngo 33c1f64272
Update mock test 2019-12-18 23:48:46 +07:00
Khanh Ngo 7205b4a01b
User session improvement
- Add session handler on other blueprint's before request
- Adjustment in using jTimeout to close warning popup on
other tabs when we extend the session
2019-12-18 15:25:20 +07:00
Khanh Ngo 9a1b92fbc2
Add session timeout warning 2019-12-17 23:46:34 +07:00
Khanh Ngo ea8531dd3c
Merge pull request #609 from ngoduykhanh/domain_sort_fixes
Fix dashboard domain sorting
2019-12-17 13:57:11 +07:00
Khanh Ngo 103bf176ed
Fix dashboard domain sorting 2019-12-17 13:34:52 +07:00
Khanh Ngo 4a3f121b02 Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2019-12-16 17:23:54 +07:00
Khanh Ngo 691d3045ad
Merge pull request #604 from ngoduykhanh/record_adjustment
Adjustment in domain's record applying
2019-12-16 17:23:16 +07:00
Khanh Ngo 3e961d7b21
LGTM fixes 2019-12-16 16:27:18 +07:00
Khanh Ngo f5b88ebc73
Merge branch 'master' into record_adjustment 2019-12-16 15:54:38 +07:00
Khanh Ngo 3196297f57
Fix domain template. Drop PDNS 3.x support 2019-12-16 14:23:17 +07:00
Khanh Ngo a891ed38e2
Add more log and history data 2019-12-16 11:01:16 +07:00
Khanh Ngo 6880657367 Merge remote-tracking branch 'chrisss404/master' 2019-12-16 08:47:51 +07:00
Christian Hofer 452fde17bd #606 update docker base image and employ multi-stage build in order to reduce image size 2019-12-15 16:33:09 +01:00
Khanh Ngo 4263bccbba
Format the history popup content 2019-12-15 21:42:08 +07:00
Khanh Ngo de581e9e1d
Yapf code formatting 2019-12-15 09:40:05 +07:00
Khanh Ngo 32236faae8 Merge remote-tracking branch 'Neven1986/saml_fixes' 2019-12-15 08:25:17 +07:00
Neven1986 37f24f9fde Correction of redirect URL after successful SAML login
From my perspective, if agreed, this change can be merged, because the basic SAM auth. functionality is now present
and was tested with "samlidp.io" iDP.

However, there are further improvements which I would like to integrate, but as a separate features in separate pull requests
2019-12-15 01:15:30 +01:00
Neven1986 894756ad96 Invocation of separate threa in get_idp_data() problem resolved 2019-12-14 21:45:51 +01:00
Neven1986 cd3535dcd2 - Variable references inside SAML class were fixed
- Function signatures inside SAML class were fixed
    - Redirect URL for /saml/login path was modified (saml_authorized -> index.saml_authorized)

    Current status is that SAML metadata can be generated under /saml/metadata and communication to SAML iDP is working

    Problems remaining:
        - SAML Response doesn't contain any attributes (There is no AttributeStatement on the Response). It can be that problem is on iDP side
        - Background thread in retrieve_idp_data() cannot be spawned, this part is currently commented out, old code needs to be revisited
2019-12-14 18:59:59 +01:00
Khanh Ngo 5ac58d5503
Re-add pretty_ipv6_ptr. Bug fixes 2019-12-14 23:13:55 +07:00
Khanh Ngo 95fe2a8a85
Update yarn lockfile 2019-12-14 20:47:34 +07:00
Khanh Ngo 14658d797e
Adjustment in domain's record applying 2019-12-14 14:47:21 +07:00
Khanh Ngo ad6b04bd78
LGTM fixes 2019-12-14 08:31:23 +07:00
Khanh Ngo c0594b2c0b
Fix SAML 2019-12-13 21:55:11 +07:00
Khanh Ngo d90a20f8da
Fix #602 domain table with long records 2019-12-13 21:22:58 +07:00
Khanh Ngo 65d4acc6c5 Merge remote-tracking branch 'paepke/history-rrsets' 2019-12-11 21:14:44 +07:00
Tobias Paepke 81869f4cd8 add history entries for rrsets delivered via zone api which is forwarded to pdns 2019-12-11 14:04:18 +01:00
Khanh Ngo 242e31a490
Merge pull request #599 from ngoduykhanh/global_search
Add PDNS global search feature
2019-12-11 16:56:06 +07:00
Khanh Ngo 1d973d1614
Allow update zone kind the domain setting page 2019-12-11 16:02:36 +07:00
Khanh Ngo 798b7abb5b
Show actual result on applyChanges(). Remove generated assets files 2019-12-11 13:45:27 +07:00
Khanh Ngo c62f9f6936
Formatting 2019-12-11 10:32:02 +07:00
Khanh Ngo 2cc73abbe5 Add PDNS global search feature
Remove POST method in global search route

Edit form action
2019-12-11 10:26:17 +07:00
Khanh Ngo a65b940103
Remove POST method in global search route 2019-12-11 10:02:04 +07:00
Khanh Ngo de69eaf825
Add PDNS global search feature 2019-12-11 09:55:18 +07:00
Khanh Ngo 67e6df6880
Bug fixes. Adjust logger 2019-12-10 17:08:43 +07:00
Khanh Ngo 9904505867
Bug fixes 2019-12-10 10:36:51 +07:00
Khanh Ngo 7e3a722215
Merge pull request #598 from ngoduykhanh/comment_feature
Add record comment
2019-12-09 22:42:01 +07:00
Khanh Ngo bca3c45e37
Add record comment 2019-12-09 17:50:48 +07:00
Khanh Ngo c1fae6f3dd
Update README and LGTM fixes 2019-12-08 18:23:36 +07:00
Khanh Ngo 354383f0ec
Fix missing import 2019-12-08 08:57:24 +07:00
Khanh Ngo 19401699ca
Fix #596 missing import 2019-12-07 20:46:14 +07:00
Khanh Ngo bbede0f7bd
Merge pull request #595 from ngoduykhanh/lgtm-fixes
LGTM fixes. Remove unused import and variables
2019-12-07 20:39:54 +07:00
Khanh Ngo 6af94df00a
LGTM fixes. Remove unused import and variables 2019-12-07 20:20:40 +07:00
Khanh Ngo 53a7545ccc
Upgrade mysqlclient package to work with MySQL 8.x #571 2019-12-07 18:10:06 +07:00
Khanh Ngo f8aaf88fc0 Fix #592 - Adjustment in Dockerfile to upgrade pip package 2019-12-07 14:45:41 +07:00
Khanh Ngo 80b6ca19ac Resolve the conflicts and add adjustment to #591 2019-12-06 14:27:35 +07:00
Khanh Ngo 0d2eeecce6
Merge pull request #586 from ngoduykhanh/code_refactor
Refactoring the code
2019-12-06 11:18:17 +07:00
Khanh Ngo 0234f21e1d Update update_zones.py bg script 2019-12-06 10:59:19 +07:00
Khanh Ngo 8de6df4d3b Fix the tests
Fix the tests

Fix the tests
2019-12-06 10:59:19 +07:00
Steve Shipway aef9842d30 Add micrsoft logo for social login 2019-12-05 14:26:01 +13:00
Steve Shipway 10ff312d95 Azure OAuth documentation and html templates 2019-12-05 13:52:30 +13:00
Steve Shipway 1662944867 Add Azure as an explicit OAuth provider 2019-12-05 13:21:50 +13:00
Khanh Ngo 840e2a4750 Update docker stuff and bug fixes 2019-12-04 11:50:46 +07:00
Khanh Ngo 8ea00b9484
Refactoring the code
- Use Flask blueprint
- Split model and views into smaller parts
- Bug fixes
- API adjustment
2019-12-02 10:32:03 +07:00
Khanh Ngo 0b2eb0fbf8 Merge branch 'split-up-update-method' 2019-09-25 11:47:45 +07:00
Khanh Ngo d31f95ca21 Merge remote-tracking branch 'in0th3p/split-up-update-method' 2019-09-25 11:47:34 +07:00
Khanh Ngo ad86f8ea2a
Remove blank lines and spaces 2019-09-25 11:45:03 +07:00
Khanh Ngo 89ac98acfb
Merge pull request #547 from Jamesits/master
fix #542
2019-09-16 11:49:04 +07:00
Khanh Ngo 7af76297de
Merge pull request #557 from tingvold/natural-sort
Add natural sort library
2019-09-16 11:47:34 +07:00
Joachim Tingvold 16669e6bbf Add natural sort library, so that natural sort works again. 2019-09-15 15:29:22 +02:00
James Swineson 0318000429 address https://github.com/ngoduykhanh/PowerDNS-Admin/issues/542 2019-07-24 15:40:11 +08:00
Benjamin Sturm a21d4d9cbd split up update function add/delete/update 2019-07-23 13:36:05 +02:00
Khanh Ngo dfce7eb537
Upgrade SQLAlchemy version to pass the security check. Set version for other libs in requirements.txt 2019-07-15 09:50:23 +07:00
Khanh Ngo 26db6b8c4b
Merge pull request #531 from pfak/master
Standard compliant HTTP Response codes
2019-07-15 09:49:00 +07:00
Peter Kieser 6e51ad866a Standard compliant HTTP Response codes 2019-07-14 19:11:00 -07:00
Khanh Ngo 798362c3c5 Merge remote-tracking branch 'Jamesits/master' 2019-07-15 09:07:51 +07:00
James Swineson 71a87dc38f pkg-config is not used 2019-07-13 09:01:41 +08:00
James Swineson 7878ecda2a allow setting gunicorn default args via environment variables 2019-07-12 22:20:59 +08:00
James Swineson d8bb62900d simply upgrade database 2019-07-12 21:03:19 +08:00
James Swineson 8c85119f5c fix dockerfile 2019-07-12 20:53:31 +08:00
James Swineson 8e11686b7d temporary create config.py to make flask work 2019-07-12 20:15:45 +08:00
James Swineson 5d23c71bcb add the true yarn 2019-07-12 19:45:56 +08:00
James Swineson ed77afcdd6 yarn now do not need --pure-lockfile 2019-07-12 19:20:37 +08:00
James Swineson b77be8d158 add setuptools 2019-07-12 18:48:31 +08:00
James Swineson e6e512efaf initial implementation of Dockerfile 2019-07-12 17:10:15 +08:00
John Warburton 9c11510914 Merge branch 'master' of https://github.com/johnwarburton/PowerDNS-Admin 2019-06-07 13:47:12 +10:00
John Warburton 67972123b6 Add LDAP_GROUP_SECURITY groupOfNames groups support 2019-06-07 13:46:54 +10:00
John Warburton 8f5a359101 Merge branch 'master' of https://github.com/ngoduykhanh/PowerDNS-Admin 2019-06-07 13:44:48 +10:00
Khanh Ngo 8309bb053e
Merge pull request #516 from ngoduykhanh/dependabot/npm_and_yarn/cached-path-relative-1.0.2
Bump cached-path-relative from 1.0.1 to 1.0.2
2019-06-07 09:19:41 +07:00
dependabot[bot] 477165c196
Bump cached-path-relative from 1.0.1 to 1.0.2
Bumps [cached-path-relative](https://github.com/ashaffer/cached-path-relative) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/ashaffer/cached-path-relative/releases)
- [Commits](https://github.com/ashaffer/cached-path-relative/commits)
2019-05-23 11:33:46 +00:00
John Warburton e5b324d74b Add LDAP_GROUP_SECURITY groupOfNames groups support 2019-05-17 09:38:08 +10:00
Khanh Ngo 53147188ef
Merge pull request #502 from in0th3p/extendedLoggingUpdateFunction
add a more comprehensive logging for domain_update
2019-05-01 09:19:33 +07:00
Khanh Ngo f6317eee33
Merge pull request #501 from in0th3p/comprehensiveLogging
added TimeStamp to logging
2019-05-01 09:18:39 +07:00
Khanh Ngo 5d8cc844aa
Merge pull request #500 from in0th3p/added_endpoint_update_domains
Added endpoint synchronize domains
2019-05-01 09:17:59 +07:00
Khanh Ngo 4d1953bf07
Merge pull request #499 from in0th3p/passwordRemoval
Removed API-Key and PlanePassword logging
2019-05-01 09:17:07 +07:00
Khanh Ngo 0522c20ce1
Merge pull request #497 from scop/spelling
Spelling fixes
2019-05-01 09:16:18 +07:00
Benjamin Wind 1acd246110 Add API-Endpoint and definition for update_domain
Enables updating/synchronizing domains in background
to avoid front end timing out when updating too many domains
2019-04-29 16:59:26 +02:00
Benjamin Wind b715e7738e add a more comprehensive logging for domain_update 2019-04-29 16:41:37 +02:00
Benjamin Wind 6a498382eb added TimeStamp to logging 2019-04-29 15:48:04 +02:00
Benjamin Wind 0db3c625e8 Removed API-Key and PlanePassword logging 2019-04-29 14:00:00 +02:00
Ville Skyttä ee73bb181f Spelling fixes 2019-04-25 16:02:49 +03:00
Khanh Ngo f95adf3ea3 Merge remote-tracking branch 'synthesio/improve-record-list-perf' 2019-04-01 22:12:53 +07:00
Khanh Ngo 00d92839fb Merge remote-tracking branch 'olemathias/master' 2019-04-01 21:46:45 +07:00
Khanh Ngo ae7b41ece2
Merge pull request #484 from genericpenguin/genericpenguin-escape-chars-groupsearchfilter
escape special chars when creating group filter.
2019-04-01 21:41:56 +07:00
Dejan Filipovic 8698009c30 Records entries should not required database requests for domain listing page
Create a new class just for storing records entries, without the Setting lookup
otherwise for each record it will make 4 SQL queries to retrieve something that
is not used.
2019-03-28 08:25:07 +01:00
Ole Mathias Aa. Heggem 15aeaef2ea Basic LUA record support + formatting cleanup of js in domains.html 2019-03-23 03:21:39 +01:00
genericpenguin 0ad5d46a4c
escape special chars when creating group filter.
The LDAP search filter used for group queries needs to be escaped so that group names with special characters will not break the search filter in queries.
2019-03-18 11:54:31 +11:00
Khanh Ngo 9f29a8e154
Merge pull request #477 from p53/patch-1
Improve README.md
2019-03-09 09:24:41 +07:00
p53 6a606dbe2c
Improve README.md
- add API to features
- make Entity Relationship generation command more generic
2019-03-04 12:27:25 +01:00
Khanh Ngo ee2ed65ff9 Merge branch 'improve-saml-support' 2019-03-04 09:19:20 +07:00
Tim Jacomb 334878d75d
Use display name of role 2019-03-03 15:08:01 +00:00
Tim Jacomb 08de197cff
Sync config templates 2019-03-03 15:07:51 +00:00
Tim Jacomb 292aaddaee
Improve SAML support
- Make SAML_WANT_MESSAGE_SIGNED configurable, AzureAD signs the assertion but wouldn't sign the message
- Add support for a name attribute, i.e. 'Tim Jacomb' using `SAML_ATTRIBUTE_NAME`, which will be mapped into the given and surname fields, AzureAD only has displayname
- Add support for group based admin `SAML_ATTRIBUTE_GROUP` and `SAML_GROUP_ADMIN_NAME`
- Add support for group based accounts `SAML_GROUP_TO_ACCOUNT_MAPPING`
- Don't fail if cert and key aren't present
2019-03-03 07:58:01 +00:00
Khanh Ngo 697aba0990
Merge pull request #474 from ngoduykhanh/update_themes
Update adminlte themes version. Fix #473
2019-03-02 11:21:52 +07:00
Khanh Ngo 0333f450f1
Update adminlte themes version 2019-03-02 10:17:11 +07:00
Khanh Ngo ff3b484d84
Merge pull request #440 from p53/create-api
Create api - resolves #416
2019-03-02 09:44:06 +07:00
Pavol Ipoth c4ab5893b4 Add README how to generate ER diagram 2019-03-02 00:51:41 +01:00
Pavol Ipoth 1feb77e2f3 Add Api to PowerDNS-Admin 2019-03-01 23:50:04 +01:00
Khanh Ngo 343190b684
Merge pull request #472 from ngoduykhanh/fix_domain_datatable
Fix domain datatable. #468
2019-03-01 17:15:46 +07:00
Khanh Ngo 3b168047c4
Fix domain datatable. #468 2019-03-01 17:06:32 +07:00
Khanh Ngo 122b9e4dfe
Merge pull request #471 from vipera/typo-qr-code
Fix typo in user profile template
2019-03-01 09:50:39 +07:00
Marin Rukavina 1f1f928a58 Fix typo in user profile template 2019-02-28 20:38:29 +01:00
Khanh Ngo 9bd5225774 Merge remote-tracking branch 'softcat/tlsa' 2019-02-16 16:45:28 +07:00
Nils Sandmann b48809dbdb
Added TLSA Popup 2019-02-14 20:59:42 +01:00
Khanh Ngo 7ba9ad68f8
Fix domain/record table size #463 2019-02-14 14:09:56 +07:00
Khanh Ngo 63b872f627 Merge remote-tracking branch 'mtdcr/master' 2019-02-13 09:50:08 +07:00
Andreas Oberritter 98f1e96d1a dyndns: accept and validate both A and AAAA records; default to client address 2019-02-12 16:27:08 +01:00
Khanh Ngo 5b88ec58ec
Merge pull request #458 from joniw/fix-equality-domain-setting
fix equality check for DomainSetting when "other" is None
2019-02-12 09:05:06 +07:00
Jonathan Westerholt 0c290073ae fix equality check for DomainSetting when "other" is None 2019-02-11 22:00:17 +01:00
Khanh Ngo bc2b57e475 Fix admin_edituser.html template 2019-01-31 09:04:06 +07:00
Khanh Ngo 5ea38a9b6d Merge remote-tracking branch 'Ramalama2/master' 2019-01-24 22:30:23 +07:00
Rama 287d171d83 Separate Tabs for Reverse Zones (IP4 & IP6)
Separate Tabs for Reverse Zones (IP4 & IP6)
2019-01-23 12:00:26 +01:00
Khanh Ngo 829d556462
Merge pull request #446 from ngoduykhanh/fix_account
Fix account deletion. Add more info in Account table #433
2019-01-23 09:23:07 +07:00
Khanh Ngo 2b17469324
Merge pull request #429 from p53/fix-not-working-logging
Fix logging path, needs absolute path
2019-01-19 14:44:57 +07:00
Khanh Ngo cdb35d27f9
Merge pull request #442 from yasuoza/node-10
Install node v10.x for docker image
2019-01-19 14:43:34 +07:00
Yasuharu Ozaki ce1d511fb2 Install node v10.x 2019-01-16 16:21:11 +09:00
Khanh Ngo 458826bc77
Fix account deletion. Add more info in Account table 2019-01-11 09:00:51 +07:00
Khanh Ngo 7da6bd5f99
Prevent non-administrator user from editing admin users 2019-01-09 13:03:27 +07:00
Pavol Ipoth 1ca311e120 Fix logging path, needs absolute path 2018-12-22 13:13:15 +01:00
Khanh Ngo 082969de72
Fix #427. JSON parse error on history detail 2018-12-20 15:26:14 +07:00
Khanh Ngo c1f5d76c6c
Merge pull request #423 from ngoduykhanh/authentication_fix
Fix disabling the user registration form
2018-12-11 15:11:01 +07:00
Khanh Ngo b56d1154cc
Fix disabling the user registration form 2018-12-11 10:56:37 +07:00
Khanh Ngo 1e6b668189
Fix changing user role js event 2018-12-03 09:39:02 +07:00
Khanh Ngo 9e14c3eb39
Merge pull request #409 from rkerr/ttl_config
Add ttl_options setting
2018-11-29 09:54:05 +07:00
Robert Kerr 017e0c7344 Fix case where TTL not in ttl_options
If PowerDNS-Admin is working on a database created by another tool
or ttl_options is changed the current TTL might be missing from
the valid list, fix this
2018-11-28 21:19:38 +00:00
Robert Kerr 976742c54e Add ttl_options to templates 2018-11-28 20:09:33 +00:00
Khanh Ngo 29b110dd15 Merge branch 'fix-411' 2018-11-28 14:40:25 +07:00
Ymage d16abb9f81 Fix login redirection when submounted
Fix #411 to use with APPLICATION_ROOT Flask config variable
2018-11-26 14:54:20 +01:00
Khanh Ngo 004b211fdc
Merge pull request #405 from ymage/fix-400
Change SOA expiry placeholder value
2018-11-26 10:43:22 +07:00
Khanh Ngo 32c442e083
Merge pull request #406 from ymage/fix-403
Make a redirection to previous page after logged in
2018-11-26 10:35:43 +07:00
Khanh Ngo 6837b1cead
Merge pull request #407 from ymage/fix-sqlite-alter
Support SQLite ALTER with batch feature during alembic migrate
2018-11-26 10:28:53 +07:00
Robert Kerr c456aa2e7a Add ttl_options setting
Adds a new setting to define the options in the TTL dropdown when
editing a record. The setting is a comma separated string with the
valid options.
2018-11-24 12:45:14 +00:00
Ymage 0b9c58971c Remove forgotten decorator 2018-11-24 12:15:49 +01:00
Ymage 94becb35c6 Support SQLite ALTER with batch feature during alembic migrate
Fix #404
2018-11-24 12:04:23 +01:00
Ymage 9e6822bcdb Make a redirection to previous page after logged in
Fix #403
2018-11-24 11:53:27 +01:00
Ymage 2a0be5b90d Change SOA expiry placeholder value
Fix #400
2018-11-21 09:17:10 +01:00
Khanh Ngo 2f39512b65 Resolve the conflicts for #398 2018-11-21 10:29:33 +07:00
Khanh Ngo 6151225b07
Merge pull request #398 from ymage/master
Add missing $SCRIPT_ROOT in template_edit.html
2018-11-21 10:27:28 +07:00
Khanh Ngo 10d915ef92 Merge branch 'master' of github.com:ngoduykhanh/PowerDNS-Admin 2018-11-21 10:24:48 +07:00
Khanh Ngo 5f049debe5 Adding Flask-SeaSurf module for CSRF protection. 2018-11-21 10:24:33 +07:00
Ymage cb726ae4f9 Add missing $SCRIPT_ROOT
Fix #317
2018-11-19 10:35:23 +01:00
Khanh Ngo a1245dcded
Merge pull request #397 from rkerr/master
Fix for when TOTP starts with 0
2018-11-19 09:23:45 +07:00
Robert Kerr c7d43ce5cc Fix for when TOTP starts with 0
PyOTP wants the token as a string, by passing it as an int leading
0s get stripped and verification fails
2018-11-18 10:23:47 +00:00
Khanh Ngo e1e4771ddc
Merge pull request #393 from rkerr/master
Fix typo Use -> User
2018-11-13 08:03:40 +06:30
Khanh Ngo 59bdc62512
Merge pull request #391 from deathowl/master
support arbitrary db port
2018-11-13 08:02:55 +06:30
Robert Kerr 6395d81916 Fix typo Use -> User 2018-11-12 20:25:56 +00:00
Balint Csergo be96921864 support arbitrary db port, pdns_proto and pdns_port 2018-11-12 13:21:04 +01:00
Khanh Ngo 679ada7a89
Merge pull request #390 from ymage/master
Add missing Remember Me input name
2018-11-12 16:05:41 +06:30
Khanh Ngo ea53ae340f Fix LDAP password checking issue due to indention level 2018-11-12 16:30:18 +07:00
Khanh Ngo e4c8f5e100 Use SvgPathImage in QR Code generation to remove white lines. #388 2018-11-12 16:00:38 +07:00
ymage f28b501c7c Add missing Remember Me input name 2018-11-05 01:19:51 +01:00
Khanh Ngo add5fd3e52 Upgrade requests module to 2.20.0 as CVE-2018-18074 2018-10-31 16:53:27 +07:00
Khanh Ngo d3972b659a Add record helper for TXT type. #380 2018-10-30 09:55:31 +07:00
Khanh Ngo 3fea57fca1 Update README. Fix warning from PR #387 2018-10-26 08:43:25 +07:00
Khanh Ngo b32a77fce1 Merge remote-tracking branch 'chriscpritchard/master' 2018-10-26 08:34:54 +07:00
Chris Pritchard 1ffaf904b2
Update run.py
Remove use_reloader
2018-10-25 11:25:41 +01:00
Khanh Ngo e273921195 Merge remote-tracking branch 'chriscpritchard/master' 2018-10-25 15:26:42 +07:00
Khanh Ngo a39f5c622c
Avoid searching for AD user's memberOf while it is missing and LDAP_GROUP_SECURITY_ENABLED is ON 2018-10-24 13:30:19 +07:00
Khanh Ngo 4a0d580e32 Merge branch 'recursive_activedirectory' 2018-10-24 11:28:43 +07:00
Khanh Ngo 95b4de65dd
Add validation for Ldap operation group input 2018-10-24 11:23:19 +07:00
Chris Pritchard 8a20d3f2d8
migrated to authlib 2018-10-22 02:33:46 +01:00
Chris Pritchard 396ce14b9f
OIDC (#1)
Implemented OIDC using authlib
2018-10-21 23:38:12 +01:00
odumasFR de3d1b3665
Merge branch 'master' into activedirectory_authent 2018-10-09 08:31:18 +02:00
odumasFR 2e5013ae73
Resolve conflict for merge
Typo correction
2018-10-05 08:46:14 +02:00
Khanh Ngo 4540d9a293
Merge pull request #379 from jsoref/spelling
Spelling
2018-10-02 14:56:25 +07:00
Khanh Ngo 4bcc0c3ac4
Merge pull request #377 from odumasFR/session_timeout
Setting a session lifetime
2018-10-02 14:49:05 +07:00
Josh Soref 5158cf93db spelling: using 2018-10-02 07:29:32 +00:00
Josh Soref 3fec02b335 spelling: useful 2018-10-02 07:29:24 +00:00
Josh Soref e5c434ac33 spelling: touches 2018-10-02 07:28:52 +00:00
Josh Soref 50e219039b spelling: retrieve 2018-10-02 07:25:36 +00:00
Josh Soref e81a44fbc1 spelling: requirement 2018-10-02 07:25:26 +00:00
Josh Soref 3c7da371d2 spelling: privilege 2018-10-02 07:23:41 +00:00
Josh Soref aced23db97 spelling: initialize 2018-10-02 07:22:15 +00:00
Josh Soref c646185e1f spelling: dropdown 2018-10-02 07:13:08 +00:00
Josh Soref 83923ebf98 spelling: database 2018-10-02 07:12:41 +00:00
Josh Soref 573291b4f5 spelling: configures 2018-10-02 07:05:42 +00:00
Josh Soref 2efb674230 spelling: comparison 2018-10-02 07:06:06 +00:00
Josh Soref d9d3add093 spelling: cannot 2018-10-02 07:04:07 +00:00
Josh Soref 74d49add6d spelling: building 2018-10-02 07:03:55 +00:00
Josh Soref 7875be3b78 spelling: authentication 2018-10-02 07:03:41 +00:00
Josh Soref 5909de99e9 spelling: admin 2018-10-02 06:56:50 +00:00
odumasFR dabc324c52
Adjust basic settings list
Added the new 'session_timeout' setting to the list of rendered basic settings
2018-10-02 08:01:04 +02:00
odumasFR 3f2d14327c
better exception handling for ldap errors 2018-10-01 23:08:45 +02:00
Olivier DUMAS 51043837f0 Recursively find ActiveDirectory groups to check whether user is in LDAP_ADMIN_GROUP or LDAP_OPERATOR_GROUP 2018-10-01 19:27:52 +02:00
Olivier DUMAS 4cd422ee54 Added a session_timeout setting to manage session lifetime 2018-10-01 19:15:09 +02:00
Olivier DUMAS bee6d1560f Do not use service login/password for AD authentication 2018-09-12 17:28:05 +02:00
Khanh Ngo 9a4eebfd42
Fix sidebar toggling 2018-09-10 14:56:05 +07:00
Khanh Ngo 65b0c6e9b9 Remove Flask-WTF from requirements.txt 2018-09-10 10:15:38 +07:00
229 changed files with 25176 additions and 7500 deletions

112
.dockerignore Normal file
View 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
View file

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

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

View file

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

@ -0,0 +1,12 @@
{
"scanSettings": {
"baseBranches": []
},
"checkRunSettings": {
"vulnerableCheckRunConclusionLevel": "failure",
"displayMode": "diff"
},
"issueSettings": {
"minSeverityLevel": "LOW"
}
}

View file

@ -1 +1 @@
--*.modules-folder "./app/static/node_modules"
--*.modules-folder "./powerdnsadmin/static/node_modules"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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">&times;</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 %}

View file

@ -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">&times;</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">&times;</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 %}

View file

@ -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&nbsp;<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&nbsp;<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">&times;</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">&times;</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 %}

View file

@ -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">&times;</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>
&nbsp;&nbsp;&nbsp;
<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>
&nbsp;&nbsp;&nbsp;
<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 %}

View file

@ -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">&times;</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 %}

View file

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

View file

@ -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">&times;</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">&times;</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>

View file

@ -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>
&nbsp;&nbsp;&nbsp;
<label>
<input type="radio" name="radio_type" id="radio_type_master" value="master"> Master
</label>
&nbsp;&nbsp;&nbsp;
<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 %}

View file

@ -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>&nbsp;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 %}>
&nbsp;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 %}>
&nbsp;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>&nbsp;Change SOA-EDIT-API setting for {{ domain.name }}
</button>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="box">
<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>&nbsp;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">&times;</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 %}

View file

@ -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">&times;</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>

View file

@ -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&rsquo;ll be back soon!</h1>
<div>
<p>Sorry for the inconvenience but we&rsquo;re performing some maintenance at the moment. Please contact the System Administrator if you need more information</a>, otherwise we&rsquo;ll be back online shortly!</p>
<p>&mdash; Team</p>
</div>
</article>

View file

@ -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">&times;</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>

View file

@ -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">&times;</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&nbsp;<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&nbsp;<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&nbsp;<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 %}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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
View 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"]

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

View 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
View 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}

View 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
View 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()"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 ###

View file

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

View file

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

View file

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

View file

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

View 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 ###

View file

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

View file

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

View file

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

View 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

View file

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

View 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

View 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
View 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")

View 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']

View 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]

View 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'}

View 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)

View 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

View 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)

View 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')))

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

View 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

View 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'}

View 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'
}

View 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)

View 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

View 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)
}

View 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

View 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)

View 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 []

View 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(',')]

View 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

View 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)

File diff suppressed because it is too large Load diff

1184
powerdnsadmin/routes/api.py Normal file

File diff suppressed because it is too large Load diff

View 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

View 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)

View 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)

File diff suppressed because it is too large Load diff

View 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'
}

View 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