From 8639bdb81b962b299b545a85e6e867f1e281e55c Mon Sep 17 00:00:00 2001 From: lurchi Date: Sun, 4 Nov 2018 22:27:57 +0100 Subject: [PATCH] initial commit --- homeserverdns-daemon | 170 +++++++++++++++++++++++++++++++++++++++++++ homeserverdns-update | 104 ++++++++++++++++++++++++++ homeserverdns.cfg | 31 ++++++++ 3 files changed, 305 insertions(+) create mode 100755 homeserverdns-daemon create mode 100644 homeserverdns-update create mode 100644 homeserverdns.cfg diff --git a/homeserverdns-daemon b/homeserverdns-daemon new file mode 100755 index 0000000..8a1a490 --- /dev/null +++ b/homeserverdns-daemon @@ -0,0 +1,170 @@ +#!/run/current-system/sw/bin/bash + +# homeserverdns - https://ulrich.earth/code/homeserverdns +# +# Copyright (C) 2018 Christian Ulrich +# +# 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 3 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, see . + +readonly required_config_options=("protocol" "auth_key" "domains") +readonly default_ttl=300 +readonly default_public_ip4_hook="upnpc -s | grep ExternalIPAddress | cut -d ' ' -f3" +readonly default_update_hook="./homeserverdns-update" + + +### () -> default_network_interface +function get_interface { + ip -o -6 route show to default | cut -d " " -f5 +} + + +### (ip_address) -> bool +function is_unique_local_address { + prefix=$(echo $1 | cut -d ":" -f1) + [ "${prefix}" == "fd00" ] && echo true || echo false +} + + +### (ip_oneline_output) -> ip6_address +function extract_ip6 { + ip_address=$(echo $1 | tr -s " " | cut -d " " -f4 | cut -d "/" -f1) + [ "$(is_unique_local_address $ip_address)" == false ] && echo $ip_address || echo "" +} + + +### Returns an IPv6 address extracted from a oneline output of ip monitor if +### it's global, used for routing and still in use. Returns "" otherwise. +### (ip_monitor_output) -> ip6_address +function parse_ip_monitor_output { + filtered=$(echo $1 | grep -Ev '^Deleted|temporary|deprecated|noprefixroute|preferred_lft 0sec' | grep "scope global") + [ -n "${filtered}" ] && echo $(extract_ip6 "${filtered}") || echo "" +} + + +### (config) -> external_ip4_address +function lookup_ip4 { + eval "declare -A config="${1#*=} + local public_ip4_hook=${config["public_ip4_hook"]} + echo $(eval ${public_ip4_hook:-$default_public_ip4_hook}) +} + + +### () -> global_ip6_address +function lookup_ip6 { + iface=$(get_interface) + ip_output=$(ip -6 -o address show dev $iface scope global -temporary -deprecated -noprefixroute | head -n1) + echo $(extract_ip6 "${ip_output}") +} + + +### (config_path) -> config +function parse_config { + declare -A config + while IFS= read -r line + do + if [ -n "${line}" ] && [[ $line != \#* ]]; then + local key value + IFS="=" read -r key value <<< $line + [ -n "${key}" ] && [ -n "${value}" ] && config[$key]=$value + fi + done < $1 + declare -p config +} + + +### utility function for joining strings +### (delimiter, list) -> joined_list +function join_by { + local IFS=$1 + shift + echo "$*" +} + + +### utility function for splitting a full-qualified domain name +### (fqdn) -> (record_name, second_level_domain) +function split_domain { + local tokens=(${1//./ }) + local token_count=${#tokens[*]} + local second_level_domain=${tokens[@]:$((token_count - 2))} + if [ "${token_count}" -gt 2 ]; then + local record_name=${tokens[@]:0:$((token_count - 2))} + else + local record_name=("@") + fi + echo $(join_by . ${record_name[@]})" "$(join_by . ${second_level_domain[@]}) +} + + +### (ip4, ip6, config) -> log_output +function update { + eval "declare -A config="${3#*=} + local domains=(${config["domains"]}) + local update_hook=${config["update_hook"]} + export PROTOCOL=${config["protocol"]} + export API_ADDRESS=${config["api_address"]} + export USER=${config["user"]} + export AUTH_KEY=${config["auth_key"]} + #export IP4=$1 + #export IP6=$2 + export TTL=${config["ttl"]:-$default_ttl} + for domain in "${domains[@]}"; do + local record_name second_level_domain + read -r record_name second_level_domain <<< $(split_domain $domain) + export DOMAIN=$domain + export L2_DOMAIN=$second_level_domain + export RECORD_NAME=$record_name + echo "[$(date "+%Y-%m-%d %H:%M")] updating ${domain} ..." + if [ -n "$1" ]; then + echo -n "| setting A=$1 ... " + echo "status: "$(eval "IP4=$1 bash ${update_hook:-$default_update_hook}") + fi + if [ -n "$2" ]; then + echo -n "| setting AAAA=$2 ... " + echo "status: "$(eval "IP6=$2 bash ${update_hook:-$default_update_hook}") + fi + done +} + + +function main { + if [ "$#" -ne 1 ]; then + echo "usage: $0 CONFIG_FILE" + exit 1 + fi + config=$(parse_config $1) + eval "declare -A config="${config#*=} + for key in "${required_config_options[@]}"; do + if [ -z "${config[$key]}" ]; then + echo "configuration is missing option \"${key}\"" >&2 + exit 1 + fi + done + local ip4=$(lookup_ip4 "$(declare -p config)") + local ip6=$(lookup_ip6) + update "${ip4}" "${ip6}" "$(declare -p config)" + ip -6 -o monitor address dev $(get_interface) | + while IFS= read -r line + do + ip4=$(lookup_ip4 "$(declare -p config)") + local new_ip6=$(parse_ip_monitor_output "${line}") + #echo "ip-monitor output was ${line}" + if [ -n "${new_ip6}" ] && [ "${new_ip6}" != "${ip6}" ]; then + ip6=$new_ip6 + update "${ip4}" "${ip6}" "$(declare -p config)" + fi + done +} + +main "$@" diff --git a/homeserverdns-update b/homeserverdns-update new file mode 100644 index 0000000..8bc2a5f --- /dev/null +++ b/homeserverdns-update @@ -0,0 +1,104 @@ +#!/run/current-system/sw/bin/bash + +# homeserverdns - https://ulrich.earth/code/homeserverdns +# +# Copyright (C) 2018 Christian Ulrich +# +# 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 3 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, see . + + +# This script is called by homeserverdns-daemon for updating A/AAAA records. +# These environment variables will be set by homeserverdns-daemon: +# +# PROTOCOL protocol name (gandi|dyndns|httpnet) +# API_ADDRESS address for API requests, required for dnydns protocol +# USER user name for authentication at the API +# AUTH_KEY authentication token for authentication at the API +# DOMAIN fqdn for which A/AAAA records will be set +# L2_DOMAIN second-level domain for which A/AAAA records will be set +# RECORD_NAME name of the record to be set +# IP4 new value of the A record (IP4 or IP6 will be set exclusively) +# IP6 new value of the AAAA record (IP4 or IP6 will be set exclusively) +# TTL new TTL value for the A/AAAA records, required for gandi protocol + + +### gandi.net LiveDNS API +### () -> status_code +function update_gandi { + local url="https://dns.api.gandi.net/api/v5/domains/${L2_DOMAIN}/records/${RECORD_NAME}" + local ip="" + if [ -n "${IP4}" ]; then + url="${url}/A" + ip=$IP4 + elif [ -n "${IP6}" ]; then + url="${url}/AAAA" + ip=$IP6 + fi + status=$(curl -o /dev/null -s -w "%{http_code}\n" \ + -X PUT \ + -H "Content-Type:application/json" \ + -H "X-Api-Key:${AUTH_KEY}" \ + -d "{\"rrset_ttl\":${TTL},\"rrset_values\":[\"${ip}\"]}" \ + $url) + echo "${status}" +} + + +# FIXME: untested +### http.net DNS API v1 +### () -> status_code +function update_httpnet { + local current_ip4=$(dig A +short $DOMAIN) + local current_ip6=$(dig AAAA +short $DOMAIN) + local url="https://partner.http.net/api/dns/v1/json/zoneUpdate" + local items_add="" + local items_delete="" + if [ -n "${IP4}" ]; then + items_add="{\"name\":\"${DOMAIN}\",\"type\":\"A\",\"content\":\"${IP4}\",\"ttl\":\"${TTL}\"}" + elif [ -n "${IP6}" ]; then + items_add="{\"name\":\"${DOMAIN}\",\"type\":\"AAAA\",\"content\":\"${IP6}\",\"ttl\":\"${TTL}\"}" + fi + if [ -n "${current_ip4}" ]; then + items_delete="{\"name\":\"${DOMAIN}\",\"type\":\"A\",\"content\":\"${current_ip4}\"}" + fi + if [ -n "${current_ip6}" ]; then + items_delete="{\"name\":\"${DOMAIN}\",\"type\":\"AAAA\",\"content\":\"${current_ip6}\"}" + fi + status=$(curl -o /dev/null -s -w "%{http_code}\n" \ + -X PUT \ + -d "{\"authToken\":\"${AUTH_KEY}\"," \ + "\"zoneConfig\":{\"name\":\"${L2_DOMAIN}\"}," \ + "\"recordsToAdd\":[${items_add}]," \ + "\"recordsToDelete\":[${items_delete}]}" \ + $url) + echo "${status}" +} + + +# FIXME: untested +### dyndns API v2/v3 API +### () -> status_code +function update_dyndns { + local hostname=${L2_DOMAIN} + if [ "${RECORD_NAME}" != "@" ]; then + local hostname=$DOMAIN + fi + local address=${IP4:-$IP6} + local url="https://${USER}:${AUTH_KEY}@${API_ADDRESS}?hostname=${hostname}&myip=${address}" + status=$(curl -o /dev/null -s -w "%{http_code}\n" $url) + echo "${url} ${status}" +} + + +eval "update_${PROTOCOL}" diff --git a/homeserverdns.cfg b/homeserverdns.cfg new file mode 100644 index 0000000..3128b81 --- /dev/null +++ b/homeserverdns.cfg @@ -0,0 +1,31 @@ +# Protocol name, may be left empty when using a custom update_hook. +# The default update_hook supports: gandi, dyndns +protocol= + +# The command to be called to determine our public IP address. By default UPNP +# will be used +public_ip4_hook= + +# The command to be called when an IP change is detected. Environment variables +# will be set when calling it (for documentation see homeserverdns-update). +# Default: ./homeserverdns-update +update_hook= + +# The address of the API, required for dyndns protocol. +# Example: dyndns.strato.com/nic/update +api_address= + +# User name for authentication at the API (not all protocols require this). +user= + +# Token used for authentication at the API +auth_key= + +# Time-to-live for the A/AAAA records (not all protocols support this). +# default: 300 +ttl= + +# A space separated list of the domains for which the A/AAAA records shall be +# updated. For each domain the update_hook will be called. +domains= +