# hepdns - PowerDNS API proxy for Hurricane Electric DNS # Copyright (C) 2021 Bob Carroll # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import os import json from flask import Flask, request, abort from flask.json import jsonify import requests import yaml app = Flask(__name__) app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True api_key = None zones = {} @app.errorhandler(400) def bad_request(error): return jsonify({'error': 'Bad Request'}), '400 Bad Request' @app.errorhandler(404) def not_found(error): return jsonify({'error': 'Not Found'}), '404 Not Found' @app.before_first_request def load_config(): """ Loads zone data from a yaml file. """ zone_file = os.environ.get('HEPDNS_ZONE_FILE') if not zone_file: raise Exception('Set HEPDNS_ZONE_FILE environment variable to zone file path') global api_key api_key = os.environ.get('HEPDNS_API_KEY') if not api_key: raise Exception('Set HEPDNS_API_KEY environment variable to an API key') def ensure_dot(x): return x if x.endswith('.') else f'{x}.' def denormalize(zone, rrset): zone = ensure_dot(zone) return {f'_acme-challenge.{k}.{zone}': v for k, v in rrset.items()} with open(zone_file, 'r') as f: z = yaml.safe_load(f) zones.update({ensure_dot(k): denormalize(k, v) for k, v in z.items()}) @app.route('/api/v1/servers/localhost/zones') def get_all_zones(): """ Returns information about all zones. """ x_api_key = request.headers.get('X-API-Key') if not x_api_key or x_api_key != api_key: abort(401) result = [{ 'id': zone, 'name': zone, 'type': 'Zone', 'url': request.path, 'kind': 'Native', 'serial': 1, 'notified_serial': 1, 'edited_serial': 1, 'masters': [], 'dnssec': False, 'nsec3param': '', 'nsec3narrow': False, 'presigned': False, 'soa_edit': '', 'soa_edit_api': '', 'api_rectify': False, 'account': '', 'master_tsig_key_ids': [], 'slave_tsig_key_ids': []} for zone in zones] return jsonify(result) @app.route('/api/v1/servers/localhost/zones/') def get_zone(zone): """ Returns information about the requested zone. """ x_api_key = request.headers.get('X-API-Key') if not x_api_key or x_api_key != api_key: abort(401) elif zone not in zones: abort(404) result = { 'id': zone, 'name': zone, 'type': 'Zone', 'url': request.path, 'kind': 'Native', 'rrsets': [{'name': x, 'type': 'TXT', 'records': [], 'ttl': 60} for x in zones[zone]], 'serial': 1, 'notified_serial': 1, 'edited_serial': 1, 'masters': [], 'dnssec': False, 'nsec3param': '', 'nsec3narrow': False, 'presigned': False, 'soa_edit': '', 'soa_edit_api': '', 'api_rectify': False, 'account': '', 'master_tsig_key_ids': [], 'slave_tsig_key_ids': []} return jsonify(result) @app.route('/api/v1/servers/localhost/zones/', methods=['PATCH']) def patch_zone(zone): """ Updates individual resource records in a zone. """ x_api_key = request.headers.get('X-API-Key') content_type = request.headers.get('Content-Type') if not x_api_key or x_api_key != api_key: abort(401) elif zone not in zones: abort(404) if content_type == 'application/json': data = request.json elif content_type == 'application/x-www-form-urlencoded': data = json.loads(request.get_data()) else: abort(400) if not isinstance(data, dict): abort(400) elif 'rrsets' not in data or len(data['rrsets']) != 1: abort(400) rr = data['rrsets'][0] if 'name' not in rr: abort(400) elif 'records' not in rr or len(rr['records']) > 1: abort(400) elif 'type' not in rr or rr['type'] != 'TXT': abort(400) elif 'changetype' not in rr or rr['changetype'] != 'REPLACE': abort(400) elif not len(rr['records']): return '', 204 rname = rr['name'] rdata = rr['records'][0].get('content') zdata = zones[zone] if rname not in zdata or not rdata: abort(400) passwd = zdata[rname] rname = rname.strip('.') resp = requests.get( f'https://dyn.dns.he.net/nic/update?hostname={rname}&password={passwd}&txt={rdata}') if resp.status_code != 200: raise Exception(resp.text) return '', 204 @app.route('/api/v1/servers/localhost/zones//notify') def notify_zone_change(zone): """ Notifies slave servers of zone changes. """ x_api_key = request.headers.get('X-API-Key') if not x_api_key or x_api_key != api_key: abort(401) elif zone not in zones: abort(404) return '', 200