dns_hostup.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. #!/usr/bin/env sh
  2. # shellcheck disable=SC2034,SC2154
  3. dns_hostup_info='HostUp DNS
  4. Site: hostup.se
  5. Docs: https://developer.hostup.se/
  6. Options:
  7. HOSTUP_API_KEY Required. HostUp API key with read:dns + write:dns + read:domains scopes.
  8. HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api).
  9. HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds).
  10. HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection).
  11. Author: HostUp (https://cloud.hostup.se/contact/en)
  12. '
  13. HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api"
  14. HOSTUP_DEFAULT_TTL=60
  15. # Public: add TXT record
  16. # Usage: dns_hostup_add _acme-challenge.example.com "txt-value"
  17. dns_hostup_add() {
  18. fulldomain="$1"
  19. txtvalue="$2"
  20. _info "Using HostUp DNS API"
  21. if ! _hostup_init; then
  22. return 1
  23. fi
  24. if ! _hostup_detect_zone "$fulldomain"; then
  25. _err "Unable to determine HostUp zone for $fulldomain"
  26. return 1
  27. fi
  28. record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")"
  29. record_name="$(_hostup_sanitize_name "$record_name")"
  30. record_value="$(_hostup_json_escape "$txtvalue")"
  31. ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}"
  32. _debug "zone_id" "$HOSTUP_ZONE_ID"
  33. _debug "zone_domain" "$HOSTUP_ZONE_DOMAIN"
  34. _debug "record_name" "$record_name"
  35. _debug "ttl" "$ttl"
  36. request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$record_value\",\"ttl\":$ttl}"
  37. if ! _hostup_rest "POST" "/dns/zones/$HOSTUP_ZONE_ID/records" "$request_body"; then
  38. return 1
  39. fi
  40. if ! _contains "$_hostup_response" '"success":true'; then
  41. _err "HostUp DNS API: failed to create TXT record for $fulldomain"
  42. _debug2 "_hostup_response" "$_hostup_response"
  43. return 1
  44. fi
  45. record_id="$(_hostup_extract_record_id "$_hostup_response")"
  46. if [ -n "$record_id" ]; then
  47. _hostup_save_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_id"
  48. _debug "hostup_saved_record_id" "$record_id"
  49. fi
  50. _info "Added TXT record for $fulldomain"
  51. return 0
  52. }
  53. # Public: remove TXT record
  54. # Usage: dns_hostup_rm _acme-challenge.example.com "txt-value"
  55. dns_hostup_rm() {
  56. fulldomain="$1"
  57. txtvalue="$2"
  58. _info "Using HostUp DNS API"
  59. if ! _hostup_init; then
  60. return 1
  61. fi
  62. if ! _hostup_detect_zone "$fulldomain"; then
  63. _err "Unable to determine HostUp zone for $fulldomain"
  64. return 1
  65. fi
  66. record_name_fqdn="$(_hostup_fqdn "$fulldomain")"
  67. record_value="$txtvalue"
  68. record_id_cached="$(_hostup_get_saved_record_id "$HOSTUP_ZONE_ID" "$fulldomain")"
  69. if [ -n "$record_id_cached" ]; then
  70. _debug "hostup_record_id_cached" "$record_id_cached"
  71. if _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$record_id_cached"; then
  72. _info "Deleted TXT record $record_id_cached"
  73. _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
  74. HOSTUP_ZONE_ID=""
  75. return 0
  76. fi
  77. fi
  78. if ! _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$record_value"; then
  79. _info "TXT record not found for $record_name_fqdn. Skipping removal."
  80. _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
  81. return 0
  82. fi
  83. _debug "Deleting record" "$HOSTUP_RECORD_ID"
  84. if ! _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$HOSTUP_RECORD_ID"; then
  85. return 1
  86. fi
  87. _info "Deleted TXT record $HOSTUP_RECORD_ID"
  88. _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
  89. HOSTUP_ZONE_ID=""
  90. return 0
  91. }
  92. ##########################
  93. # Private helper methods #
  94. ##########################
  95. _hostup_init() {
  96. HOSTUP_API_KEY="${HOSTUP_API_KEY:-$(_readaccountconf_mutable HOSTUP_API_KEY)}"
  97. HOSTUP_API_BASE="${HOSTUP_API_BASE:-$(_readaccountconf_mutable HOSTUP_API_BASE)}"
  98. HOSTUP_TTL="${HOSTUP_TTL:-$(_readaccountconf_mutable HOSTUP_TTL)}"
  99. HOSTUP_ZONE_ID="${HOSTUP_ZONE_ID:-$(_readaccountconf_mutable HOSTUP_ZONE_ID)}"
  100. if [ -z "$HOSTUP_API_BASE" ]; then
  101. HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT"
  102. fi
  103. if [ -z "$HOSTUP_API_KEY" ]; then
  104. HOSTUP_API_KEY=""
  105. _err "HOSTUP_API_KEY is not set."
  106. _err "Please export your HostUp API key with read:dns and write:dns scopes."
  107. return 1
  108. fi
  109. _saveaccountconf_mutable HOSTUP_API_KEY "$HOSTUP_API_KEY"
  110. _saveaccountconf_mutable HOSTUP_API_BASE "$HOSTUP_API_BASE"
  111. if [ -n "$HOSTUP_TTL" ]; then
  112. _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL"
  113. fi
  114. if [ -n "$HOSTUP_ZONE_ID" ]; then
  115. _saveaccountconf_mutable HOSTUP_ZONE_ID "$HOSTUP_ZONE_ID"
  116. fi
  117. return 0
  118. }
  119. _hostup_detect_zone() {
  120. fulldomain="$1"
  121. if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then
  122. return 0
  123. fi
  124. HOSTUP_ZONE_DOMAIN=""
  125. _debug "hostup_full_domain" "$fulldomain"
  126. if [ -n "$HOSTUP_ZONE_ID" ] && [ -z "$HOSTUP_ZONE_DOMAIN" ]; then
  127. # Attempt to fetch domain name for provided zone ID
  128. if _hostup_fetch_zone_details "$HOSTUP_ZONE_ID"; then
  129. return 0
  130. fi
  131. HOSTUP_ZONE_ID=""
  132. fi
  133. if ! _hostup_load_zones; then
  134. return 1
  135. fi
  136. _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)"
  137. _debug "hostup_initial_candidate" "$_domain_candidate"
  138. while [ -n "$_domain_candidate" ]; do
  139. _debug "hostup_zone_candidate" "$_domain_candidate"
  140. if _hostup_lookup_zone "$_domain_candidate"; then
  141. HOSTUP_ZONE_DOMAIN="$_lookup_zone_domain"
  142. HOSTUP_ZONE_ID="$_lookup_zone_id"
  143. return 0
  144. fi
  145. case "$_domain_candidate" in
  146. *.*) ;;
  147. *) break ;;
  148. esac
  149. _domain_candidate="${_domain_candidate#*.}"
  150. done
  151. HOSTUP_ZONE_ID=""
  152. return 1
  153. }
  154. _hostup_record_name() {
  155. fulldomain="$1"
  156. zonedomain="$2"
  157. # Remove trailing dot, if any
  158. fulldomain="${fulldomain%.}"
  159. zonedomain="${zonedomain%.}"
  160. if [ "$fulldomain" = "$zonedomain" ]; then
  161. printf "%s" "@"
  162. return 0
  163. fi
  164. suffix=".$zonedomain"
  165. case "$fulldomain" in
  166. *"$suffix")
  167. printf "%s" "${fulldomain%"$suffix"}"
  168. ;;
  169. *)
  170. # Domain not within zone, fall back to full host
  171. printf "%s" "$fulldomain"
  172. ;;
  173. esac
  174. }
  175. _hostup_sanitize_name() {
  176. name="$1"
  177. if [ -z "$name" ] || [ "$name" = "." ]; then
  178. printf "%s" "@"
  179. return 0
  180. fi
  181. # Remove any trailing dot
  182. name="${name%.}"
  183. printf "%s" "$name"
  184. }
  185. _hostup_fqdn() {
  186. domain="$1"
  187. printf "%s" "${domain%.}"
  188. }
  189. _hostup_fetch_zone_details() {
  190. zone_id="$1"
  191. if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
  192. return 1
  193. fi
  194. zonedomain="$(printf "%s" "$_hostup_response" | _egrep_o '"domain":"[^"]*"' | sed -n '1p' | cut -d ':' -f 2 | tr -d '"')"
  195. if [ -n "$zonedomain" ]; then
  196. HOSTUP_ZONE_DOMAIN="$zonedomain"
  197. return 0
  198. fi
  199. return 1
  200. }
  201. _hostup_load_zones() {
  202. if ! _hostup_rest "GET" "/dns/zones" ""; then
  203. return 1
  204. fi
  205. HOSTUP_ZONES_CACHE=""
  206. data="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
  207. while IFS= read -r line; do
  208. case "$line" in
  209. *'"domain_id"'*'"domain"'*)
  210. zone_id="$(printf "%s" "$line" | _hostup_json_extract "domain_id")"
  211. zone_domain="$(printf "%s" "$line" | _hostup_json_extract "domain")"
  212. if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then
  213. HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id}
  214. "
  215. _debug "hostup_zone_loaded" "$zone_domain|$zone_id"
  216. fi
  217. ;;
  218. esac
  219. done <<EOF
  220. $data
  221. EOF
  222. if [ -z "$HOSTUP_ZONES_CACHE" ]; then
  223. _err "HostUp DNS API: no zones returned for the current API key."
  224. return 1
  225. fi
  226. return 0
  227. }
  228. _hostup_lookup_zone() {
  229. lookup_domain="$1"
  230. _lookup_zone_id=""
  231. _lookup_zone_domain=""
  232. while IFS='|' read -r domain zone_id; do
  233. [ -z "$domain" ] && continue
  234. if [ "$domain" = "$lookup_domain" ]; then
  235. _lookup_zone_domain="$domain"
  236. _lookup_zone_id="$zone_id"
  237. HOSTUP_ZONE_DOMAIN="$domain"
  238. HOSTUP_ZONE_ID="$zone_id"
  239. return 0
  240. fi
  241. done <<EOF
  242. $HOSTUP_ZONES_CACHE
  243. EOF
  244. return 1
  245. }
  246. _hostup_find_record() {
  247. zone_id="$1"
  248. fqdn="$2"
  249. txtvalue="$3"
  250. if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
  251. return 1
  252. fi
  253. HOSTUP_RECORD_ID=""
  254. records="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
  255. while IFS= read -r line; do
  256. # Normalize line to make TXT value matching reliable
  257. line_clean="$(printf "%s" "$line" | tr -d '\r\n')"
  258. line_value_clean="$(printf "%s" "$line_clean" | sed 's/\\"//g')"
  259. case "$line_clean" in
  260. *'"type":"TXT"'*'"name"'*'"value"'*)
  261. name_value="$(_hostup_json_extract "name" "$line_clean")"
  262. record_value="$(_hostup_json_extract "value" "$line_value_clean")"
  263. _debug "hostup_record_raw" "$record_value"
  264. if [ "${record_value#\"}" != "$record_value" ] && [ "${record_value%\"}" != "$record_value" ]; then
  265. record_value="${record_value#\"}"
  266. record_value="${record_value%\"}"
  267. fi
  268. if [ "${record_value#\'}" != "$record_value" ] && [ "${record_value%\'}" != "$record_value" ]; then
  269. record_value="${record_value#\'}"
  270. record_value="${record_value%\'}"
  271. fi
  272. record_value="$(printf "%s" "$record_value" | tr -d '\r\n')"
  273. _debug "hostup_record_value" "$record_value"
  274. if [ "$name_value" = "$fqdn" ] && [ "$record_value" = "$txtvalue" ]; then
  275. record_id="$(_hostup_json_extract "id" "$line_clean")"
  276. if [ -n "$record_id" ]; then
  277. HOSTUP_RECORD_ID="$record_id"
  278. return 0
  279. fi
  280. fi
  281. ;;
  282. esac
  283. done <<EOF
  284. $records
  285. EOF
  286. return 1
  287. }
  288. _hostup_json_extract() {
  289. key="$1"
  290. input="${2:-$line}"
  291. # First try to extract quoted values (strings)
  292. quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\":\"[^\"]*\"" | _head_n 1)"
  293. if [ -n "$quoted_match" ]; then
  294. printf "%s" "$quoted_match" |
  295. cut -d : -f2- |
  296. sed 's/^"//' |
  297. sed 's/"$//' |
  298. sed 's/\\"/"/g'
  299. return 0
  300. fi
  301. # Fallback for unquoted values (e.g., numeric IDs)
  302. unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\":[^,}]*" | _head_n 1)"
  303. if [ -n "$unquoted_match" ]; then
  304. printf "%s" "$unquoted_match" |
  305. cut -d : -f2- |
  306. tr -d '", ' |
  307. tr -d '\r\n'
  308. return 0
  309. fi
  310. return 1
  311. }
  312. _hostup_json_escape() {
  313. printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
  314. }
  315. _hostup_record_key() {
  316. zone_id="$1"
  317. domain="$2"
  318. safe_zone="$(printf "%s" "$zone_id" | sed 's/[^A-Za-z0-9]/_/g')"
  319. safe_domain="$(printf "%s" "$domain" | _lower_case | sed 's/[^a-z0-9]/_/g')"
  320. printf "%s_%s" "$safe_zone" "$safe_domain"
  321. }
  322. _hostup_save_record_id() {
  323. zone_id="$1"
  324. domain="$2"
  325. record_id="$3"
  326. key="$(_hostup_record_key "$zone_id" "$domain")"
  327. _saveaccountconf_mutable "HOSTUP_RECORD_$key" "$record_id"
  328. }
  329. _hostup_get_saved_record_id() {
  330. zone_id="$1"
  331. domain="$2"
  332. key="$(_hostup_record_key "$zone_id" "$domain")"
  333. _readaccountconf_mutable "HOSTUP_RECORD_$key"
  334. }
  335. _hostup_clear_record_id() {
  336. zone_id="$1"
  337. domain="$2"
  338. key="$(_hostup_record_key "$zone_id" "$domain")"
  339. _clearaccountconf_mutable "HOSTUP_RECORD_$key"
  340. }
  341. _hostup_extract_record_id() {
  342. record_id="$(_hostup_json_extract "id" "$1")"
  343. if [ -n "$record_id" ]; then
  344. printf "%s" "$record_id"
  345. return 0
  346. fi
  347. printf "%s" "$1" | _egrep_o '"id":[0-9]+' | _head_n 1 | cut -d: -f2
  348. }
  349. _hostup_delete_record_by_id() {
  350. zone_id="$1"
  351. record_id="$2"
  352. if ! _hostup_rest "DELETE" "/dns/zones/$zone_id/records/$record_id" ""; then
  353. return 1
  354. fi
  355. if ! _contains "$_hostup_response" '"success":true'; then
  356. return 1
  357. fi
  358. return 0
  359. }
  360. _hostup_rest() {
  361. method="$1"
  362. route="$2"
  363. data="$3"
  364. _hostup_response=""
  365. export _H1="Authorization: Bearer $HOSTUP_API_KEY"
  366. export _H2="Content-Type: application/json"
  367. export _H3="Accept: application/json"
  368. if [ "$method" = "GET" ]; then
  369. _hostup_response="$(_get "$HOSTUP_API_BASE$route")"
  370. else
  371. _hostup_response="$(_post "$data" "$HOSTUP_API_BASE$route" "" "$method" "application/json")"
  372. fi
  373. ret="$?"
  374. unset _H1
  375. unset _H2
  376. unset _H3
  377. if [ "$ret" != "0" ]; then
  378. _err "HTTP request failed for $route"
  379. return 1
  380. fi
  381. http_status="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
  382. _debug2 "HTTP status" "$http_status"
  383. _debug2 "_hostup_response" "$_hostup_response"
  384. case "$http_status" in
  385. 200 | 201 | 204) return 0 ;;
  386. 401)
  387. _err "HostUp API returned 401 Unauthorized. Check HOSTUP_API_KEY scopes and IP restrictions."
  388. return 1
  389. ;;
  390. 403)
  391. _err "HostUp API returned 403 Forbidden. The API key lacks required DNS scopes."
  392. return 1
  393. ;;
  394. 404)
  395. _err "HostUp API returned 404 Not Found for $route"
  396. return 1
  397. ;;
  398. 429)
  399. _err "HostUp API rate limit exceeded. Please retry later."
  400. return 1
  401. ;;
  402. *)
  403. _err "HostUp API request failed with status $http_status"
  404. return 1
  405. ;;
  406. esac
  407. }