| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- #!/usr/bin/env bash
- # Railway custom domains on unacms.app (DNS hosted by Vercel).
- # Used by .github/workflows/ci.yml; every subcommand is idempotent.
- #
- # domains.sh railway-domain <host> <railway-environment> [service]
- # custom domain for a Railway service (default `una`) plus its DNS records;
- # `api-domain` is the old name
- # domains.sh dns-remove <host> delete the records railway-domain created for <host>
- # domains.sh client-alias <host> [branch] (Vercel client, before NEO moved to Railway) point <host> at
- # the neo-ci build of <branch>, or its production build
- # domains.sh client-unalias <host> remove such a Vercel alias
- #
- # Environment: VERCEL_TOKEN, RAILWAY_PROJECT_ID (api-domain), RAILWAY_API_TOKEN (railway CLI),
- # VERCEL_TEAM (team slug or id; found from DNS_ZONE ownership when unset),
- # DNS_ZONE (default unacms.app), CLIENT_PROJECT (Vercel project of the NEO client; when unset
- # or not found, the project that has DNS_ZONE attached).
- set -euo pipefail
- DNS_ZONE="${DNS_ZONE:-unacms.app}"
- CLIENT_PROJECT="${CLIENT_PROJECT:-}"
- RAILWAY_SERVICE="${RAILWAY_SERVICE:-una}"
- # Apache listens on Railway's $PORT (entrypoint.sh), which Railway sets to 8080.
- RAILWAY_TARGET_PORT="${RAILWAY_TARGET_PORT:-8080}"
- VERCEL_API=https://api.vercel.com
- die() { echo "domains.sh: $*" >&2; exit 1; }
- [ -n "${VERCEL_TOKEN:-}" ] || die "VERCEL_TOKEN is not set"
- vercel() { # vercel <METHOD> <path> [json-body]
- local method=$1 path=$2 body=${3:-}
- local sep='?'; case "$path" in *\?*) sep='&' ;; esac
- local url="$VERCEL_API$path${TEAM_ID:+${sep}teamId=$TEAM_ID}${TEAM_SLUG:+${sep}slug=$TEAM_SLUG}"
- local out code
- out=$(mktemp)
- code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" -H "Authorization: Bearer $VERCEL_TOKEN" \
- ${body:+-H 'Content-Type: application/json' -d "$body"} "$url") || code=000
- if [ "${code:0:1}" != 2 ]; then
- # Vercel explains 403s (token scope, team access) in the body; show it.
- echo "vercel: $method ${path%%\?*} -> HTTP $code: $(jq -r '.error.message // .error.code // empty' "$out" 2>/dev/null)" >&2
- rm -f "$out"
- return 1
- fi
- cat "$out"
- rm -f "$out"
- }
- # The Vercel scope that owns DNS_ZONE (it also owns the client project): VERCEL_TEAM when
- # that works, else the token's personal account, else any team the token can list.
- # Logs which scope it used, and Vercel's reason when none works (usually token scope).
- TEAM_ID=""
- TEAM_SLUG=""
- owns_zone() { vercel GET "/v5/domains/$DNS_ZONE" >/dev/null; }
- resolve_team() {
- local team
- if [ -n "${VERCEL_TEAM:-}" ]; then
- case "$VERCEL_TEAM" in team_*) TEAM_ID=$VERCEL_TEAM ;; *) TEAM_SLUG=$VERCEL_TEAM ;; esac
- if owns_zone 2>/dev/null; then echo "vercel: scope $VERCEL_TEAM" >&2; return; fi
- echo "vercel: $DNS_ZONE is not in scope $VERCEL_TEAM, trying the token's other scopes" >&2
- TEAM_ID=""; TEAM_SLUG=""
- fi
- if owns_zone 2>/dev/null; then echo "vercel: personal scope" >&2; return; fi
- for team in $(vercel GET /v2/teams 2>/dev/null | jq -r '.teams[].id'); do
- TEAM_ID=$team
- if owns_zone 2>/dev/null; then echo "vercel: scope $team" >&2; return; fi
- done
- TEAM_ID=""
- owns_zone || true # print Vercel's reason
- die "VERCEL_TOKEN has no scope that owns $DNS_ZONE (check the token's scope in Vercel)"
- }
- label_of() { # api-pr-12.unacms.app -> api-pr-12; the zone apex -> ""
- local host=$1
- [ "$host" != "$DNS_ZONE" ] || { echo ""; return; }
- [ "${host%."$DNS_ZONE"}" != "$host" ] || die "$host is not in $DNS_ZONE"
- echo "${host%."$DNS_ZONE"}"
- }
- # All records of DNS_ZONE as one JSON array (Vercel pages them; follow `pagination.next`).
- dns_records() {
- local page next="" all="[]"
- while :; do
- page=$(vercel GET "/v4/domains/$DNS_ZONE/records?limit=100${next:+&until=$next}")
- all=$(jq -c --argjson a "$all" '$a + .records' <<< "$page")
- next=$(jq -r '.pagination.next // empty' <<< "$page")
- [ -n "$next" ] || break
- done
- echo "$all"
- }
- # Values compare without case or a trailing dot ("Host.example." == "host.example").
- dns_has() { # dns_has <name> <type> <value>
- dns_records | jq -e --arg n "$1" --arg t "$2" --arg v "$3" \
- 'def norm: ascii_downcase | rtrimstr("."); any(.[]; .name == $n and .type == $t and (.value | norm) == ($v | norm))' >/dev/null
- }
- # Vercel's authoritative answer for <name>.DNS_ZONE: what resolvers will actually get.
- dns_served() { # dns_served <name> <type> <value>
- command -v dig >/dev/null || return 1
- local want got
- want=$(tr '[:upper:]' '[:lower:]' <<< "${3%.}")
- got=$(dig +short +norecurse "$2" "$1.$DNS_ZONE" @ns1.vercel-dns.com 2>/dev/null | tr -d '"' | sed 's/\.$//' | tr '[:upper:]' '[:lower:]')
- grep -qxF "$want" <<< "$got"
- }
- dns_upsert() { # dns_upsert <name> <type> <value>
- local name=$1 type=$2 value=$3 id val
- while read -r id val; do
- [ -n "$id" ] || continue
- if [ "$(tr '[:upper:]' '[:lower:]' <<< "${val%.}")" = "$(tr '[:upper:]' '[:lower:]' <<< "${value%.}")" ]; then echo "dns: $type $name ok"; return; fi
- vercel DELETE "/v2/domains/$DNS_ZONE/records/$id" >/dev/null
- done < <(dns_records | jq -r --arg n "$name" --arg t "$type" '.[] | select(.name == $n and .type == $t) | "\(.id) \(.value)"')
- vercel POST "/v2/domains/$DNS_ZONE/records" \
- "$(jq -nc --arg n "$name" --arg t "$type" --arg v "$value" '{name: $n, type: $t, value: $v, ttl: 60}')" >/dev/null
- # Read it back: a record that silently didn't stick left previews without an API host.
- # Vercel's record list can lag the write, so its nameserver's answer counts too.
- local try
- for try in $(seq 1 10); do
- if dns_has "$name" "$type" "$value" || dns_served "$name" "$type" "$value"; then
- echo "dns: $type $name -> $value"; return
- fi
- sleep 3
- done
- echo "dns: records named $name in $DNS_ZONE:" >&2
- dns_records | jq -c --arg n "$name" '.[] | select(.name == $n) | {id, type, value}' >&2
- die "dns: $type $name was not found after writing it"
- }
- dns_remove() { # dns_remove <name> (also its _railway-verify TXT)
- local name=$1 id
- while read -r id; do
- [ -n "$id" ] || continue
- vercel DELETE "/v2/domains/$DNS_ZONE/records/$id" >/dev/null && echo "dns: removed record $id"
- done < <(dns_records | jq -r --arg n "$name" --arg v "_railway-verify.$name" '.[] | select(.name == $n or .name == $v) | .id')
- }
- railway_domain() { # railway_domain <host> <environment> [service]
- local host=$1 env=$2 label id status
- RAILWAY_SERVICE=${3:-$RAILWAY_SERVICE}
- label=$(label_of "$host")
- [ -n "${RAILWAY_PROJECT_ID:-}" ] || die "RAILWAY_PROJECT_ID is not set"
- railway domain "$host" --service "$RAILWAY_SERVICE" --environment "$env" --port "$RAILWAY_TARGET_PORT" --json >/dev/null 2>&1 || true
- local domains env_id
- domains=$(railway domain list --service "$RAILWAY_SERVICE" --environment "$env" --json)
- id=$(jq -r --arg h "$host" '.domains[] | select(.type == "custom" and .domain == $h) | .id' <<< "$domains")
- [ -n "$id" ] || die "Railway did not create $host"
- # Domains created earlier pointed at port 80 and answered 502; keep them on the app's port.
- if [ "$(jq -r --arg h "$host" '.domains[] | select(.domain == $h) | .targetPort' <<< "$domains")" != "$RAILWAY_TARGET_PORT" ]; then
- env_id=$(railway status --json | jq -r --arg e "$env" '.environments.edges[].node | select(.name == $e) | .id')
- railway api 'mutation($id: String!, $environmentId: String!, $port: Int) { customDomainUpdate(id: $id, environmentId: $environmentId, targetPort: $port) }' \
- --variables "$(jq -nc --arg id "$id" --arg e "$env_id" --argjson p "$RAILWAY_TARGET_PORT" '{id: $id, environmentId: $e, port: $p}')" >/dev/null
- echo "railway: $host now targets port $RAILWAY_TARGET_PORT"
- fi
- status=$(railway api 'query($id: String!, $projectId: String!) { customDomain(id: $id, projectId: $projectId) { status { verificationDnsHost verificationToken dnsRecords { hostlabel recordType requiredValue } } } }' \
- --variables "$(jq -nc --arg id "$id" --arg p "$RAILWAY_PROJECT_ID" '{id: $id, projectId: $p}')")
- # Not a pipeline: dns_upsert must run in this shell so a failure fails the job.
- local name type value
- # `|`-separated: the apex's host label can be empty, and `read` would drop an empty
- # whitespace-separated field.
- while IFS='|' read -r name type value; do
- [ -n "$type" ] || continue
- # The apex can't be a CNAME; Vercel DNS flattens an ALIAS record instead.
- if [ -z "$label" ] && { [ -z "$name" ] || [ "$name" = "@" ]; }; then
- dns_upsert "" ALIAS "$value"
- else
- dns_upsert "$name" "$type" "$value"
- fi
- done < <(jq -r '.data.customDomain.status.dnsRecords[] | "\(.hostlabel // "")|\(.recordType | sub("DNS_RECORD_TYPE_"; ""))|\(.requiredValue)"' <<< "$status")
- local vhost vtoken
- vhost=$(jq -r '.data.customDomain.status.verificationDnsHost // empty' <<< "$status")
- vtoken=$(jq -r '.data.customDomain.status.verificationToken // empty' <<< "$status")
- [ -z "$vhost" ] || dns_upsert "$vhost" TXT "$vtoken"
- echo "railway: https://$host -> $RAILWAY_SERVICE in $env"
- }
- # The client project: CLIENT_PROJECT when set, else the project that serves DNS_ZONE
- # (by attached domain, so a renamed or similarly named project can't be picked by mistake).
- client_project_id() {
- local id p
- if [ -n "$CLIENT_PROJECT" ]; then
- id=$(vercel GET "/v9/projects/$CLIENT_PROJECT" | jq -r '.id // empty')
- [ -n "$id" ] || die "Vercel project $CLIENT_PROJECT not found"
- echo "$id"; return
- fi
- for p in $(vercel GET "/v9/projects?limit=100" | jq -r '.projects[].id'); do
- 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
- echo "client: project $(vercel GET "/v9/projects/$p" | jq -r .name) serves $DNS_ZONE" >&2
- echo "$p"; return
- fi
- done
- die "no Vercel project serves $DNS_ZONE"
- }
- client_alias() { # client_alias <host> [branch]
- local host=$1 branch=${2:-} project uid=""
- project=$(client_project_id)
- if [ -n "$branch" ]; then
- uid=$(vercel GET "/v6/deployments?projectId=$project&state=READY&limit=100" \
- | jq -r --arg b "$branch" '[.deployments[] | select(.meta.githubCommitRef == $b)] | sort_by(.created) | last | .uid // empty')
- fi
- if [ -n "$uid" ]; then
- echo "client: branch $branch"
- else
- uid=$(vercel GET "/v6/deployments?projectId=$project&target=production&state=READY&limit=1" | jq -r '.deployments[0].uid // empty')
- [ -n "$uid" ] || die "no ready production deployment of the client project"
- echo "client: production"
- fi
- vercel POST "/v2/deployments/$uid/aliases" "$(jq -nc --arg a "$host" '{alias: $a}')" >/dev/null
- echo "client: https://$host"
- }
- client_unalias() {
- vercel DELETE "/v2/aliases/$1" >/dev/null 2>&1 && echo "client: removed $1" || echo "client: $1 had no alias"
- }
- resolve_team
- case "${1:-}" in
- railway-domain|api-domain) railway_domain "${2:?host}" "${3:?environment}" "${4:-}" ;;
- dns-remove) dns_remove "$(label_of "${2:?host}")" ;;
- client-alias) client_alias "${2:?host}" "${3:-}" ;;
- client-unalias) client_unalias "${2:?host}" ;;
- *) die "usage: domains.sh railway-domain|dns-remove|client-alias|client-unalias ..." ;;
- esac
|