#!/usr/bin/env python3
#
# Copyright (C) 2019-2023 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# 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, see <http://www.gnu.org/licenses/>.
#
#########
# USAGE #
#########
# This daemon listens on its socket for JSON messages.
# The received message format is:
#
# { 'type': '<message type>',
#   'op': '<message operation>',
#   'data': <data list or dict>
# }
#
# For supported message types, see below.
# 'op' can be 'add', delete', 'get', 'set' or 'apply'.
# Different message types support different sets of operations and different
# data formats.
#
# Changes to configuration made via add or delete don't take effect immediately,
# they are remembered in a state variable and saved to disk to a state file.
# State is remembered across daemon restarts but not across system reboots
# as it's saved in a temporary filesystem (/run).
#
# 'apply' is a special operation that applies the configuration from the cached
# state, rendering all config files and reloading relevant daemons (currently
# just pdns-recursor via rec-control).
#
# note: 'add' operation also acts as 'update' as it uses dict.update, if the
# 'data' dict item value is a dict. If it is a list, it uses list.append.
#
### tags
# Tags can be arbitrary, but they are generally in this format:
# 'static', 'system', 'dhcp(v6)-<intf>' or 'dhcp-server-<client ip>'
# They are used to distinguish entries created by different scripts so they can
# be removed and recreated without having to track what needs to be changed.
# They are also used as a way to control which tags settings (e.g. nameservers)
# get added to various config files via name_server_tags_(recursor|system)
#
### name_server_tags_(recursor|system)
# A list of tags whose nameservers and search domains is used to generate
# /etc/resolv.conf and pdns-recursor config.
# system list is used to generate resolv.conf.
# recursor list is used to generate pdns-rec forward-zones.
# When generating each file, the order of nameservers is as per the order of
# name_server_tags (the order in which tags were added), then the order in
# which the name servers for each tag were added.
#
#### Message types
#
### name_servers
#
# { 'type': 'name_servers',
#   'op': 'add',
#   'data': {
#       '<str tag>': ['<str nameserver>', ...],
#       ...
#     }
# }
#
# { 'type': 'name_servers',
#   'op': 'delete',
#   'data': ['<str tag>', ...]
# }
#
# { 'type': 'name_servers',
#   'op': 'get',
#   'tag_regex': '<str regex>'
# }
# response:
# { 'data': {
#       '<str tag>': ['<str nameserver>', ...],
#       ...
#     }
# }
#
### name_server_tags
#
# { 'type': 'name_server_tags',
#   'op': 'add',
#   'data':  ['<str tag>', ...]
# }
#
# { 'type': 'name_server_tags',
#   'op': 'delete',
#   'data': ['<str tag>', ...]
# }
#
# { 'type': 'name_server_tags',
#   'op': 'get',
# }
# response:
# { 'data': ['<str tag>', ...] }
#
### forward_zones
## Additional zones added to pdns-recursor forward-zones-file.
## If recursion_desired is true, '+' will be prepended to the zone line.
## If addnta is true, a NTA (Negative Trust Anchor) will be added via
## lua-config-file.
#
# { 'type': 'forward_zones',
#   'op': 'add',
#   'data': {
#       '<str zone>': {
#           'server': ['<str nameserver>', ...],
#           'addnta': <bool>,
#           'recursion_desired': <bool>
#         }
#       ...
#     }
# }
#
# { 'type': 'forward_zones',
#   'op': 'delete',
#   'data': ['<str zone>', ...]
# }
#
# { 'type': 'forward_zones',
#   'op': 'get',
# }
# response:
# { 'data': {
#       '<str zone>': { ... },
#       ...
#     }
# }
#
#
### authoritative_zones
## Additional zones hosted authoritatively by pdns-recursor.
## We add NTAs for these zones but do not do much else here.
#
# { 'type': 'authoritative_zones',
#   'op': 'add',
#   'data': ['<str zone>', ...]
# }
#
# { 'type': 'authoritative_zones',
#   'op': 'delete',
#   'data': ['<str zone>', ...]
# }
#
# { 'type': 'authoritative_zones',
#   'op': 'get',
# }
# response:
# { 'data': ['<str zone>', ...] }
#
#
### search_domains
#
# { 'type': 'search_domains',
#   'op': 'add',
#   'data': {
#       '<str tag>': ['<str domain>', ...],
#       ...
#     }
# }
#
# { 'type': 'search_domains',
#   'op': 'delete',
#   'data': ['<str tag>', ...]
# }
#
# { 'type': 'search_domains',
#   'op': 'get',
# }
# response:
# { 'data': {
#       '<str tag>': ['<str domain>', ...],
#       ...
#     }
# }
#
### hosts
#
# { 'type': 'hosts',
#   'op': 'add',
#   'data': {
#       '<str tag>': {
#           '<str host>': {
#               'address': '<str address>',
#               'aliases': ['<str alias>, ...]
#             },
#           ...
#         },
#       ...
#     }
# }
#
# { 'type': 'hosts',
#   'op': 'delete',
#   'data': ['<str tag>', ...]
# }
#
# { 'type': 'hosts',
#   'op': 'get'
#   'tag_regex': '<str regex>'
# }
# response:
# { 'data': {
#       '<str tag>': {
#           '<str host>': {
#               'address': '<str address>',
#               'aliases': ['<str alias>, ...]
#             },
#           ...
#         },
#       ...
#     }
# }
### host_name
#
# { 'type': 'host_name',
#   'op': 'set',
#   'data': {
#       'host_name': '<str hostname>'
#       'domain_name': '<str domainname>'
#     }
# }

import os
import sys
import time
import json
import signal
import traceback
import re
import logging
import zmq

from voluptuous import Schema, MultipleInvalid, Required, Any
from collections import OrderedDict
from vyos.utils.file import makedir
from vyos.utils.permission import chown
from vyos.utils.permission import chmod_755
from vyos.utils.process import popen
from vyos.utils.process import process_named_running
from vyos.template import render

debug = True

# Configure logging
logger = logging.getLogger(__name__)
# set stream as output
logs_handler = logging.StreamHandler()
logger.addHandler(logs_handler)

if debug:
    logger.setLevel(logging.DEBUG)
else:
    logger.setLevel(logging.INFO)

RUN_DIR = "/run/vyos-hostsd"
STATE_FILE = os.path.join(RUN_DIR, "vyos-hostsd.state")
SOCKET_PATH = "ipc://" + os.path.join(RUN_DIR, 'vyos-hostsd.sock')

RESOLV_CONF_FILE = '/etc/resolv.conf'
HOSTS_FILE = '/etc/hosts'

PDNS_REC_USER = PDNS_REC_GROUP = 'pdns'
PDNS_REC_RUN_DIR = '/run/powerdns'
PDNS_REC_LUA_CONF_FILE = f'{PDNS_REC_RUN_DIR}/recursor.vyos-hostsd.conf.lua'
PDNS_REC_ZONES_FILE = f'{PDNS_REC_RUN_DIR}/recursor.forward-zones.conf'

STATE = {
    "name_servers": {},
    "name_server_tags_recursor": [],
    "name_server_tags_system": [],
    "forward_zones": {},
    "authoritative_zones": [],
    "hosts": {},
    "host_name": "vyos",
    "domain_name": "",
    "search_domains": {},
    "changes": 0
    }

# the base schema that every received message must be in
base_schema = Schema({
    Required('op'): Any('add', 'delete', 'set', 'get', 'apply'),
    'type': Any('name_servers',
        'name_server_tags_recursor', 'name_server_tags_system',
        'forward_zones', 'authoritative_zones', 'search_domains',
        'hosts', 'host_name'),
    'data': Any(list, dict),
    'tag': str,
    'tag_regex': str
    })

# more specific schemas
op_schema = Schema({
    'op': str,
    }, required=True)

op_type_schema = op_schema.extend({
    'type': str,
    }, required=True)

host_name_add_schema = op_type_schema.extend({
    'data': {
        'host_name': str,
        'domain_name': Any(str, None)
        }
    }, required=True)

data_dict_list_schema = op_type_schema.extend({
    'data': {
        str: [str]
        }
    }, required=True)

data_list_schema = op_type_schema.extend({
    'data': [str]
    }, required=True)

tag_regex_schema = op_type_schema.extend({
    'tag_regex': str
    }, required=True)

forward_zone_add_schema = op_type_schema.extend({
    'data': {
        str: {
            'name_server': [str],
            'addnta': Any({}, None),
            'recursion_desired': Any({}, None),
            }
        }
    }, required=False)

hosts_add_schema = op_type_schema.extend({
    'data': {
        str: {
            str: {
            'address': [str],
            'aliases': [str]
                }
            }
        }
    }, required=True)


# op and type to schema mapping
msg_schema_map = {
    'name_servers': {
        'add': data_dict_list_schema,
        'delete': data_list_schema,
        'get': tag_regex_schema
        },
    'name_server_tags_recursor': {
        'add': data_list_schema,
        'delete': data_list_schema,
        'get': op_type_schema
        },
    'name_server_tags_system': {
        'add': data_list_schema,
        'delete': data_list_schema,
        'get': op_type_schema
        },
    'forward_zones': {
        'add': forward_zone_add_schema,
        'delete': data_list_schema,
        'get': op_type_schema
        },
    'authoritative_zones': {
        'add': data_list_schema,
        'delete': data_list_schema,
        'get': op_type_schema
        },
    'search_domains': {
        'add': data_dict_list_schema,
        'delete': data_list_schema,
        'get': tag_regex_schema
        },
    'hosts': {
        'add': hosts_add_schema,
        'delete': data_list_schema,
        'get': tag_regex_schema
        },
    'host_name': {
        'set': host_name_add_schema
        },
    None: {
        'apply': op_schema
        }
    }

def validate_schema(data):
    base_schema(data)

    try:
        schema = msg_schema_map[data['type'] if 'type' in data else None][data['op']]
        schema(data)
    except KeyError:
        raise ValueError((
            'Invalid or unknown combination: '
            f'op: "{data["op"]}", type: "{data["type"]}"'))


def pdns_rec_control(command):
    if not process_named_running('pdns_recursor'):
        logger.info(f'pdns_recursor not running, not sending "{command}"')
        return

    logger.info(f'Running "rec_control {command}"')
    (ret,ret_code) = popen((
            f"rec_control --socket-dir={PDNS_REC_RUN_DIR} {command}"))
    if ret_code > 0:
        logger.exception((
            f'"rec_control {command}" failed with exit status {ret_code}, '
            f'output: "{ret}"'))

def make_resolv_conf(state):
    logger.info(f"Writing {RESOLV_CONF_FILE}")
    render(RESOLV_CONF_FILE, 'vyos-hostsd/resolv.conf.j2', state,
            user='root', group='root')

def make_hosts(state):
    logger.info(f"Writing {HOSTS_FILE}")
    render(HOSTS_FILE, 'vyos-hostsd/hosts.j2', state,
            user='root', group='root')

def make_pdns_rec_conf(state):
    logger.info(f"Writing {PDNS_REC_LUA_CONF_FILE}")

    # on boot, /run/powerdns does not exist, so create it
    makedir(PDNS_REC_RUN_DIR, user=PDNS_REC_USER, group=PDNS_REC_GROUP)
    chmod_755(PDNS_REC_RUN_DIR)

    render(PDNS_REC_LUA_CONF_FILE,
            'dns-forwarding/recursor.vyos-hostsd.conf.lua.j2',
            state, user=PDNS_REC_USER, group=PDNS_REC_GROUP)

    logger.info(f"Writing {PDNS_REC_ZONES_FILE}")
    render(PDNS_REC_ZONES_FILE,
            'dns-forwarding/recursor.forward-zones.conf.j2',
            state, user=PDNS_REC_USER, group=PDNS_REC_GROUP)

def set_host_name(state, data):
    if data['host_name']:
        state['host_name'] = data['host_name']
    if 'domain_name' in data:
        state['domain_name'] = data['domain_name']

def add_items_to_dict(_dict, items):
    """
    Dedupes and preserves sort order.
    """
    assert isinstance(_dict, dict)
    assert isinstance(items, dict)

    if not items:
        return

    _dict.update(items)

def add_items_to_dict_as_keys(_dict, items):
    """
    Added item values are converted to OrderedDict with the value as keys
    and null values. This is to emulate a list but with inherent deduplication.
    Dedupes and preserves sort order.
    """
    assert isinstance(_dict, dict)
    assert isinstance(items, dict)

    if not items:
        return

    for item, item_val in items.items():
        if item not in _dict:
            _dict[item] = OrderedDict({})
        _dict[item].update(OrderedDict.fromkeys(item_val))

def add_items_to_list(_list, items):
    """
    Dedupes and preserves sort order.
    """
    assert isinstance(_list, list)
    assert isinstance(items, list)

    if not items:
        return

    for item in items:
        if item not in _list:
            _list.append(item)

def delete_items_from_dict(_dict, items):
    """
    items is a list of keys to delete.
    Doesn't error if the key doesn't exist.
    """
    assert isinstance(_dict, dict)
    assert isinstance(items, list)

    for item in items:
        if item in _dict:
            del _dict[item]

def delete_items_from_list(_list, items):
    """
    items is a list of items to remove.
    Doesn't error if the key doesn't exist.
    """
    assert isinstance(_list, list)
    assert isinstance(items, list)

    for item in items:
        if item in _list:
            _list.remove(item)

def get_items_from_dict_regex(_dict, item_regex_string):
    """
    Returns the items whose keys match item_regex_string.
    """
    assert isinstance(_dict, dict)
    assert isinstance(item_regex_string, str)

    tmp = {}
    regex = re.compile(item_regex_string)
    for item in _dict:
        if regex.match(item):
            tmp[item] = _dict[item]
    return tmp

def get_option(msg, key):
    if key in msg:
        return msg[key]
    else:
        raise ValueError("Missing required option \"{0}\"".format(key))

def handle_message(msg):
    result = None
    op = get_option(msg, 'op')

    if op in ['add', 'delete', 'set']:
        STATE['changes'] += 1

    if op == 'delete':
        _type = get_option(msg, 'type')
        data = get_option(msg, 'data')
        if _type in ['name_servers', 'forward_zones', 'search_domains', 'hosts']:
            delete_items_from_dict(STATE[_type], data)
        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'authoritative_zones']:
            delete_items_from_list(STATE[_type], data)
        else:
            raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
    elif op == 'add':
        _type = get_option(msg, 'type')
        data = get_option(msg, 'data')
        if _type in ['name_servers', 'search_domains']:
            add_items_to_dict_as_keys(STATE[_type], data)
        elif _type in ['forward_zones', 'hosts']:
            add_items_to_dict(STATE[_type], data)
            # maybe we need to rec_control clear-nta each domain that was removed here?
        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'authoritative_zones']:
            add_items_to_list(STATE[_type], data)
        else:
            raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
    elif op == 'set':
        _type = get_option(msg, 'type')
        data = get_option(msg, 'data')
        if _type == 'host_name':
            set_host_name(STATE, data)
        else:
            raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
    elif op == 'get':
        _type = get_option(msg, 'type')
        if _type in ['name_servers', 'search_domains', 'hosts']:
            tag_regex = get_option(msg, 'tag_regex')
            result = get_items_from_dict_regex(STATE[_type], tag_regex)
        elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'forward_zones', 'authoritative_zones']:
            result = STATE[_type]
        else:
            raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
    elif op == 'apply':
        logger.info(f"Applying {STATE['changes']} changes")
        make_resolv_conf(STATE)
        make_hosts(STATE)
        make_pdns_rec_conf(STATE)
        pdns_rec_control('reload-lua-config')
        pdns_rec_control('reload-zones')
        logger.info("Success")
        result = {'message': f'Applied {STATE["changes"]} changes'}
        STATE['changes'] = 0

    else:
        raise ValueError(f"Unknown operation {op}")

    logger.debug(f"Saving state to {STATE_FILE}")
    with open(STATE_FILE, 'w') as f:
        json.dump(STATE, f)

    return result

if __name__ == '__main__':
    # Create a directory for state checkpoints
    os.makedirs(RUN_DIR, exist_ok=True)
    if os.path.exists(STATE_FILE):
        with open(STATE_FILE, 'r') as f:
            try:
                STATE = json.load(f)
            except:
                logger.exception(traceback.format_exc())
                logger.exception("Failed to load the state file, using default")

    context = zmq.Context()
    socket = context.socket(zmq.REP)

    # Set the right permissions on the socket, then change it back
    o_mask = os.umask(0o000)
    socket.bind(SOCKET_PATH)
    os.umask(o_mask)

    while True:
        #  Wait for next request from client
        msg_json = socket.recv().decode()
        logger.debug(f"Request data: {msg_json}")

        try:
            msg = json.loads(msg_json)
            validate_schema(msg)

            resp = {}
            resp['data'] = handle_message(msg)
        except ValueError as e:
            resp['error'] = str(e)
        except MultipleInvalid as e:
            # raised by schema
            resp['error'] = f'Invalid message: {str(e)}'
            logger.exception(resp['error'])
        except:
            logger.exception(traceback.format_exc())
            resp['error'] = "Internal error"

        #  Send reply back to client
        socket.send(json.dumps(resp).encode())
        logger.debug(f"Sent response: {resp}")