diff --git a/README.md b/README.md index 3ec6393..003d08a 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,54 @@ exit status is non-zero and the JSON carries an `error` key. | `--timeout MS` | per-query timeout for HTTP and dig, default 4000 | | `--name NAME` | alternative to the positional name argument | +### `domainfree` + +Bulk domain availability, straight from the registry. Prints only the names you +can actually buy, one per line, so it pipes into anything. + +```sh +domainfree sorrycheck.com sinkstate.com +domainfree -f candidates.txt +generate-names | domainfree --jobs 24 +domainfree --all example.com # show TAKEN rows too +``` + +Availability is read from **RDAP, never inferred from DNS**, because DNS cannot +tell registration apart from configuration: + +- A parked domain resolves fine and is taken. +- A registered domain with no nameservers returns `NXDOMAIN` — identical to a + name nobody owns. + +Checked against 8,513 generated candidates, the DNS shortcut (`dig NAME | grep +"ANSWER: 0"`) reported 20 registered domains as free while missing none that +were genuinely free. `oubliette.com` is the instructive one: registered in 1996, +paid through 2034, three nameservers, no `A` record — so `dig` says `ANSWER: 0` +and reads as available. Fine as a cheap prefilter, useless as a buy signal. + +Lookups run in parallel (16 by default; 8,500 names take about 45 seconds). +Anything indeterminate — a 429, a 5xx, a timeout — is retried once serially and +then reported as `ERR:`, and the exit status is `2` so an unknown is never +silently read as available. + +| Flag | Effect | +| --- | --- | +| `-f, --file FILE` | read names from FILE, one per line (`-` for stdin) | +| `-j, --jobs N` | parallel lookups, default 16 | +| `-t, --timeout S` | per-lookup timeout in seconds, default 20 | +| `-a, --all` | print every name as `STATUS domain`, not just the free ones | +| `-q, --quiet` | suppress the summary, which is written to stderr | + +The summary goes to stderr and the names to stdout, so `domainfree -f in.txt | +wc -l` counts what you can buy. For a deep look at one name rather than a +verdict across thousands, use `domainjson`. + +Single name, in the shape of the familiar `dig` one-liner: + +```sh +curl -sfL -o /dev/null https://rdap.verisign.com/com/v1/domain/NAME && echo "not available" +``` + ## `wrappers/` Snapshots of the launcher scripts that third-party installers drop into @@ -124,6 +172,6 @@ an installer is unavailable and you need the old contents back. ## Requirements -`gh` (authenticated), `jq`, and `awk`. `domainjson` additionally wants `node`, -`dig`, and the OpenRDAP CLI (`go install github.com/openrdap/rdap/cmd/rdap@latest`, +`gh` (authenticated), `jq`, and `awk`. `domainfree` needs only `curl`. +`domainjson` additionally wants `node`, `dig`, and the OpenRDAP CLI (`go install github.com/openrdap/rdap/cmd/rdap@latest`, run from `~/go/bin/rdap` or on `PATH`). diff --git a/bin/domainfree b/bin/domainfree new file mode 100755 index 0000000..d2c8547 --- /dev/null +++ b/bin/domainfree @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# domainfree — bulk domain availability, straight from the registry. +# +# Prints only names that are genuinely unregistered. Availability is read from +# RDAP, never inferred from DNS: a parked domain resolves but is taken, and a +# registered domain with no nameservers returns NXDOMAIN exactly like a free +# one. DNS cannot tell registration apart from configuration; the registry can. +# +# Companion to `domainjson`, which does a deep lookup of a single name. +# This one answers one question across thousands: can I buy it? + +set -uo pipefail + +JOBS=16 +TIMEOUT=20 +SHOW_ALL=0 +QUIET=0 +FILE="" + +usage() { + cat <<'EOF' +domainfree — bulk domain availability from RDAP + +USAGE + domainfree example.com another.dev ... + domainfree -f candidates.txt + generate-names | domainfree + +OPTIONS + -f, --file FILE read names from FILE, one per line ("-" for stdin) + -j, --jobs N parallel lookups, default 16 + -t, --timeout S per-lookup timeout in seconds, default 20 + -a, --all print every name with its status, not just the free ones + -q, --quiet suppress the trailing summary (on stderr) + -h, --help this text + +OUTPUT + By default, one available domain per line — pipe it straight into anything. + With --all, each line is "STATUS domain" where STATUS is AVAILABLE, TAKEN, + or ERR:. + +EXIT + 0 ran to completion (even if nothing was available) + 1 usage error + 2 one or more lookups failed and their status is unknown + +NOTES + A single name, in the shape of the familiar dig one-liner: + + curl -sfL -o /dev/null https://rdap.verisign.com/com/v1/domain/NAME && echo taken + + Rate limits are retried once, serially, before being reported as ERR. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + -f|--file) FILE="${2:-}"; shift 2 ;; + -j|--jobs) JOBS="${2:-16}"; shift 2 ;; + -t|--timeout) TIMEOUT="${2:-20}"; shift 2 ;; + -a|--all) SHOW_ALL=1; shift ;; + -q|--quiet) QUIET=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "domainfree: unknown option $1" >&2; usage >&2; exit 1 ;; + *) break ;; + esac +done + +command -v curl >/dev/null || { echo "domainfree: curl is required" >&2; exit 1; } + +# ---------------------------------------------------------------- input +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +NAMES="$TMP/names" + +if [ -n "$FILE" ]; then + if [ "$FILE" = "-" ]; then cat; else cat "$FILE"; fi +elif [ $# -gt 0 ]; then + printf '%s\n' "$@" +elif [ ! -t 0 ]; then + cat +else + usage >&2 + exit 1 +fi | tr 'A-Z' 'a-z' | sed 's/[[:space:]]//g' | grep -E '^[a-z0-9.-]+\.[a-z]{2,}$' | sort -u > "$NAMES" + +TOTAL=$(wc -l < "$NAMES" | tr -d ' ') +[ "$TOTAL" -eq 0 ] && { echo "domainfree: no valid names given" >&2; exit 1; } + +# ------------------------------------------------------------- lookup +# Exported so the xargs subshell can call it. +lookup() { + d="$1" + tld="${d##*.}" + case "$tld" in + com|net) url="https://rdap.verisign.com/$tld/v1/domain/$d" ;; + org) url="https://rdap.publicinterestregistry.org/rdap/domain/$d" ;; + *) url="https://rdap.org/domain/$d" ;; + esac + code=$(curl -sL -o /dev/null -w '%{http_code}' --max-time "$DF_TIMEOUT" "$url" 2>/dev/null) + case "$code" in + 404) echo "AVAILABLE $d" ;; # no registration record exists + 200) echo "TAKEN $d" ;; # registered: parked, dark or live, all taken + *) echo "ERR:$code $d" ;; # 429, 5xx, timeout — status unknown + esac +} +export -f lookup +export DF_TIMEOUT="$TIMEOUT" + +RESULTS="$TMP/results" +xargs -a "$NAMES" -P "$JOBS" -I{} bash -c 'lookup "$@"' _ {} > "$RESULTS" + +# Retry anything indeterminate once, serially — rate limiting is the usual cause +# and it clears at low concurrency. +RETRY="$TMP/retry" +grep '^ERR:' "$RESULTS" | awk '{print $2}' > "$RETRY" || true +if [ -s "$RETRY" ]; then + grep -v '^ERR:' "$RESULTS" > "$TMP/keep" || true + while read -r d; do + sleep 0.3 + lookup "$d" + done < "$RETRY" >> "$TMP/keep" + mv "$TMP/keep" "$RESULTS" +fi + +# ------------------------------------------------------------- output +if [ "$SHOW_ALL" -eq 1 ]; then + sort -k1,1 -k2,2 "$RESULTS" +else + awk '$1 == "AVAILABLE" { print $2 }' "$RESULTS" | sort +fi + +AVAIL=$(grep -c '^AVAILABLE ' "$RESULTS" || true) +TAKEN=$(grep -c '^TAKEN ' "$RESULTS" || true) +UNKNOWN=$(grep -c '^ERR:' "$RESULTS" || true) + +if [ "$QUIET" -eq 0 ]; then + printf '%s checked · %s available · %s taken' "$TOTAL" "$AVAIL" "$TAKEN" >&2 + [ "$UNKNOWN" -gt 0 ] && printf ' · %s unknown' "$UNKNOWN" >&2 + printf '\n' >&2 +fi + +[ "$UNKNOWN" -gt 0 ] && exit 2 +exit 0