A simple DNS hook that lets Dehydrated talk to the PowerDNS API.
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

469 lines
11KB

  1. #!/usr/bin/env bash
  2. # Copyright 2016-2018 - Silke Hofstra and contributors
  3. #
  4. # Licensed under the EUPL
  5. #
  6. # You may not use this work except in compliance with the Licence.
  7. # You may obtain a copy of the Licence at:
  8. #
  9. # https://joinup.ec.europa.eu/collection/eupl
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the Licence is distributed on an "AS IS" basis,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
  14. # express or implied.
  15. # See the Licence for the specific language governing
  16. # permissions and limitations under the Licence.
  17. #
  18. set -e
  19. set -u
  20. set -o pipefail
  21. # Local directory
  22. DIR="$(dirname "$0")"
  23. # Config directories
  24. CONFIG_DIRS="/etc/dehydrated /usr/local/etc/dehydrated"
  25. # Show an error/warning
  26. error() { echo "Error: $*" >&2; }
  27. warn() { echo "Warning: $*" >&2; }
  28. fatalerror() { error "$*"; exit 1; }
  29. # Debug message
  30. debug() { [[ -z "${DEBUG:-}" ]] || echo "$@"; }
  31. # Join an array with a character
  32. join() { local IFS="$1"; shift; echo "$*"; }
  33. # Reverse a string
  34. rev() {
  35. local str rev
  36. str="$(cat)"
  37. rev=""
  38. for (( i=${#str}-1; i>=0; i-- )); do rev="${rev}${str:$i:1}"; done
  39. echo "${rev}"
  40. }
  41. # Different sed version for different os types...
  42. # From letsencrypt.sh
  43. _sed() {
  44. if [[ "${OSTYPE}" = "Linux" ]]; then
  45. sed -r "${@}"
  46. else
  47. sed -E "${@}"
  48. fi
  49. }
  50. # Get string value from json dictionary
  51. # From letsencrypt.sh
  52. get_json_string_value() {
  53. local filter
  54. filter="$(printf 's/.*"%s": *"([^"]*)".*/\\1/p' "$1")"
  55. _sed -n "${filter}"
  56. }
  57. # Get integer value from json dictionary
  58. get_json_int_value() {
  59. local filter
  60. filter="$(printf 's/.*"%s": *([^,}]*),*.*/\\1/p' "$1")"
  61. _sed -n "${filter}"
  62. }
  63. # Load the configuration and set default values
  64. load_config() {
  65. # Check for config in various locations
  66. # From letsencrypt.sh
  67. if [[ -z "${CONFIG:-}" ]]; then
  68. for check_config in ${CONFIG_DIRS} "${PWD}" "${DIR}"; do
  69. if [[ -f "${check_config}/config" ]]; then
  70. CONFIG="${check_config}/config"
  71. break
  72. fi
  73. done
  74. fi
  75. # Check if config was set
  76. if [[ -z "${CONFIG:-}" ]]; then
  77. # Warn about missing config
  78. warn "No config file found, using default config!"
  79. elif [[ -f "${CONFIG}" ]]; then
  80. # shellcheck disable=SC1090
  81. . "${CONFIG}"
  82. fi
  83. if [[ -n "${CONFIG_D:-}" ]]; then
  84. if [[ ! -d "${CONFIG_D}" ]]; then
  85. fatalerror "The path ${CONFIG_D} specified for CONFIG_D does not point to a directory."
  86. fi
  87. # Allow globbing
  88. if [[ -n "${ZSH_VERSION:-}" ]]
  89. then
  90. set +o noglob
  91. else
  92. set +f
  93. fi
  94. for check_config_d in "${CONFIG_D}"/*.sh; do
  95. if [[ -f "${check_config_d}" ]] && [[ -r "${check_config_d}" ]]; then
  96. echo "# INFO: Using additional config file ${check_config_d}"
  97. # shellcheck disable=SC1090
  98. . "${check_config_d}"
  99. else
  100. fatalerror "Specified additional config ${check_config_d} is not readable or not a file at all."
  101. fi
  102. done
  103. # Disable globbing
  104. if [[ -n "${ZSH_VERSION:-}" ]]
  105. then
  106. set -o noglob
  107. else
  108. set -f
  109. fi
  110. fi
  111. # Check required settings
  112. [[ -n "${PDNS_HOST:-}" ]] || fatalerror "PDNS_HOST setting is required."
  113. [[ -n "${PDNS_KEY:-}" ]] || fatalerror "PDNS_KEY setting is required."
  114. # Check optional settings
  115. [[ -n "${PDNS_PORT:-}" ]] || PDNS_PORT=8081
  116. }
  117. # Load the zones from file
  118. load_zones() {
  119. # Check for zones.txt in various locations
  120. if [[ -z "${PDNS_ZONES_TXT:-}" ]]; then
  121. for check_zones in ${CONFIG_DIRS} "${PWD}" "${DIR}"; do
  122. if [[ -f "${check_zones}/zones.txt" ]]; then
  123. PDNS_ZONES_TXT="${check_zones}/zones.txt"
  124. break
  125. fi
  126. done
  127. fi
  128. # Load zones
  129. all_zones=""
  130. if [[ -n "${PDNS_ZONES_TXT:-}" ]] && [[ -f "${PDNS_ZONES_TXT}" ]]; then
  131. all_zones="$(cat "${PDNS_ZONES_TXT}")"
  132. fi
  133. }
  134. # API request
  135. request() {
  136. # Request parameters
  137. local method url data
  138. method="$1"
  139. url="$2"
  140. data="$3"
  141. error=false
  142. # Perform the request
  143. # This is wrappend in an if to avoid the exit on error
  144. if ! res="$(curl -sSfL --stderr - --request "${method}" --header "${content_header}" --header "${api_header}" --data "${data}" "${url}")"; then
  145. error=true
  146. fi
  147. # Debug output
  148. debug "# Request"
  149. debug "Method: ${method}"
  150. debug "URL: ${url}"
  151. debug "Data: ${data}"
  152. debug "Response: ${res}"
  153. # Abort on failed request
  154. if [[ "${res}" = *"error"* ]] || [[ "${error}" = true ]]; then
  155. fatalerror "API error: ${res}"
  156. fi
  157. }
  158. # Setup of connection settings
  159. setup() {
  160. # Header values
  161. api_header="X-API-Key: ${PDNS_KEY}"
  162. content_header="Content-Type: application/json"
  163. # Set the URL to the host if it is a URL,
  164. # otherwise create it from the host and port.
  165. if [[ "${PDNS_HOST}" == http?(s)://* ]]; then
  166. url="${PDNS_HOST}"
  167. else
  168. url="http://${PDNS_HOST}:${PDNS_PORT}"
  169. fi
  170. # Detect the version
  171. if [[ -z "${PDNS_VERSION:-}" ]]; then
  172. request "GET" "${url}/api" ""
  173. PDNS_VERSION="$(<<< "${res}" get_json_int_value version)"
  174. fi
  175. # Fallback to version 0
  176. if [[ -z "${PDNS_VERSION}" ]]; then
  177. PDNS_VERSION=0
  178. fi
  179. # Some version incompatibilities
  180. if [[ "${PDNS_VERSION}" -ge 1 ]]; then
  181. url="${url}/api/v${PDNS_VERSION}"
  182. fi
  183. # Detect the server
  184. if [[ -z "${PDNS_SERVER:-}" ]]; then
  185. request "GET" "${url}/servers" ""
  186. PDNS_SERVER="$(<<< "${res}" get_json_string_value id)"
  187. fi
  188. # Fallback to localhost
  189. if [[ -z "${PDNS_SERVER}" ]]; then
  190. PDNS_SERVER="localhost"
  191. fi
  192. # Zone endpoint on the API
  193. url="${url}/servers/${PDNS_SERVER}/zones"
  194. # Get a zone list from the API is none was set
  195. if [[ -z "${all_zones}" ]]; then
  196. request "GET" "${url}" ""
  197. all_zones="$(<<< "${res//, /$',\n'}" get_json_string_value name)"
  198. fi
  199. # Strip trailing dots from zones
  200. all_zones="${all_zones//$'.\n'/ }"
  201. all_zones="${all_zones%.}"
  202. # Sort zones to list most specific first
  203. all_zones="$(<<< "${all_zones}" rev | sort | rev)"
  204. # Set suffix in case of CNAME redirection
  205. if [[ -n "${PDNS_SUFFIX:-}" ]]; then
  206. suffix=".${PDNS_SUFFIX}"
  207. else
  208. suffix=""
  209. fi
  210. # Debug setup result
  211. debug "# Setup"
  212. debug "API version: ${PDNS_VERSION}"
  213. debug "PDNS server: ${PDNS_SERVER}"
  214. debug "Zones: ${all_zones}"
  215. debug "Suffix: \"${suffix}\""
  216. }
  217. setup_domain() {
  218. # Domain and token from arguments
  219. domain="$1"
  220. token="$2"
  221. zone=""
  222. # Record name
  223. name="_acme-challenge.${domain}${suffix}"
  224. # Read name parts into array
  225. IFS='.' read -ra name_array <<< "${name}"
  226. # Find zone name, cut off subdomains until match
  227. for check_zone in ${all_zones}; do
  228. for (( j=${#name_array[@]}-1; j>=0; j-- )); do
  229. if [[ "${check_zone}" = "$(join . "${name_array[@]:j}")" ]]; then
  230. zone="${check_zone}"
  231. break 2
  232. fi
  233. done
  234. done
  235. # Fallback to creating zone from arguments
  236. if [[ -z "${zone}" ]]; then
  237. zone="${name_array[*]: -2:1}.${name_array[*]: -1:1}"
  238. warn "zone not found, using '${zone}'"
  239. fi
  240. # Some version incompatibilities
  241. if [[ "${PDNS_VERSION}" -ge 1 ]]; then
  242. name="${name}."
  243. zone="${zone}."
  244. extra_data=""
  245. else
  246. extra_data=",\"name\": \"${name}\", \"type\": \"TXT\", \"ttl\": 1"
  247. fi
  248. }
  249. get_records() {
  250. IFS=" " read -ra tokens <<< "${token}"
  251. for i in "${!tokens[@]}"; do
  252. printf '%.*s' $((i != 0)) ","
  253. echo -n '{
  254. "content": "\"'"${tokens[$i]}"'\"",
  255. "disabled": false,
  256. "set-ptr": false
  257. '"${extra_data}"'
  258. }'
  259. done
  260. }
  261. deploy_rrset() {
  262. echo -n '{
  263. "name": "'"${name}"'",
  264. "type": "TXT",
  265. "ttl": 1,
  266. "records": ['"$(get_records)"'],
  267. "changetype": "REPLACE"
  268. }'
  269. }
  270. clean_rrset() {
  271. echo '{"name":"'"${name}"'","type":"TXT","changetype":"DELETE"}'
  272. }
  273. soa_edit() {
  274. # Show help
  275. if [[ $# -eq 0 ]]; then
  276. echo "Usage: pdns_api.sh soa_edit <zone> [SOA-EDIT] [SOA-EDIT-API]"
  277. exit 1
  278. fi
  279. # Get current values for zone
  280. request "GET" "${url}/$1" ""
  281. # Set variables
  282. if [[ $# -le 1 ]]; then
  283. soa_edit=$(<<< "${res}" get_json_string_value soa_edit)
  284. soa_edit_api=$(<<< "${res}" get_json_string_value soa_edit_api)
  285. echo "Current values:"
  286. else
  287. soa_edit="$2"
  288. if [[ $# -eq 3 ]]; then
  289. soa_edit_api="$3"
  290. else
  291. soa_edit_api="DEFAULT"
  292. fi
  293. echo "Setting:"
  294. fi
  295. # Display values
  296. echo "SOA-EDIT: ${soa_edit}"
  297. echo "SOA-EDIT-API: ${soa_edit_api}"
  298. # Update values
  299. if [[ $# -ge 2 ]]; then
  300. request "PUT" "${url}/${1}" '{
  301. "soa_edit":"'"${soa_edit}"'",
  302. "soa_edit_api":"'"${soa_edit_api}"'",
  303. "kind":"'"$(<<< "${res}" get_json_string_value kind)"'"
  304. }'
  305. fi
  306. }
  307. exit_hook() {
  308. if [[ ! -z "${PDNS_EXIT_HOOK:-}" ]]; then
  309. if [[ -x "${PDNS_EXIT_HOOK}" ]]; then
  310. exec "${PDNS_EXIT_HOOK}"
  311. else
  312. fatalerror "${PDNS_EXIT_HOOK} is not an executable"
  313. fi
  314. fi
  315. }
  316. main() {
  317. # Set hook
  318. hook="$1"
  319. # Debug output
  320. debug "Hook: ${hook}"
  321. # Ignore unknown hooks
  322. if [[ ! "${hook}" =~ ^(deploy_challenge|clean_challenge|soa_edit|exit_hook)$ ]]; then
  323. exit 0
  324. fi
  325. # Main setup
  326. load_config
  327. load_zones
  328. setup
  329. declare -A requests
  330. # Interface for SOA-EDIT
  331. if [[ "${hook}" = "soa_edit" ]]; then
  332. shift
  333. soa_edit "$@"
  334. exit 0
  335. fi
  336. # Interface for exit_hook
  337. if [[ "${hook}" = "exit_hook" ]]; then
  338. shift
  339. exit_hook "$@"
  340. exit 0
  341. fi
  342. declare -A domains
  343. # Loop through arguments per 3
  344. for ((i=2; i<=$#; i=i+3)); do
  345. t=$((i + 2))
  346. _domain="${!i}"
  347. _token="${!t}"
  348. if [[ "${_domain}" == "*."* ]]; then
  349. debug "Domain ${_domain} is a wildcard domain, ACME challenge will be for domain apex (${_domain:2})"
  350. _domain="${_domain:2}"
  351. fi
  352. domains[${_domain}]="${_token} ${domains[${_domain}]:-}"
  353. done
  354. # Loop through unique domains
  355. for domain in "${!domains[@]}"; do
  356. # Setup for this domain
  357. req=""
  358. t=${domains[${domain}]}
  359. setup_domain "${domain}" "${t}"
  360. # Debug output
  361. debug "# Domain"
  362. debug "Name: ${name}"
  363. debug "Token: ${token}"
  364. debug "Zone: ${zone}"
  365. # Add comma
  366. if [[ ${requests[${zone}]+x} ]]; then
  367. req="${requests[${zone}]},"
  368. fi
  369. # Deploy a token
  370. if [[ "${hook}" = "deploy_challenge" ]]; then
  371. requests[${zone}]="${req}$(deploy_rrset)"
  372. fi
  373. # Remove a token
  374. if [[ "${hook}" = "clean_challenge" ]]; then
  375. requests[${zone}]="${req}$(clean_rrset)"
  376. fi
  377. # Other actions are not implemented but will not cause an error
  378. done
  379. # Perform requests
  380. for zone in "${!requests[@]}"; do
  381. request "PATCH" "${url}/${zone}" '{"rrsets": ['"${requests[${zone}]}"']}'
  382. if [[ -z "${PDNS_NO_NOTIFY:-}" ]]; then
  383. request "PUT" "${url}/${zone}/notify" ''
  384. fi
  385. done
  386. # Wait the requested amount of seconds when deployed
  387. if [[ "${hook}" = "deploy_challenge" ]] && [[ -n "${PDNS_WAIT:-}" ]]; then
  388. debug "Waiting for ${PDNS_WAIT} seconds"
  389. sleep "${PDNS_WAIT}"
  390. fi
  391. }
  392. main "$@"