···11+import asyncio
22+import re
33+from typing import Any
44+55+import httpx
66+from dns import asyncresolver
77+88+from src.tools.registry import TOOL_REGISTRY, ToolContext, ToolParameter
99+1010+_DOMAIN_REGEX = re.compile(r"^https?://")
1111+1212+1313+async def _check_http(domain: str) -> tuple[str | int, str | None]:
1414+ """check the http status and see if the domain redirects elsewhere"""
1515+ # give it a shot with https first
1616+ try:
1717+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
1818+ response = await client.head(f"https://{domain}")
1919+ redirects_to = response.headers.get("Location")
2020+ return response.status_code, redirects_to
2121+ except Exception:
2222+ pass
2323+2424+ # otherwise try http
2525+ try:
2626+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
2727+ response = await client.head(f"http://{domain}")
2828+ redirects_to = response.headers.get("Location")
2929+ return response.status_code, redirects_to
3030+ except Exception:
3131+ return "unreachable", None
3232+3333+3434+async def _query_dns(
3535+ resolver: asyncresolver.Resolver, domain: str, record_type: str
3636+) -> list[str] | str | None:
3737+ """query domains for a given domain and record type, with an input resolver"""
3838+ try:
3939+ answers = await resolver.resolve(domain, record_type)
4040+4141+ if record_type == "SOA":
4242+ # soa returns a single answer
4343+ return str(answers[0]) if answers else None
4444+ elif record_type == "MX":
4545+ # mx have priority
4646+ return [f"{answer.preference} {answer.exchange}" for answer in answers]
4747+ elif record_type == "TXT":
4848+ # txt have quotes
4949+ return [
5050+ " ".join(
5151+ str(s, "utf-8") if isinstance(s, bytes) else str(s)
5252+ for s in answer.strings
5353+ )
5454+ for answer in answers
5555+ ]
5656+ else:
5757+ return [str(answer) for answer in answers]
5858+ except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers):
5959+ return [] if record_type != "SOA" else None
6060+ except Exception:
6161+ return [] if record_type != "SOA" else None
6262+6363+6464+@TOOL_REGISTRY.tool(
6565+ name="clickhouse.query",
6666+ description="Lookup A, AAAA, NS, MX, TXT, CNAME, and SOA for a given input domain",
6767+ parameters=[
6868+ ToolParameter(
6969+ name="domain",
7070+ type="string",
7171+ description="The domain name (not a URL) to check",
7272+ ),
7373+ ],
7474+)
7575+async def check_domain(ctx: ToolContext, domain: str):
7676+ # defensive incase the model decides to stick a url in instead of a domain
7777+ re.sub(_DOMAIN_REGEX, "", domain).split("/")[0]
7878+7979+ try:
8080+ resolver = asyncresolver.Resolver()
8181+8282+ dns_tasks: dict[str, Any] = {
8383+ "a": _query_dns(resolver, domain, "A"),
8484+ "aaaa": _query_dns(resolver, domain, "AAAA"),
8585+ "ns": _query_dns(resolver, domain, "NS"),
8686+ "mx": _query_dns(resolver, domain, "MX"),
8787+ "txt": _query_dns(resolver, domain, "TXT"),
8888+ "cname": _query_dns(resolver, domain, "CNAME"),
8989+ "soa": _query_dns(resolver, domain, "SOA"),
9090+ }
9191+9292+ # run all of the lookups in parallel
9393+ dns_results = await asyncio.gather(*dns_tasks.values(), return_exceptions=True)
9494+ dns_data = dict(zip(dns_tasks.keys(), dns_results))
9595+9696+ a_records = (
9797+ dns_data.get("a", [])
9898+ if not isinstance(dns_data.get("a"), Exception)
9999+ else []
100100+ )
101101+ aaaa_records = (
102102+ dns_data.get("aaaa", [])
103103+ if not isinstance(dns_data.get("aaaa"), Exception)
104104+ else []
105105+ )
106106+ cname_records = (
107107+ dns_data.get("cname", [])
108108+ if not isinstance(dns_data.get("cname"), Exception)
109109+ else []
110110+ )
111111+112112+ http_status, redirects_to = await _check_http(domain)
113113+114114+ result: dict[str, Any] = {
115115+ "success": True,
116116+ "domain": domain,
117117+ "resolves": len(a_records) > 0
118118+ or len(aaaa_records) > 0
119119+ or len(cname_records) > 0,
120120+ "dns": {
121121+ "a": a_records,
122122+ "aaaa": aaaa_records,
123123+ "cname": cname_records,
124124+ "ns": dns_data.get("ns", [])
125125+ if not isinstance(dns_data.get("ns"), Exception)
126126+ else [],
127127+ "mx": dns_data.get("mx", [])
128128+ if not isinstance(dns_data.get("mx"), Exception)
129129+ else [],
130130+ "txt": dns_data.get("txt", [])
131131+ if not isinstance(dns_data.get("txt"), Exception)
132132+ else [],
133133+ "soa": dns_data.get("soa")
134134+ if not isinstance(dns_data.get("soa"), Exception)
135135+ else None,
136136+ },
137137+ "http_status": http_status,
138138+ "redirects_to": redirects_to,
139139+ }
140140+141141+ return result
142142+143143+ except Exception as e:
144144+ result = {"success": False, "domain": domain, "error": str(e)}
145145+ return result
+2-2
src/tools/deno/tools.ts
···55 /** Get Osprey/network table schema information including tables and their columns. Schema is for the table default.osprey_execution_results */
66 getSchema: (): Promise<unknown> => callTool("clickhouse.getSchema", {}),
7788- /** Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on default.osprey_execution_results. */
99- query: (sql: string): Promise<unknown> => callTool("clickhouse.query", { sql }),
88+ /** Lookup A, AAAA, NS, MX, TXT, CNAME, and SOA for a given input domain */
99+ query: (domain: string): Promise<unknown> => callTool("clickhouse.query", { domain }),
1010};
11111212export const osprey = {
+2
uv.lock
···603603 { name = "atproto" },
604604 { name = "click" },
605605 { name = "clickhouse-connect" },
606606+ { name = "dnspython" },
606607 { name = "pydantic" },
607608 { name = "pydantic-settings" },
608609]
···614615 { name = "atproto", specifier = ">=0.0.65" },
615616 { name = "click", specifier = ">=8.3.1" },
616617 { name = "clickhouse-connect", specifier = ">=0.10.0" },
618618+ { name = "dnspython", specifier = ">=2.8.0" },
617619 { name = "pydantic", specifier = ">=2.12.5" },
618620 { name = "pydantic-settings", specifier = ">=2.12.0" },
619621]