domains.sh 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. #!/usr/bin/env bash
  2. # Railway custom domains on unacms.app (DNS hosted by Vercel).
  3. # Used by .github/workflows/ci.yml; every subcommand is idempotent.
  4. #
  5. # domains.sh railway-domain <host> <railway-environment> [service]
  6. # custom domain for a Railway service (default `una`) plus its DNS records;
  7. # `api-domain` is the old name
  8. # domains.sh dns-remove <host> delete the records railway-domain created for <host>
  9. # domains.sh client-alias <host> [branch] (Vercel client, before NEO moved to Railway) point <host> at
  10. # the neo-ci build of <branch>, or its production build
  11. # domains.sh client-unalias <host> remove such a Vercel alias
  12. #
  13. # Environment: VERCEL_TOKEN, RAILWAY_PROJECT_ID (api-domain), RAILWAY_API_TOKEN (railway CLI),
  14. # VERCEL_TEAM (team slug or id; found from DNS_ZONE ownership when unset),
  15. # DNS_ZONE (default unacms.app), CLIENT_PROJECT (Vercel project of the NEO client; when unset
  16. # or not found, the project that has DNS_ZONE attached).
  17. set -euo pipefail
  18. DNS_ZONE="${DNS_ZONE:-unacms.app}"
  19. CLIENT_PROJECT="${CLIENT_PROJECT:-}"
  20. RAILWAY_SERVICE="${RAILWAY_SERVICE:-una}"
  21. # Apache listens on Railway's $PORT (entrypoint.sh), which Railway sets to 8080.
  22. RAILWAY_TARGET_PORT="${RAILWAY_TARGET_PORT:-8080}"
  23. VERCEL_API=https://api.vercel.com
  24. die() { echo "domains.sh: $*" >&2; exit 1; }
  25. [ -n "${VERCEL_TOKEN:-}" ] || die "VERCEL_TOKEN is not set"
  26. vercel() { # vercel <METHOD> <path> [json-body]
  27. local method=$1 path=$2 body=${3:-}
  28. local sep='?'; case "$path" in *\?*) sep='&' ;; esac
  29. local url="$VERCEL_API$path${TEAM_ID:+${sep}teamId=$TEAM_ID}${TEAM_SLUG:+${sep}slug=$TEAM_SLUG}"
  30. local out code
  31. out=$(mktemp)
  32. code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" -H "Authorization: Bearer $VERCEL_TOKEN" \
  33. ${body:+-H 'Content-Type: application/json' -d "$body"} "$url") || code=000
  34. if [ "${code:0:1}" != 2 ]; then
  35. # Vercel explains 403s (token scope, team access) in the body; show it.
  36. echo "vercel: $method ${path%%\?*} -> HTTP $code: $(jq -r '.error.message // .error.code // empty' "$out" 2>/dev/null)" >&2
  37. rm -f "$out"
  38. return 1
  39. fi
  40. cat "$out"
  41. rm -f "$out"
  42. }
  43. # The Vercel scope that owns DNS_ZONE (it also owns the client project): VERCEL_TEAM when
  44. # that works, else the token's personal account, else any team the token can list.
  45. # Logs which scope it used, and Vercel's reason when none works (usually token scope).
  46. TEAM_ID=""
  47. TEAM_SLUG=""
  48. owns_zone() { vercel GET "/v5/domains/$DNS_ZONE" >/dev/null; }
  49. resolve_team() {
  50. local team
  51. if [ -n "${VERCEL_TEAM:-}" ]; then
  52. case "$VERCEL_TEAM" in team_*) TEAM_ID=$VERCEL_TEAM ;; *) TEAM_SLUG=$VERCEL_TEAM ;; esac
  53. if owns_zone 2>/dev/null; then echo "vercel: scope $VERCEL_TEAM" >&2; return; fi
  54. echo "vercel: $DNS_ZONE is not in scope $VERCEL_TEAM, trying the token's other scopes" >&2
  55. TEAM_ID=""; TEAM_SLUG=""
  56. fi
  57. if owns_zone 2>/dev/null; then echo "vercel: personal scope" >&2; return; fi
  58. for team in $(vercel GET /v2/teams 2>/dev/null | jq -r '.teams[].id'); do
  59. TEAM_ID=$team
  60. if owns_zone 2>/dev/null; then echo "vercel: scope $team" >&2; return; fi
  61. done
  62. TEAM_ID=""
  63. owns_zone || true # print Vercel's reason
  64. die "VERCEL_TOKEN has no scope that owns $DNS_ZONE (check the token's scope in Vercel)"
  65. }
  66. label_of() { # api-pr-12.unacms.app -> api-pr-12; the zone apex -> ""
  67. local host=$1
  68. [ "$host" != "$DNS_ZONE" ] || { echo ""; return; }
  69. [ "${host%."$DNS_ZONE"}" != "$host" ] || die "$host is not in $DNS_ZONE"
  70. echo "${host%."$DNS_ZONE"}"
  71. }
  72. # All records of DNS_ZONE as one JSON array (Vercel pages them; follow `pagination.next`).
  73. dns_records() {
  74. local page next="" all="[]"
  75. while :; do
  76. page=$(vercel GET "/v4/domains/$DNS_ZONE/records?limit=100${next:+&until=$next}")
  77. all=$(jq -c --argjson a "$all" '$a + .records' <<< "$page")
  78. next=$(jq -r '.pagination.next // empty' <<< "$page")
  79. [ -n "$next" ] || break
  80. done
  81. echo "$all"
  82. }
  83. # Values compare without case or a trailing dot ("Host.example." == "host.example").
  84. dns_has() { # dns_has <name> <type> <value>
  85. dns_records | jq -e --arg n "$1" --arg t "$2" --arg v "$3" \
  86. 'def norm: ascii_downcase | rtrimstr("."); any(.[]; .name == $n and .type == $t and (.value | norm) == ($v | norm))' >/dev/null
  87. }
  88. # Vercel's authoritative answer for <name>.DNS_ZONE: what resolvers will actually get.
  89. dns_served() { # dns_served <name> <type> <value>
  90. command -v dig >/dev/null || return 1
  91. local want got
  92. want=$(tr '[:upper:]' '[:lower:]' <<< "${3%.}")
  93. got=$(dig +short +norecurse "$2" "$1.$DNS_ZONE" @ns1.vercel-dns.com 2>/dev/null | tr -d '"' | sed 's/\.$//' | tr '[:upper:]' '[:lower:]')
  94. grep -qxF "$want" <<< "$got"
  95. }
  96. dns_upsert() { # dns_upsert <name> <type> <value>
  97. local name=$1 type=$2 value=$3 id val
  98. while read -r id val; do
  99. [ -n "$id" ] || continue
  100. if [ "$(tr '[:upper:]' '[:lower:]' <<< "${val%.}")" = "$(tr '[:upper:]' '[:lower:]' <<< "${value%.}")" ]; then echo "dns: $type $name ok"; return; fi
  101. vercel DELETE "/v2/domains/$DNS_ZONE/records/$id" >/dev/null
  102. done < <(dns_records | jq -r --arg n "$name" --arg t "$type" '.[] | select(.name == $n and .type == $t) | "\(.id) \(.value)"')
  103. vercel POST "/v2/domains/$DNS_ZONE/records" \
  104. "$(jq -nc --arg n "$name" --arg t "$type" --arg v "$value" '{name: $n, type: $t, value: $v, ttl: 60}')" >/dev/null
  105. # Read it back: a record that silently didn't stick left previews without an API host.
  106. # Vercel's record list can lag the write, so its nameserver's answer counts too.
  107. local try
  108. for try in $(seq 1 10); do
  109. if dns_has "$name" "$type" "$value" || dns_served "$name" "$type" "$value"; then
  110. echo "dns: $type $name -> $value"; return
  111. fi
  112. sleep 3
  113. done
  114. echo "dns: records named $name in $DNS_ZONE:" >&2
  115. dns_records | jq -c --arg n "$name" '.[] | select(.name == $n) | {id, type, value}' >&2
  116. die "dns: $type $name was not found after writing it"
  117. }
  118. dns_remove() { # dns_remove <name> (also its _railway-verify TXT)
  119. local name=$1 id
  120. while read -r id; do
  121. [ -n "$id" ] || continue
  122. vercel DELETE "/v2/domains/$DNS_ZONE/records/$id" >/dev/null && echo "dns: removed record $id"
  123. done < <(dns_records | jq -r --arg n "$name" --arg v "_railway-verify.$name" '.[] | select(.name == $n or .name == $v) | .id')
  124. }
  125. railway_domain() { # railway_domain <host> <environment> [service]
  126. local host=$1 env=$2 label id status
  127. RAILWAY_SERVICE=${3:-$RAILWAY_SERVICE}
  128. label=$(label_of "$host")
  129. [ -n "${RAILWAY_PROJECT_ID:-}" ] || die "RAILWAY_PROJECT_ID is not set"
  130. railway domain "$host" --service "$RAILWAY_SERVICE" --environment "$env" --port "$RAILWAY_TARGET_PORT" --json >/dev/null 2>&1 || true
  131. local domains env_id
  132. domains=$(railway domain list --service "$RAILWAY_SERVICE" --environment "$env" --json)
  133. id=$(jq -r --arg h "$host" '.domains[] | select(.type == "custom" and .domain == $h) | .id' <<< "$domains")
  134. [ -n "$id" ] || die "Railway did not create $host"
  135. # Domains created earlier pointed at port 80 and answered 502; keep them on the app's port.
  136. if [ "$(jq -r --arg h "$host" '.domains[] | select(.domain == $h) | .targetPort' <<< "$domains")" != "$RAILWAY_TARGET_PORT" ]; then
  137. env_id=$(railway status --json | jq -r --arg e "$env" '.environments.edges[].node | select(.name == $e) | .id')
  138. railway api 'mutation($id: String!, $environmentId: String!, $port: Int) { customDomainUpdate(id: $id, environmentId: $environmentId, targetPort: $port) }' \
  139. --variables "$(jq -nc --arg id "$id" --arg e "$env_id" --argjson p "$RAILWAY_TARGET_PORT" '{id: $id, environmentId: $e, port: $p}')" >/dev/null
  140. echo "railway: $host now targets port $RAILWAY_TARGET_PORT"
  141. fi
  142. status=$(railway api 'query($id: String!, $projectId: String!) { customDomain(id: $id, projectId: $projectId) { status { verificationDnsHost verificationToken dnsRecords { hostlabel recordType requiredValue } } } }' \
  143. --variables "$(jq -nc --arg id "$id" --arg p "$RAILWAY_PROJECT_ID" '{id: $id, projectId: $p}')")
  144. # Not a pipeline: dns_upsert must run in this shell so a failure fails the job.
  145. local name type value
  146. # `|`-separated: the apex's host label can be empty, and `read` would drop an empty
  147. # whitespace-separated field.
  148. while IFS='|' read -r name type value; do
  149. [ -n "$type" ] || continue
  150. # The apex can't be a CNAME; Vercel DNS flattens an ALIAS record instead.
  151. if [ -z "$label" ] && { [ -z "$name" ] || [ "$name" = "@" ]; }; then
  152. dns_upsert "" ALIAS "$value"
  153. else
  154. dns_upsert "$name" "$type" "$value"
  155. fi
  156. done < <(jq -r '.data.customDomain.status.dnsRecords[] | "\(.hostlabel // "")|\(.recordType | sub("DNS_RECORD_TYPE_"; ""))|\(.requiredValue)"' <<< "$status")
  157. local vhost vtoken
  158. vhost=$(jq -r '.data.customDomain.status.verificationDnsHost // empty' <<< "$status")
  159. vtoken=$(jq -r '.data.customDomain.status.verificationToken // empty' <<< "$status")
  160. [ -z "$vhost" ] || dns_upsert "$vhost" TXT "$vtoken"
  161. echo "railway: https://$host -> $RAILWAY_SERVICE in $env"
  162. }
  163. # The client project: CLIENT_PROJECT when set, else the project that serves DNS_ZONE
  164. # (by attached domain, so a renamed or similarly named project can't be picked by mistake).
  165. client_project_id() {
  166. local id p
  167. if [ -n "$CLIENT_PROJECT" ]; then
  168. id=$(vercel GET "/v9/projects/$CLIENT_PROJECT" | jq -r '.id // empty')
  169. [ -n "$id" ] || die "Vercel project $CLIENT_PROJECT not found"
  170. echo "$id"; return
  171. fi
  172. for p in $(vercel GET "/v9/projects?limit=100" | jq -r '.projects[].id'); do
  173. if vercel GET "/v9/projects/$p/domains?limit=100" | jq -e --arg z "$DNS_ZONE" '.domains[] | select(.name == $z or .name == ("www." + $z))' >/dev/null; then
  174. echo "client: project $(vercel GET "/v9/projects/$p" | jq -r .name) serves $DNS_ZONE" >&2
  175. echo "$p"; return
  176. fi
  177. done
  178. die "no Vercel project serves $DNS_ZONE"
  179. }
  180. client_alias() { # client_alias <host> [branch]
  181. local host=$1 branch=${2:-} project uid=""
  182. project=$(client_project_id)
  183. if [ -n "$branch" ]; then
  184. uid=$(vercel GET "/v6/deployments?projectId=$project&state=READY&limit=100" \
  185. | jq -r --arg b "$branch" '[.deployments[] | select(.meta.githubCommitRef == $b)] | sort_by(.created) | last | .uid // empty')
  186. fi
  187. if [ -n "$uid" ]; then
  188. echo "client: branch $branch"
  189. else
  190. uid=$(vercel GET "/v6/deployments?projectId=$project&target=production&state=READY&limit=1" | jq -r '.deployments[0].uid // empty')
  191. [ -n "$uid" ] || die "no ready production deployment of the client project"
  192. echo "client: production"
  193. fi
  194. vercel POST "/v2/deployments/$uid/aliases" "$(jq -nc --arg a "$host" '{alias: $a}')" >/dev/null
  195. echo "client: https://$host"
  196. }
  197. client_unalias() {
  198. vercel DELETE "/v2/aliases/$1" >/dev/null 2>&1 && echo "client: removed $1" || echo "client: $1 had no alias"
  199. }
  200. resolve_team
  201. case "${1:-}" in
  202. railway-domain|api-domain) railway_domain "${2:?host}" "${3:?environment}" "${4:-}" ;;
  203. dns-remove) dns_remove "$(label_of "${2:?host}")" ;;
  204. client-alias) client_alias "${2:?host}" "${3:-}" ;;
  205. client-unalias) client_unalias "${2:?host}" ;;
  206. *) die "usage: domains.sh railway-domain|dns-remove|client-alias|client-unalias ..." ;;
  207. esac