A simple DNS hook that lets Dehydrated talk to the PowerDNS API.
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

469 líneas
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 "$@"