[object Object]

← back to Domain Sniper

initial scaffold — leak-minimal DoH checker + CertStream watcher

b738669095a5fae2a74ae31af884147cd9e66243 · 2026-05-12 13:07:05 -0700 · Steve Abrams

Step 0 (check.js): single-domain availability check via Cloudflare 1.1.1.1 DoH.
Queries NS + SOA only. Never touches WHOIS, RDAP, or registrar APIs — all
confirmed leak vectors after callr.app/callr.co/butlr.app got sniped.

Step 1 (watch-ct.js): live CertStream WebSocket listener with hot-pattern
filter and brand-typo defense (designerwallcoverings, philipperomano,
venturacorridor, etc.). Logs hits to data/ct-hits.jsonl.

Files touched

Diff

commit b738669095a5fae2a74ae31af884147cd9e66243
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 13:07:05 2026 -0700

    initial scaffold — leak-minimal DoH checker + CertStream watcher
    
    Step 0 (check.js): single-domain availability check via Cloudflare 1.1.1.1 DoH.
    Queries NS + SOA only. Never touches WHOIS, RDAP, or registrar APIs — all
    confirmed leak vectors after callr.app/callr.co/butlr.app got sniped.
    
    Step 1 (watch-ct.js): live CertStream WebSocket listener with hot-pattern
    filter and brand-typo defense (designerwallcoverings, philipperomano,
    venturacorridor, etc.). Logs hits to data/ct-hits.jsonl.
---
 .gitignore        |  11 +++++
 README.md         |  62 ++++++++++++++++++++++++
 check.js          |  75 +++++++++++++++++++++++++++++
 package-lock.json |  44 +++++++++++++++++
 package.json      |  24 ++++++++++
 watch-ct.js       | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 356 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f75534c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env
+.env.*
+!.env.example
+data/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+tmp/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e0d1968
--- /dev/null
+++ b/README.md
@@ -0,0 +1,62 @@
+# domain-sniper
+
+Steve's offensive counterpart to the aggregators that sniped callr.app, callr.co, and butlr.app. Watches public domain-intel feeds, surfaces candidates worth grabbing, registers them atomically before anyone else moves.
+
+> "I want to scrape just like they did to me." — Steve, 2026-05-12
+
+## What the aggregators are doing to us
+
+They monitor public registry data streams and front-run human searches:
+
+1. **Verisign / Google Registry WHOIS query logs** — every `whois -h whois.verisign-grs.com <domain>` query is logged and (per industry reports) resold to drop-catch partners. Same for Google's `whois.nic.google` for `.app`/`.dev`. When Steve queried `callr.app`, a partner saw the query stream within seconds and OVH/French aggregator beat him to registration.
+2. **Registrar availability-API partners** — GoDaddy, Namecheap, etc. expose `/v1/domains/available` endpoints whose query streams are visible to API partners.
+3. **Browser search-box telemetry** — typing into a registrar's homepage search box is the loudest possible signal.
+
+We can't get the WHOIS query stream (it's gated to aggregator partners), but we don't need it. There's enough public intel to compete.
+
+## Sniper data sources (all free, all public)
+
+| Source | What it tells us | Auth |
+| --- | --- | --- |
+| **Certificate Transparency logs** (`certstream.calidog.io`) | Every TLS cert issued in real-time. Tells us a domain was JUST registered + set up. | None — public WebSocket |
+| **Pool.com / DropCatch / SnapNames daily auction lists** | Domains pending deletion (5-day grace window before drop) | None — CSV download |
+| **ICANN CZDS zone files** (`czds.icann.org`) | Daily snapshot of every registered .com/.net/.app/.dev. Diff = drops + new. | Free application |
+| **crt.sh** | Historical CT search by keyword | None |
+| **DNS Census / passive DNS** | Historical NS/A records | Free tier |
+| **WhoisXMLAPI free tier** | 500 RDAP queries/mo for verification | API key |
+
+## Architecture (one step at a time)
+
+- [x] **Step 0** — leak-minimal availability checker (`check.js`, Cloudflare DoH, no WHOIS/RDAP) — built first so we can SAFELY verify a candidate before racing to register. Used as a utility inside the sniper.
+- [ ] **Step 1** — `watch-ct.js` — live CertStream WebSocket listener. Pipes every new cert through a filter and surfaces hits. Tells us what's getting registered RIGHT NOW.
+- [ ] **Step 2** — `watch-drops.js` — daily pull of pending-delete CSVs from public auction houses. Surfaces candidates dropping in the next 5 days.
+- [ ] **Step 3** — `brand-typo-watcher.js` — watches for typos of Steve's brands (designerwallcoverings, philipperomano, ventura-corridor, etc.) hitting the market, alerts immediately.
+- [ ] **Step 4** — `register.js` — atomic register via Porkbun direct API (skip availability check entirely; Porkbun returns success-or-taken). Bound to a hotkey for hand-typed candidates + auto-fired by filters above.
+- [ ] **Step 5** — `viewer/` — web dashboard listing live hits, with one-click register.
+
+## Filters (what makes a candidate "hot")
+
+The CT stream is a firehose (~50/sec). Filter aggressively. A domain is a candidate if:
+
+- Length ≤ 8 characters (excluding TLD)
+- TLD in {com, app, io, co, ai, dev}
+- Doesn't contain digits (usually)
+- Doesn't contain hyphens
+- Pronounceable (consonant-vowel pattern)
+- Optional: matches a Steve-supplied keyword list
+
+## Operational discipline
+
+1. **Never check via WHOIS/RDAP from Steve's IP.** Use `check.js` only — and only at the moment of decision, not exploratory.
+2. **Don't type candidates into registrar search boxes.** Direct API only.
+3. **Single source of truth** for "what we already grabbed" — `data/owned.json`. Always check this before pinging anything external.
+4. **Audit-log every external query** to `data/queries.log` so we can trace any future leak back to its source.
+
+## Usage (so far)
+
+```bash
+# Check a single candidate, leak-minimally
+node check.js callr.app
+```
+
+More commands land as each step ships.
diff --git a/check.js b/check.js
new file mode 100644
index 0000000..2cda34c
--- /dev/null
+++ b/check.js
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+// Leak-minimal single-domain availability check.
+// Method: Cloudflare 1.1.1.1 DoH (DNS-over-HTTPS), query type=NS.
+// Avoids: WHOIS, RDAP at registry, GoDaddy availability API, registrar search boxes —
+// all known leak vectors after 3 confirmed snipes (callr.app, callr.co, butlr.app).
+
+const https = require('https');
+
+const target = (process.argv[2] || '').trim().toLowerCase();
+if (!target || !target.includes('.')) {
+  console.error('usage: node check.js <domain>');
+  process.exit(2);
+}
+
+function doh(name, type = 'NS') {
+  return new Promise((resolve, reject) => {
+    const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`;
+    https
+      .get(url, { headers: { Accept: 'application/dns-json' } }, (res) => {
+        let data = '';
+        res.on('data', (c) => (data += c));
+        res.on('end', () => {
+          try {
+            resolve(JSON.parse(data));
+          } catch (e) {
+            reject(new Error(`bad DoH response: ${data.slice(0, 200)}`));
+          }
+        });
+      })
+      .on('error', reject);
+  });
+}
+
+(async () => {
+  // Two parallel queries — NS (does the TLD know about it) + SOA (does it have a zone of its own).
+  // Both via Cloudflare. We do NOT call WHOIS/RDAP under any circumstance.
+  const [ns, soa] = await Promise.all([doh(target, 'NS'), doh(target, 'SOA')]);
+
+  // DNS Status codes:
+  //   0 = NOERROR — TLD knows the name (registered, possibly parked)
+  //   2 = SERVFAIL — temporary, retry
+  //   3 = NXDOMAIN — TLD doesn't know the name (unregistered)
+  const nxNs = ns.Status === 3;
+  const nxSoa = soa.Status === 3;
+  const hasNs = !!(ns.Answer && ns.Answer.length);
+  const hasSoa = !!(soa.Answer && soa.Answer.length);
+
+  // Available if BOTH NS + SOA come back NXDOMAIN. Either NOERROR signals the TLD recognizes it.
+  const available = nxNs && nxSoa;
+
+  const result = {
+    domain: target,
+    available,
+    confidence: available ? 'high' : hasNs || hasSoa ? 'high' : 'medium',
+    signals: {
+      ns_status: ns.Status,
+      soa_status: soa.Status,
+      has_ns: hasNs,
+      has_soa: hasSoa,
+      ns_records: hasNs ? ns.Answer.map((a) => a.data) : [],
+    },
+    method: 'cloudflare-doh-ns-soa',
+    leak_minimal: true,
+    timestamp: new Date().toISOString(),
+    advice: available
+      ? 'AVAILABLE — register within minutes via API. Do not type into registrar search boxes.'
+      : 'TAKEN — TLD recognizes the name. Move on to the next candidate.',
+  };
+
+  console.log(JSON.stringify(result, null, 2));
+  process.exit(available ? 0 : 1);
+})().catch((err) => {
+  console.error(JSON.stringify({ error: err.message, domain: target }, null, 2));
+  process.exit(3);
+});
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..487f0b2
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,44 @@
+{
+  "name": "domain-sniper",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "domain-sniper",
+      "version": "0.1.0",
+      "license": "UNLICENSED",
+      "dependencies": {
+        "ws": "^8.20.1"
+      },
+      "bin": {
+        "check": "check.js",
+        "watch-ct": "watch-ct.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.20.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+      "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..02e4253
--- /dev/null
+++ b/package.json
@@ -0,0 +1,24 @@
+{
+  "name": "domain-sniper",
+  "version": "0.1.0",
+  "description": "Offensive domain-intel sniper. Watches public registry feeds, surfaces hot candidates, registers atomically.",
+  "private": true,
+  "type": "commonjs",
+  "bin": {
+    "check": "./check.js",
+    "watch-ct": "./watch-ct.js"
+  },
+  "scripts": {
+    "check": "node check.js",
+    "watch:ct": "node watch-ct.js",
+    "watch:drops": "node watch-drops.js"
+  },
+  "dependencies": {
+    "ws": "^8.20.1"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "author": "Steve Abrams",
+  "license": "UNLICENSED"
+}
diff --git a/watch-ct.js b/watch-ct.js
new file mode 100644
index 0000000..2b63615
--- /dev/null
+++ b/watch-ct.js
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+// Live Certificate Transparency watcher.
+// Source: wss://certstream.calidog.io/  — public WebSocket of every TLS cert issued.
+// Use cases:
+//   1. INTEL — see what kinds of names other people are registering right now (patterns to grab next)
+//   2. BRAND DEFENSE — alert if someone registers a typo of one of Steve's brands
+//   3. TIMING — measure the latency between WHOIS-check and cert-issue to calibrate aggregator behavior
+//
+// Reminder: certs = ALREADY REGISTERED domains. This is not a "drops" feed.
+// For actual snipe targets (pending-delete), see watch-drops.js (Step 2).
+
+const WebSocket = require('ws');
+const fs = require('fs');
+const path = require('path');
+
+const CERTSTREAM_URL = process.env.CERTSTREAM_URL || 'wss://certstream.calidog.io/';
+const DATA_DIR = path.join(__dirname, 'data');
+const HITS_LOG = path.join(DATA_DIR, 'ct-hits.jsonl');
+const STATS_INTERVAL_MS = 30_000;
+
+// Brand-defense keywords. Any new cert whose domain CONTAINS one of these substrings is a hit.
+// Edit freely — keep them lowercase.
+const BRAND_KEYWORDS = (process.env.BRAND_KEYWORDS || [
+  'designerwallcoverings',
+  'philipperomano',
+  'venturacorridor',
+  'venturaclaw',
+  'starsofdesign',
+  'bubbesblock',
+  'wholivedthere',
+  'agentabrams',
+  'nationalpaperhangers',
+  'lawyer-directory',
+  'novasuede',
+  'callr',
+  'butlr',
+  'flockedwallpaper',
+  'grasscloth',
+  'glassbeaded',
+].join(',')).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
+
+// "Hot pattern" filter — short, no digits, no hyphens, mainstream TLD.
+const HOT_TLDS = new Set(['com', 'app', 'io', 'co', 'ai', 'dev']);
+const MAX_STEM_LEN = 8;
+
+function looksHot(domain) {
+  if (!domain || domain.startsWith('*.')) return false;
+  const parts = domain.split('.');
+  if (parts.length < 2) return false;
+  const tld = parts[parts.length - 1].toLowerCase();
+  if (!HOT_TLDS.has(tld)) return false;
+  // For multi-label hosts (foo.bar.app), only consider the registrable label (= parts[-2]).
+  const stem = parts[parts.length - 2].toLowerCase();
+  if (stem.length > MAX_STEM_LEN || stem.length < 3) return false;
+  if (/[-0-9]/.test(stem)) return false;
+  // Pronounceability heuristic: must have both a vowel and a consonant.
+  if (!/[aeiou]/.test(stem)) return false;
+  if (!/[bcdfghjklmnpqrstvwxyz]/.test(stem)) return false;
+  return true;
+}
+
+function brandMatch(domain) {
+  const d = domain.toLowerCase().replace(/^\*\./, '');
+  return BRAND_KEYWORDS.find((kw) => d.includes(kw));
+}
+
+let count = { total: 0, hot: 0, brand: 0 };
+let buffer = [];
+
+function appendHit(rec) {
+  buffer.push(JSON.stringify(rec));
+  if (buffer.length >= 25) flushHits();
+}
+
+function flushHits() {
+  if (!buffer.length) return;
+  fs.mkdirSync(DATA_DIR, { recursive: true });
+  fs.appendFileSync(HITS_LOG, buffer.join('\n') + '\n');
+  buffer = [];
+}
+
+function ts() {
+  return new Date().toISOString();
+}
+
+let reconnectDelay = 1000;
+
+function connect() {
+  console.error(`[${ts()}] connecting to ${CERTSTREAM_URL}`);
+  const ws = new WebSocket(CERTSTREAM_URL, { handshakeTimeout: 15_000 });
+
+  ws.on('open', () => {
+    console.error(`[${ts()}] connected — watching for hot + brand hits`);
+    reconnectDelay = 1000;
+  });
+
+  ws.on('message', (raw) => {
+    let msg;
+    try { msg = JSON.parse(raw.toString()); } catch { return; }
+    if (msg.message_type !== 'certificate_update') return;
+    const domains = (msg.data && msg.data.leaf_cert && msg.data.leaf_cert.all_domains) || [];
+    const seenAt = ts();
+    for (const d of domains) {
+      count.total++;
+      const brand = brandMatch(d);
+      const hot = looksHot(d);
+      if (brand) {
+        count.brand++;
+        const line = { ts: seenAt, type: 'BRAND', domain: d, keyword: brand, cert_index: msg.data.cert_index };
+        console.log(`[BRAND] ${d}  (matches: ${brand})`);
+        appendHit(line);
+      } else if (hot) {
+        count.hot++;
+        const line = { ts: seenAt, type: 'HOT', domain: d, cert_index: msg.data.cert_index };
+        console.log(`[HOT]   ${d}`);
+        appendHit(line);
+      }
+    }
+  });
+
+  ws.on('close', (code) => {
+    console.error(`[${ts()}] connection closed (code ${code}). reconnecting in ${reconnectDelay}ms`);
+    setTimeout(connect, reconnectDelay);
+    reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
+  });
+
+  ws.on('error', (err) => {
+    console.error(`[${ts()}] ws error: ${err.message}`);
+  });
+}
+
+setInterval(() => {
+  console.error(`[${ts()}] seen=${count.total}  hot=${count.hot}  brand=${count.brand}`);
+  flushHits();
+}, STATS_INTERVAL_MS);
+
+process.on('SIGINT', () => { flushHits(); process.exit(0); });
+process.on('SIGTERM', () => { flushHits(); process.exit(0); });
+
+connect();

(oldest)  ·  back to Domain Sniper  ·  step 2: watch-outbound.js — HTTPS forward proxy to identify f13a4f9 →