A simple DNS hook that lets Dehydrated talk to the PowerDNS API.
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

460 lines
10KB

  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 "Method: ${method}"
  149. debug "URL: ${url}"
  150. debug "Data: ${data}"
  151. debug "Response: ${res}"
  152. # Abort on failed request
  153. if [[ "${res}" = *"error"* ]] || [[ "${error}" = true ]]; then
  154. fatalerror "API error: ${res}"
  155. fi
  156. }
  157. # Setup of connection settings
  158. setup() {
  159. # Header values
  160. api_header="X-API-Key: ${PDNS_KEY}"
  161. content_header="Content-Type: application/json"
  162. # Set the URL to the host if it is a URL,
  163. # otherwise create it from the host and port.
  164. if [[ "${PDNS_HOST}" == http?(s)://* ]]; then
  165. url="${PDNS_HOST}"
  166. else
  167. url="http://${PDNS_HOST}:${PDNS_PORT}"
  168. fi
  169. # Detect the version
  170. if [[ -z "${PDNS_VERSION:-}" ]]; then
  171. request "GET" "${url}/api" ""
  172. PDNS_VERSION="$(<<< "${res}" get_json_int_value version)"
  173. fi
  174. # Fallback to version 0
  175. if [[ -z "${PDNS_VERSION}" ]]; then
  176. PDNS_VERSION=0
  177. fi
  178. # Some version incompatibilities
  179. if [[ "${PDNS_VERSION}" -ge 1 ]]; then
  180. url="${url}/api/v${PDNS_VERSION}"
  181. fi
  182. # Detect the server
  183. if [[ -z "${PDNS_SERVER:-}" ]]; then
  184. request "GET" "${url}/servers" ""
  185. PDNS_SERVER="$(<<< "${res}" get_json_string_value id)"
  186. fi
  187. # Fallback to localhost
  188. if [[ -z "${PDNS_SERVER}" ]]; then
  189. PDNS_SERVER="localhost"
  190. fi
  191. # Zone endpoint on the API
  192. url="${url}/servers/${PDNS_SERVER}/zones"
  193. # Get a zone list from the API is none was set
  194. if [[ -z "${all_zones}" ]]; then
  195. request "GET" "${url}" ""
  196. all_zones="$(<<< "${res//, /$',\n'}" get_json_string_value name)"
  197. fi
  198. # Strip trailing dots from zones
  199. all_zones="${all_zones//$'.\n'/ }"
  200. all_zones="${all_zones%.}"
  201. # Sort zones to list most specific first
  202. all_zones="$(<<< "${all_zones}" rev | sort | rev)"
  203. # Set suffix in case of CNAME redirection
  204. if [[ -n "${PDNS_SUFFIX:-}" ]]; then
  205. suffix=".${PDNS_SUFFIX}"
  206. else
  207. suffix=""
  208. fi
  209. }
  210. setup_domain() {
  211. # Domain and token from arguments
  212. domain="$1"
  213. token="$2"
  214. zone=""
  215. # Record name
  216. name="_acme-challenge.${domain}${suffix}"
  217. # Read name parts into array
  218. IFS='.' read -ra name_array <<< "${name}"
  219. # Find zone name, cut off subdomains until match
  220. for check_zone in ${all_zones}; do
  221. for (( j=${#name_array[@]}-1; j>=0; j-- )); do
  222. if [[ "${check_zone}" = "$(join . "${name_array[@]:j}")" ]]; then
  223. zone="${check_zone}"
  224. break 2
  225. fi
  226. done
  227. done
  228. # Fallback to creating zone from arguments
  229. if [[ -z "${zone}" ]]; then
  230. zone="${name_array[*]: -2:1}.${name_array[*]: -1:1}"
  231. warn "zone not found, using '${zone}'"
  232. fi
  233. # Some version incompatibilities
  234. if [[ "${PDNS_VERSION}" -ge 1 ]]; then
  235. name="${name}."
  236. zone="${zone}."
  237. extra_data=""
  238. else
  239. extra_data=",\"name\": \"${name}\", \"type\": \"TXT\", \"ttl\": 1"
  240. fi
  241. }
  242. get_records() {
  243. IFS=" " read -ra tokens <<< "${token}"
  244. for i in "${!tokens[@]}"; do
  245. printf '%.*s' $((i != 0)) ","
  246. echo -n '{
  247. "content": "\"'"${tokens[$i]}"'\"",
  248. "disabled": false,
  249. "set-ptr": false
  250. '"${extra_data}"'
  251. }'
  252. done
  253. }
  254. deploy_rrset() {
  255. echo -n '{
  256. "name": "'"${name}"'",
  257. "type": "TXT",
  258. "ttl": 1,
  259. "records": ['"$(get_records)"'],
  260. "changetype": "REPLACE"
  261. }'
  262. }
  263. clean_rrset() {
  264. echo '{"name":"'"${name}"'","type":"TXT","changetype":"DELETE"}'
  265. }
  266. soa_edit() {
  267. # Show help
  268. if [[ $# -eq 0 ]]; then
  269. echo "Usage: pdns_api.sh soa_edit <zone> [SOA-EDIT] [SOA-EDIT-API]"
  270. exit 1
  271. fi
  272. # Get current values for zone
  273. request "GET" "${url}/$1" ""
  274. # Set variables
  275. if [[ $# -le 1 ]]; then
  276. soa_edit=$(<<< "${res}" get_json_string_value soa_edit)
  277. soa_edit_api=$(<<< "${res}" get_json_string_value soa_edit_api)
  278. echo "Current values:"
  279. else
  280. soa_edit="$2"
  281. if [[ $# -eq 3 ]]; then
  282. soa_edit_api="$3"
  283. else
  284. soa_edit_api="DEFAULT"
  285. fi
  286. echo "Setting:"
  287. fi
  288. # Display values
  289. echo "SOA-EDIT: ${soa_edit}"
  290. echo "SOA-EDIT-API: ${soa_edit_api}"
  291. # Update values
  292. if [[ $# -ge 2 ]]; then
  293. request "PUT" "${url}/${1}" '{
  294. "soa_edit":"'"${soa_edit}"'",
  295. "soa_edit_api":"'"${soa_edit_api}"'",
  296. "kind":"'"$(<<< "${res}" get_json_string_value kind)"'"
  297. }'
  298. fi
  299. }
  300. exit_hook() {
  301. if [[ ! -z "${PDNS_EXIT_HOOK:-}" ]]; then
  302. if [[ -x "${PDNS_EXIT_HOOK}" ]]; then
  303. exec "${PDNS_EXIT_HOOK}"
  304. else
  305. fatalerror "${PDNS_EXIT_HOOK} is not an executable"
  306. fi
  307. fi
  308. }
  309. main() {
  310. # Set hook
  311. hook="$1"
  312. # Debug output
  313. debug "Hook: ${hook}"
  314. # Ignore unknown hooks
  315. if [[ ! "${hook}" =~ ^(deploy_challenge|clean_challenge|soa_edit|exit_hook)$ ]]; then
  316. exit 0
  317. fi
  318. # Main setup
  319. load_config
  320. load_zones
  321. setup
  322. declare -A requests
  323. # Interface for SOA-EDIT
  324. if [[ "${hook}" = "soa_edit" ]]; then
  325. shift
  326. soa_edit "$@"
  327. exit 0
  328. fi
  329. # Interface for exit_hook
  330. if [[ "${hook}" = "exit_hook" ]]; then
  331. shift
  332. exit_hook "$@"
  333. exit 0
  334. fi
  335. declare -A domains
  336. # Loop through arguments per 3
  337. for ((i=2; i<=$#; i=i+3)); do
  338. t=$((i + 2))
  339. _domain="${!i}"
  340. _token="${!t}"
  341. if [[ "${_domain}" == "*."* ]]; then
  342. debug "Domain ${_domain} is a wildcard domain, ACME challenge will be for domain apex (${_domain:2})"
  343. _domain="${_domain:2}"
  344. fi
  345. domains[${_domain}]="${_token} ${domains[${_domain}]:-}"
  346. done
  347. # Loop through unique domains
  348. for domain in "${!domains[@]}"; do
  349. # Setup for this domain
  350. req=""
  351. t=${domains[${domain}]}
  352. setup_domain "${domain}" "${t}"
  353. # Debug output
  354. debug "Name: ${name}"
  355. debug "Token: ${token}"
  356. debug "Zone: ${zone}"
  357. # Add comma
  358. if [[ ${requests[${zone}]+x} ]]; then
  359. req="${requests[${zone}]},"
  360. fi
  361. # Deploy a token
  362. if [[ "${hook}" = "deploy_challenge" ]]; then
  363. requests[${zone}]="${req}$(deploy_rrset)"
  364. fi
  365. # Remove a token
  366. if [[ "${hook}" = "clean_challenge" ]]; then
  367. requests[${zone}]="${req}$(clean_rrset)"
  368. fi
  369. # Other actions are not implemented but will not cause an error
  370. done
  371. # Perform requests
  372. for zone in "${!requests[@]}"; do
  373. request "PATCH" "${url}/${zone}" '{"rrsets": ['"${requests[${zone}]}"']}'
  374. if [[ -z "${PDNS_NO_NOTIFY:-}" ]]; then
  375. request "PUT" "${url}/${zone}/notify" ''
  376. fi
  377. done
  378. # Wait the requested amount of seconds when deployed
  379. if [[ "${hook}" = "deploy_challenge" ]] && [[ -n "${PDNS_WAIT:-}" ]]; then
  380. debug "Waiting for ${PDNS_WAIT} seconds"
  381. sleep "${PDNS_WAIT}"
  382. fi
  383. }
  384. main "$@"