Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<code>`, 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
Expand All @@ -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`).
145 changes: 145 additions & 0 deletions bin/domainfree
Original file line number Diff line number Diff line change
@@ -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:<code>.

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