Files
2026-09-15 15:24:03 -04:00

1124 lines
48 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Networking and IT tools.
Two kinds live here. `subnet_calc` and `network_reference` are pure
computation against local data - no network, no rate limit, and they
cannot be wrong. The other four reach out and touch a host the user named,
so they carry a per-user hourly budget (see limits.py). squid is what makes
them safe; the budget is what makes them polite.
"""
from __future__ import annotations
import csv
import datetime
import ipaddress
import logging
import re
import ssl
from functools import lru_cache
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import Field
from starlette.concurrency import run_in_threadpool
from .. import cache
from ..httpc import FetchError, get_client, get_json, tls_socket
from ..limits import Identity, identity, spend_probe
from ..security import UnsafeURL, require_auth, validate_hostname, validate_url
from ..toolbase import ToolModel, tool
log = logging.getLogger(__name__)
router = APIRouter(prefix="/tools", tags=["network"], dependencies=[Depends(require_auth)])
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
# --------------------------------------------------------------- subnet_calc
class SubnetRequest(ToolModel):
network: str = Field(
description="CIDR, e.g. '192.168.1.0/24' or '2001:db8::/32'. A bare address is one host; "
"'address mask' form ('192.168.1.0 255.255.255.0') also works.",
)
split_into: int | None = Field(
default=None,
description="Divide the network into this many equal subnets (rounded up to a power of "
"two) and list them.",
)
split_prefix: int | None = Field(
default=None,
description="Carve the network into subnets of this prefix length, e.g. 56 to divide a /48 "
"into /56s. Use with `nth` to pick one without listing them all.",
)
nth: int | None = Field(
default=None,
description="Which subnet of the split to return, counting from 1 - 'the 4th /56 of this "
"/48'. Never work these out by hand: IPv6 prefix boundaries fall inside a "
"hextet.",
)
_SPECIAL_BLOCKS = (
("2001:db8::/32", "documentation only (RFC 3849) - not routable, and not a ULA"),
("fc00::/7", "unique local address, ULA (RFC 4193)"),
("192.0.2.0/24", "documentation only (RFC 5737, TEST-NET-1)"),
("198.51.100.0/24", "documentation only (RFC 5737, TEST-NET-2)"),
("203.0.113.0/24", "documentation only (RFC 5737, TEST-NET-3)"),
("100.64.0.0/10", "carrier-grade NAT shared address space (RFC 6598)"),
)
# The same fact as a sentence. Three graded rounds of one prompt showed the
# model reading "Scope: documentation only (RFC 5737)" as a field to skip
# and the advisory paragraph beside it as something to reproduce clause for
# clause; a fact the answer should carry has to be shaped like the prose it
# does carry.
_DOCUMENTATION_SENTENCE = (
"This prefix is reserved for documentation and examples ({rfc}): it is never allocated, "
"never announced on the public Internet, and hosts do not route to it. If it stands in "
"for a real prefix in a question, the arithmetic is the same for the real one - say so, "
"and answer for the real prefix."
)
_RESERVED_KINDS = (
("10.0.0.0/8", "private address space (RFC 1918)"),
("172.16.0.0/12", "private address space (RFC 1918)"),
("192.168.0.0/16", "private address space (RFC 1918)"),
("127.0.0.0/8", "the loopback range (RFC 1122)"),
("169.254.0.0/16", "link-local address space (RFC 3927)"),
("0.0.0.0/8", "\"this network\" (RFC 1122), not a host address"),
("224.0.0.0/4", "multicast address space (RFC 5771)"),
("240.0.0.0/4", "reserved for future use (RFC 1112)"),
("::1/128", "the IPv6 loopback address"),
("fe80::/10", "IPv6 link-local address space (RFC 4291)"),
("ff00::/8", "IPv6 multicast address space (RFC 4291)"),
)
def _describe_reserved(ip) -> tuple[str, str]:
"""(block, description) for an address that has no public registration."""
for cidr, label in _SPECIAL_BLOCKS + _RESERVED_KINDS:
block = ipaddress.ip_network(cidr)
if block.version == ip.version and ip in block:
return cidr, label
return "a reserved range", "reserved or special-purpose address space (IANA special-purpose registry)"
def _special_use(net) -> str | None:
"""Name the specific reserved block rather than lumping it under "private".
`is_private` is true for the documentation prefixes, so 2001:db8::/32 was
being reported as "private (RFC 1918 / ULA)" - wrong on both counts, and
misleading in the direction that matters: it is not assignable at all.
"""
for cidr, label in _SPECIAL_BLOCKS:
block = ipaddress.ip_network(cidr)
if block.version == net.version and net.subnet_of(block):
return label
return None
def _slaac_note(version: int, prefixlen: int, *, splitting: bool) -> str | None:
"""Warn when a prefix longer than /64 is described or produced.
Asked to carve a /64 into four VLANs, this tool returned eight /67s with
perfect arithmetic and no comment, while the same output carried a
"Scope: documentation only (RFC 3849)" line. The answer that followed
tracked the tool one for one - it repeated the documentation caveat the
tool flagged, said nothing about the /64 boundary the tool did not, and
recommended SLAAC on /67s. The advisory surface of this tool is what
decides which facts reach the answer, so the boundary belongs here rather
than in a prompt.
IPv4 has no such boundary, and /64 or shorter is exactly what SLAAC wants,
so neither is flagged. A single /128 being *described* is a host address or
a host route rather than a subnet, so it is not flagged either; a split
that produces /128s is.
"""
if version != 6 or prefixlen <= 64:
return None
if splitting is False and prefixlen == 128:
return None
subject = "The arithmetic below is correct" if splitting else "The details above are correct"
return (
f"Note: a /{prefixlen} is longer than /64. SLAAC needs a 64-bit interface identifier "
"(RFC 4291 section 2.5.1; RFC 7421 explains why the boundary is fixed), so hosts on a "
f"/{prefixlen} cannot autoconfigure an address - they need DHCPv6 or static addressing, "
"and some stacks (notably Android) ship no DHCPv6 client at all. "
f"{subject}; whether to subnet past /64 is a design decision. Where the constraint is "
"\"I only have one /64\", the usual answer is to ask the provider for a /56 or /48 by "
"DHCPv6-PD rather than to split the /64."
)
def _reverse_zone(net) -> str:
"""Reverse DNS zone for a *prefix*, not for its first address.
`reverse_pointer` on a network address gives the PTR name of that single
address. For 2001:db8:4f2:30b::/64 that is all 32 nibbles, sixteen of which
are the host part and belong to records *inside* the zone rather than to the
zone name. Delegation happens on the prefix, so the name has to be truncated
to it. IPv4 had the same bug and hid it better: a /24 returned
0.10.168.192.in-addr.arpa, which is the PTR of 192.168.1.0 rather than the
zone 10.168.192.in-addr.arpa - one label too many, and easy to read past.
Only prefixes on a nibble (v6) or octet (v4) boundary name a single zone;
anything else is delegated with RFC 2317 style CNAMEs, so say so rather than
inventing a zone that cannot exist.
"""
full = net.network_address.reverse_pointer
step = 4 if net.version == 6 else 8
suffix = "nibble" if net.version == 6 else "octet"
article = "a" if net.version == 6 else "an"
if net.prefixlen % step:
return (f"{full} [that is the PTR name of the network address itself. A "
f"/{net.prefixlen} is not on {article} {suffix} boundary, so it has no single "
"reverse zone - delegation needs RFC 2317 style CNAMEs.]")
labels = full.split(".")
keep = net.prefixlen // step
return ".".join(labels[len(labels) - 2 - keep:])
@router.post("/subnet_calc", operation_id="subnet_calc", response_model=str,
summary="Calculate subnet details")
@tool
async def subnet_calc(body: SubnetRequest) -> str:
"""Subnet details for IPv4 or IPv6: network, broadcast, usable range, mask, host count, splits.
Use it for every subnetting question rather than doing the arithmetic yourself -
off-by-one errors in usable ranges and broadcast addresses land in real firewall rules
and DHCP scopes. It also splits a network into equal subnets, which is what "divide
this /24 into N networks" wants.
"""
raw = body.network.strip()
# "192.168.1.0 255.255.255.0" is how a lot of gear prints it.
if " " in raw and "/" not in raw:
addr, _, mask = raw.partition(" ")
raw = f"{addr.strip()}/{mask.strip()}"
try:
net = ipaddress.ip_network(raw, strict=False)
except ValueError as exc:
return f"CANNOT DO THAT: '{body.network}' is not a valid network or address ({exc})."
given = None
if "/" not in body.network or net.prefixlen == net.max_prefixlen:
try:
given = ipaddress.ip_interface(raw)
except ValueError:
given = None
lines = [f"# {net.with_prefixlen}", ""]
if net.version == 4:
total = net.num_addresses
usable = max(total - 2, 0) if net.prefixlen < 31 else total
lines += [
f"Network address: {net.network_address}",
f"Broadcast: {net.broadcast_address}",
f"Netmask: {net.netmask} (/{net.prefixlen})",
f"Wildcard mask: {net.hostmask}",
f"Total addresses: {total:,}",
f"Usable hosts: {usable:,}",
]
if usable and net.prefixlen < 31:
# Deliberately arithmetic rather than list(net.hosts()): that
# materialises every address, and a /8 is 16.7 million of them.
# It OOM-killed the container the first time this was exercised.
first = ipaddress.ip_address(int(net.network_address) + 1)
last = ipaddress.ip_address(int(net.broadcast_address) - 1)
lines.append(f"Usable range: {first} - {last}")
elif net.prefixlen == 31:
lines.append(f"Usable range: {net.network_address} - {net.broadcast_address} (RFC 3021 point-to-point)")
else:
lines += [
f"Network address: {net.network_address}",
f"Prefix length: /{net.prefixlen}",
f"Total addresses: {net.num_addresses:,}",
f"Address range: {net.network_address} - {net[-1]}",
]
scope = []
if special := _special_use(net):
scope.append(special)
elif net.is_private:
scope.append("private (RFC 1918 / ULA)")
if net.is_global:
scope.append("globally routable")
if net.is_loopback:
scope.append("loopback")
if net.is_link_local:
scope.append("link-local")
if net.is_multicast:
scope.append("multicast")
lines.append(f"Scope: {', '.join(scope) or 'reserved/special-use'}")
if special and "documentation" in special:
rfc = re.search(r"RFC \d+", special)
lines += ["", _DOCUMENTATION_SENTENCE.format(rfc=rfc.group(0) if rfc else "documentation range")]
if note := _slaac_note(net.version, net.prefixlen, splitting=False):
lines += ["", note]
if given is not None and given.ip != net.network_address:
lines += ["", f"The address {given.ip} sits inside this network."]
try:
lines.append(f"Reverse DNS zone: {_reverse_zone(net)}")
except Exception:
pass
if body.split_prefix is not None:
want = body.split_prefix
if want <= net.prefixlen or want > net.max_prefixlen:
lines += ["", f"Cannot carve a /{net.prefixlen} into /{want} subnets - the target "
f"prefix must be longer than /{net.prefixlen} and no longer than "
f"/{net.max_prefixlen}."]
else:
bits = want - net.prefixlen
count = 2 ** bits
lines += ["", f"## Carved into /{want} subnets"]
if note := _slaac_note(net.version, want, splitting=True):
lines += [note, ""]
lines.append(f"A /{net.prefixlen} contains 2^({want}-{net.prefixlen}) = 2^{bits} = "
f"**{count:,}** subnets of /{want}.")
if body.nth is not None:
n = body.nth
if n < 1 or n > count:
lines.append(f"There is no {n}th /{want} here - there are only {count:,}.")
else:
sub = ipaddress.ip_network(
(int(net.network_address) + (n - 1) * (2 ** (net.max_prefixlen - want)), want))
lines += [
"",
f"**#{n} (counting from 1) = {sub.with_prefixlen}**",
f"First address: {sub.network_address}",
f"Last address: {sub[-1]}",
f"Reverse zone: {_reverse_zone(sub)}",
"",
f"Index {n} is offset {n - 1} from the base, and each /{want} step is "
f"2^({net.max_prefixlen}-{want}) = 2^{net.max_prefixlen - want} addresses. "
f"If you meant zero-based numbering, #{n} zero-based is "
f"{ipaddress.ip_network((int(net.network_address) + n * (2 ** (net.max_prefixlen - want)), want)).with_prefixlen if n < count else 'past the end'}"
" - say which convention you used.",
]
else:
shown = [x for x, _ in zip(net.subnets(new_prefix=want), range(16))]
lines.append("")
for i, sub in enumerate(shown, 1):
lines.append(f"- #{i}: {sub.with_prefixlen}")
if count > 16:
lines.append(f"- ... and {count - 16:,} more. Pass nth=N to get one directly "
"rather than counting down this list.")
elif body.split_into and body.split_into > 1:
import math
bits = math.ceil(math.log2(body.split_into))
new_prefix = net.prefixlen + bits
if new_prefix > net.max_prefixlen:
lines += ["", f"Cannot split into {body.split_into} subnets - that would need a "
f"/{new_prefix}, which is longer than /{net.max_prefixlen}."]
else:
count = 2 ** bits
generator = net.subnets(prefixlen_diff=bits)
shown = [s for s, _ in zip(generator, range(32))]
lines += ["", f"## Split into {count:,} × /{new_prefix} subnets"]
if note := _slaac_note(net.version, new_prefix, splitting=True):
lines += [note, ""]
lines += [f"(you asked for {body.split_into}; subnetting works in powers of two)", ""]
for sub in shown:
if sub.version == 4 and sub.prefixlen < 31:
first = ipaddress.ip_address(int(sub.network_address) + 1)
last = ipaddress.ip_address(int(sub.broadcast_address) - 1)
lines.append(f"- {sub.with_prefixlen} hosts {first} - {last} "
f"broadcast {sub.broadcast_address}")
else:
lines.append(f"- {sub.with_prefixlen}")
if count > 32:
lines.append(f"- … and {count - 32:,} more")
return "\n".join(lines)
# ---------------------------------------------------------------- dns_lookup
DNS_TYPES = Literal["A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA", "CAA", "SRV", "PTR"]
class DnsRequest(ToolModel):
name: str = Field(description="Domain name, e.g. 'example.com'. Public names only.")
record_type: DNS_TYPES = Field(
default="A",
description="Default A. A/AAAA=addresses, MX=mail servers, TXT=SPF/DKIM/verification, "
"NS=nameservers, CNAME=aliases, SOA=zone authority, CAA=certificate authority "
"authorisation, SRV=service records, PTR=reverse lookup.",
)
@router.post("/dns_lookup", operation_id="dns_lookup", response_model=str,
summary="Look up DNS records")
@tool
async def dns_lookup(body: DnsRequest, who: Identity = Depends(identity)) -> str:
"""Query live DNS records for a public domain name.
Use it to see what a domain resolves to, diagnose mail delivery (MX, SPF/DMARC in
TXT), confirm delegation, or check that a change has propagated. It queries the public
internet from this server and cannot see the user's local or split-horizon DNS - say
so when relevant.
"""
await spend_probe(who, "dns_lookup")
name = validate_hostname(body.name)
rtype = body.record_type
if hit := await cache.cached("dns", name, rtype):
return hit
payload = None
for endpoint, params in (
("https://dns.google/resolve", {"name": name, "type": rtype}),
("https://cloudflare-dns.com/dns-query", {"name": name, "type": rtype}),
):
try:
payload = await get_json(endpoint, params=params,
headers={"Accept": "application/dns-json"})
if payload:
break
except Exception as exc:
log.info("DoH resolver %s failed: %s", endpoint, exc)
if payload is None:
raise FetchError("No DNS resolver responded. Do not guess what the records contain.")
status = payload.get("Status", -1)
if status == 3:
return (f"NXDOMAIN — the domain '{name}' does not exist in public DNS. "
"Do not speculate about what it might resolve to.")
if status != 0:
return f"DNS query for {name} failed with status code {status}."
answers = payload.get("Answer") or []
records = [a for a in answers if _dns_type_name(a.get("type")) == rtype]
lines = [f"# DNS {rtype} records for {name}", ""]
if not records:
other = {_dns_type_name(a.get("type")) for a in answers}
if other:
lines.append(f"No {rtype} records exist. The name does resolve, but only has: "
f"{', '.join(sorted(t for t in other if t))}.")
else:
lines.append(f"No {rtype} records exist for this name.")
else:
for record in records:
ttl = record.get("TTL")
value = (record.get("data") or "").strip()
lines.append(f"- {value} (TTL {ttl}s)")
if authority := payload.get("Authority"):
soa = next((a.get("data", "") for a in authority if _dns_type_name(a.get("type")) == "SOA"), "")
if soa:
lines += ["", f"Authority: {soa}"]
lines += ["", "[Resolved from the public internet, not from the user's local network.]"]
result = "\n".join(lines)
await cache.store("dns", result, name, rtype)
return result
_DNS_TYPE_MAP = {1: "A", 2: "NS", 5: "CNAME", 6: "SOA", 12: "PTR", 15: "MX",
16: "TXT", 28: "AAAA", 33: "SRV", 257: "CAA"}
def _dns_type_name(code) -> str:
return _DNS_TYPE_MAP.get(code, str(code))
# --------------------------------------------------------------- inspect_tls
class TlsRequest(ToolModel):
hostname: str = Field(description="Hostname, e.g. 'example.com', without https://.")
port: int = Field(default=443, ge=1, le=65535,
description="TLS port, default 443.")
@router.post("/inspect_tls", operation_id="inspect_tls", response_model=str,
summary="Inspect a TLS certificate")
@tool
async def inspect_tls(body: TlsRequest, who: Identity = Depends(identity)) -> str:
"""Examine the live TLS certificate a public host serves.
Reports expiry, issuer, covered hostnames, chain validity, and the negotiated TLS
version and cipher. Use it for "is my certificate about to expire", "why does my
browser complain", "did my renewal deploy". Expired and self-signed certificates are
reported, not refused.
"""
await spend_probe(who, "inspect_tls")
host = validate_hostname(body.hostname)
port = body.port
if port not in (443, 80):
# squid's CONNECT allowlist would refuse anything else anyway; saying
# so plainly beats a confusing tunnel error.
return (f"CANNOT DO THAT: only port 443 can be inspected from this server (you asked "
f"for {port}). The egress proxy does not permit other ports.")
if hit := await cache.cached("tls", host, str(port)):
return hit
result = await run_in_threadpool(_inspect_tls_sync, host, port)
await cache.store("tls", result, host, str(port))
return result
def _inspect_tls_sync(host: str, port: int) -> str:
from cryptography import x509
from cryptography.hazmat.primitives import hashes
trusted = True
trust_error = ""
try:
wrapped, raw_sock = tls_socket(host, port, verify=True)
except ssl.SSLCertVerificationError as exc:
trusted = False
trust_error = exc.verify_message or str(exc)
wrapped, raw_sock = tls_socket(host, port, verify=False)
try:
der = wrapped.getpeercert(binary_form=True)
version = wrapped.version()
cipher = wrapped.cipher()
finally:
try:
wrapped.close()
finally:
raw_sock.close()
if not der:
raise FetchError(f"{host} did not present a certificate.")
cert = x509.load_der_x509_certificate(der)
now = datetime.datetime.now(datetime.UTC)
not_after = cert.not_valid_after_utc
not_before = cert.not_valid_before_utc
days_left = (not_after - now).days
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
names = san.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
names = []
lines = [f"# TLS certificate for {host}:{port}", ""]
if days_left < 0:
lines.append(f"**EXPIRED {abs(days_left)} days ago.**")
elif days_left <= 14:
lines.append(f"**EXPIRES IN {days_left} DAYS — renewal is urgent.**")
elif days_left <= 30:
lines.append(f"**Expires in {days_left} days.**")
else:
lines.append(f"Valid for another {days_left} days.")
if not trusted:
lines.append(f"**Chain does NOT validate:** {trust_error}. A browser will warn on this.")
else:
lines.append("Certificate chain validates against the public trust store.")
lines += [
"",
f"Subject: {cert.subject.rfc4514_string()}",
f"Issuer: {cert.issuer.rfc4514_string()}",
f"Valid from: {not_before:%Y-%m-%d %H:%M UTC}",
f"Valid until: {not_after:%Y-%m-%d %H:%M UTC}",
f"Serial: {cert.serial_number:x}",
f"Signature: {cert.signature_algorithm_oid._name}",
f"SHA-256: {cert.fingerprint(hashes.SHA256()).hex(':')}",
"",
f"Negotiated: {version}, cipher {cipher[0] if cipher else 'unknown'}",
]
if names:
shown = ", ".join(names[:25])
more = f" (+{len(names) - 25} more)" if len(names) > 25 else ""
lines += ["", f"Covers {len(names)} name(s): {shown}{more}"]
return "\n".join(lines)
# -------------------------------------------------------------- http_headers
class HeadersRequest(ToolModel):
url: str = Field(description="Full URL, e.g. https://example.com/path.")
_SECURITY_HEADERS = {
"strict-transport-security": "HSTS",
"content-security-policy": "CSP",
"x-frame-options": "clickjacking protection",
"x-content-type-options": "MIME sniffing protection",
"referrer-policy": "referrer policy",
"permissions-policy": "permissions policy",
}
def _hsts_detail(value: str) -> str:
"""Break an HSTS header into the three things people ask about.
The question is almost always "what is the max-age, and are
includeSubDomains and preload set" - and `max-age=0`, which switches HSTS
off, used to render identically to a two-year preloaded policy because the
tool only reported that the header existed.
Directives are matched as whole tokens between semicolons, case-insensitively.
A substring search for "preload" or "includesubdomains" also matches those
words inside another directive's value (a report URI, for instance), which
would report a policy the server never set.
"""
tokens = [token.strip() for token in value.split(";") if token.strip()]
names = {token.partition("=")[0].strip().lower() for token in tokens}
max_age: int | None = None
for token in tokens:
name, sep, raw = token.partition("=")
if name.strip().lower() == "max-age" and sep:
try:
max_age = int(raw.strip().strip('"'))
except ValueError:
max_age = None
break
if max_age is None:
age = "max-age: absent or unparseable - without a max-age the header sets no policy"
elif max_age == 0:
age = "max-age=0 - HSTS is switched off here, which tells browsers to forget the policy"
elif max_age < 3600:
age = f"max-age={max_age} (~{max_age // 60} minutes)"
elif max_age < 86400:
age = f"max-age={max_age} (~{max_age / 3600:.0f} hours)"
else:
age = f"max-age={max_age} (~{max_age / 86400:.0f} days)"
subdomains = "includesubdomains" in names
detail = (f"{age}; includeSubDomains: {'yes' if subdomains else 'no'}; "
f"preload: {'yes' if 'preload' in names else 'no'}")
if "preload" in names and (max_age is None or max_age < 31536000 or not subdomains):
detail += (". The preload directive is set but the policy does not meet the preload "
"list's own requirements (max-age of at least 31536000 plus includeSubDomains), "
"so a submission to hstspreload.org would be rejected as it stands")
return detail
def _clip(value: str, limit: int) -> str:
return value if len(value) <= limit else f"{value[:limit]}… [{len(value)} chars total]"
def _security_section(response) -> list[str]:
"""Report each security header's value, not merely that it exists.
Asked for a site's HSTS max-age, this tool answered "Present: HSTS, CSP,
clickjacking protection" - a membership test, `header in response.headers`,
that never emitted the value; none of these six appear in the raw-header
dump either, so the value the model had already fetched was nowhere in the
output. It noticed, said so, and spent five more tool calls chasing it.
The tool follows redirects and only the final response's headers were ever
read. Apex and www are configured separately in the real world and do
differ: cloudflare.com sends max-age=15780000, www.cloudflare.com sends
max-age=31536000. The final response is the primary answer; an earlier hop
that disagrees is named alongside it rather than discarded.
"""
present: list[str] = []
missing: list[str] = []
notes: list[str] = []
for header, label in _SECURITY_HEADERS.items():
value = response.headers.get(header)
if value is None:
# "referrer-policy (referrer policy)" says nothing twice.
missing.append(header if label.lower() == header.replace("-", " ")
else f"{header} ({label})")
else:
present.append(f"{header}: {_clip(value, 300)}")
if header == "strict-transport-security":
present.append(f" {_hsts_detail(value)}")
# One note per distinct earlier value, naming the host that sent it.
earlier: dict[str, object] = {}
for hop in response.history:
hop_value = hop.headers.get(header)
if hop_value is not None and hop_value != value:
earlier.setdefault(hop_value, hop.url)
for other, hop_url in earlier.items():
hop_host = hop_url.host
if value is None:
notes.append(
f"Note: {hop_host} sends {header} ({_clip(other, 120)}) but the final "
f"response from {response.url.host} does not send it at all - they are "
"configured separately; report whichever the question is about, or both.")
elif hop_host != response.url.host:
notes.append(
f"Note: {header} differs between {hop_host} ({_clip(other, 120)}) and "
f"{response.url.host} ({_clip(value, 120)}) - the apex and www are "
"configured separately; report whichever the question is about, or both.")
else:
notes.append(
f"Note: {header} differs between the earlier hop {hop_url} "
f"({_clip(other, 120)}) and the final response {response.url} "
f"({_clip(value, 120)}) on the same host.")
if response.history and response.history[0].url.scheme != response.url.scheme:
notes.append(
f"Note: the scheme changed while following redirects - the request began at "
f"{response.history[0].url} and these headers are {response.url}'s. The "
"plain-http address itself answers with a redirect only, and an HSTS policy sent "
"over http is ignored by browsers (RFC 6797 section 8.1), so this is a statement "
"about the https address.")
lines = ["", f"## Security headers (final response: {response.url})"]
if present:
lines += present
else:
lines.append("None of the six security headers is present on the final response.")
lines.append(f"Missing: {', '.join(missing) if missing else 'none'}")
return lines + notes
@router.post("/http_headers", operation_id="http_headers", response_model=str,
summary="Inspect HTTP response headers")
@tool
async def http_headers(body: HeadersRequest, who: Identity = Depends(identity)) -> str:
"""A URL's HTTP status, redirect chain and response headers, without reading the page.
Use it to diagnose redirects and loops, check caching and CORS, see which server or
CDN answers, or read a site's security headers - each value is reported, with HSTS
broken into max-age, includeSubDomains and preload. For the page's content use
fetch_page.
"""
await spend_probe(who, "http_headers")
url = validate_url(body.url)
client = get_client()
response = await client.get(url, follow_redirects=True)
lines = [f"# {url}", ""]
if response.history:
lines.append("## Redirect chain")
for hop in response.history:
lines.append(f"{hop.status_code} {hop.url}\n -> {hop.headers.get('location', '')}")
lines += [f"{response.status_code} {response.url} (final)", ""]
else:
lines += [f"Status: {response.status_code} {response.reason_phrase}", ""]
interesting = [
"server", "content-type", "content-length", "content-encoding", "cache-control",
"expires", "etag", "last-modified", "location", "set-cookie", "x-powered-by",
"cf-ray", "via", "age", "vary", "access-control-allow-origin",
]
lines.append("## Response headers")
for key in interesting:
if value := response.headers.get(key):
lines.append(f"{key}: {value[:200]}")
lines += _security_section(response)
return "\n".join(lines)
# --------------------------------------------------------- lookup_ip_or_asn
class RdapRequest(ToolModel):
target: str = Field(
description="A public IP address (8.8.8.8), an AS number (AS15169 or 15169), or a domain "
"name.",
)
@router.post("/lookup_ip_or_asn", operation_id="lookup_ip_or_asn", response_model=str,
summary="Look up IP, ASN or domain registration")
@tool
async def lookup_ip_or_asn(body: RdapRequest, who: Identity = Depends(identity)) -> str:
"""Who a public IP address, AS number or domain is registered to, via RDAP.
The replacement for whois: the owner of an address seen in logs, an abuse contact,
which provider or cloud an address belongs to, a domain's registration and expiry
dates. Private addresses have no registration.
"""
await spend_probe(who, "lookup_ip_or_asn")
target = body.target.strip()
if not target:
return "CANNOT DO THAT: provide an IP address, AS number or domain name."
if hit := await cache.cached("rdap", target.lower()):
return hit
asn_match = re.fullmatch(r"(?:AS)?(\d{1,10})", target, re.I)
try:
ip = ipaddress.ip_address(target)
except ValueError:
ip = None
if ip is not None:
if not ip.is_global:
# "Private, loopback or reserved" was true and useless: asked
# about two TEST-NET addresses, the model knew from memory that
# they were documentation ranges, got this line back, and
# talked itself out of saying so. Name the block.
block, label = _describe_reserved(ip)
sentence = ""
if "documentation" in label:
rfc = re.search(r"RFC \d+", label)
sentence = " " + _DOCUMENTATION_SENTENCE.format(rfc=rfc.group(0) if rfc else "documentation range")
return (f"CANNOT DO THAT: {ip} is in {block}, {label}. It has no public registration "
f"and no origin AS to look up.{sentence}"
+ ("" if sentence else " It belongs to whoever runs that local network."))
url, kind = f"https://rdap.org/ip/{ip}", "IP address"
elif asn_match:
url, kind = f"https://rdap.org/autnum/{asn_match.group(1)}", "AS number"
else:
host = validate_hostname(target)
# RDAP registers the registrable domain, not arbitrary subdomains.
parts = host.split(".")
domain = ".".join(parts[-2:]) if len(parts) > 2 else host
url, kind = f"https://rdap.org/domain/{domain}", "domain"
routing = ""
if kind == "IP address":
routing = await _routing_view(str(ip))
elif kind == "AS number":
routing = await _as_view(asn_match.group(1))
try:
data = await get_json(url)
except Exception:
if routing:
result = (f"# {target} ({kind})\n\nNo RDAP registration record was returned for it - "
"some registries do not publish RDAP for every resource, so registration "
f"details are unknown; do not invent them.\n\n{routing}")
await cache.store("rdap", result, target.lower())
return result
return (f"NO RESULTS. No RDAP registration found for '{target}'. Some registries do not "
"publish RDAP for every resource. Do not invent ownership details.")
result = _format_rdap(target, kind, data)
if routing:
result += "\n\n" + routing
await cache.store("rdap", result, target.lower())
return result
# Registration is not routing. RDAP says who an address block is allocated
# to; which network actually announces it, and under which AS number, is a
# BGP question that RDAP cannot answer - and it is the question "which AS
# does this site sit in" asks. Two graded rounds of that prompt cost four
# extra fetches each for the origin AS, from whichever third-party page the
# model could find; RIPEstat publishes it from the collectors' routing view.
_RIPESTAT = "https://stat.ripe.net/data"
async def _routing_view(ip: str) -> str:
url = f"{_RIPESTAT}/prefix-overview/data.json?resource={ip}"
try:
payload = await get_json(url, timeout=12.0)
data = payload.get("data") or {}
except Exception:
return ("## Routing (BGP)\nOrigin AS: not available - RIPEstat did not answer. Do not "
"supply one from memory.")
lines = ["## Routing (BGP)"]
if not data.get("announced"):
lines.append(f"{ip} is not announced in BGP - no origin AS; the address is not reachable "
"on the public Internet right now (or is inside a block that is only "
"registered, not routed).")
else:
asns = data.get("asns") or []
prefix = data.get("resource") or "?"
origins = ", ".join(f"AS{a.get('asn')} ({a.get('holder') or 'holder unknown'})" for a in asns)
lines.append(f"Announced prefix: {prefix}")
lines.append(f"Origin AS: {origins or 'unknown'}")
if len(asns) > 1:
lines.append("More than one origin: the prefix is announced from several networks "
"(anycast or a multi-origin arrangement) - report all of them.")
lines.append(f"From RIPEstat's routing view (RIS collectors), {url}")
return "\n".join(lines)
async def _as_view(asn: str) -> str:
url = f"{_RIPESTAT}/as-overview/data.json?resource=AS{asn}"
try:
payload = await get_json(url, timeout=12.0)
data = payload.get("data") or {}
except Exception:
return ""
holder = data.get("holder") or "unknown"
announced = "announces prefixes" if data.get("announced") else "announces no prefixes"
return (f"## Routing (BGP)\nAS{asn} holder: {holder}; {announced} in the current routing "
f"view.\nFrom RIPEstat, {url}")
def _format_rdap(target: str, kind: str, data: dict) -> str:
lines = [f"# RDAP registration for {target} ({kind})", ""]
if name := data.get("name"):
lines.append(f"Name: {name}")
if handle := data.get("handle"):
lines.append(f"Handle: {handle}")
if start := data.get("startAddress"):
lines.append(f"Range: {start} - {data.get('endAddress', '?')}")
if cidrs := data.get("cidr0_cidrs"):
rendered = ", ".join(
f"{c.get('v4prefix') or c.get('v6prefix')}/{c.get('length')}" for c in cidrs[:4]
)
lines.append(f"CIDR: {rendered}")
if country := data.get("country"):
lines.append(f"Country: {country}")
if atype := data.get("type"):
lines.append(f"Type: {atype}")
for event in data.get("events") or []:
action = event.get("eventAction", "")
when = (event.get("eventDate") or "")[:10]
if action in ("registration", "last changed", "expiration"):
lines.append(f"{action.title():10} {when}")
if statuses := data.get("status"):
lines.append(f"Status: {', '.join(statuses[:6])}")
contacts: list[str] = []
for entity in data.get("entities") or []:
roles = ", ".join(entity.get("roles") or [])
name, email = _vcard(entity.get("vcardArray"))
if name or email:
contacts.append(f"- {roles or 'contact'}: {name}{f' <{email}>' if email else ''}")
if contacts:
lines += ["", "## Contacts"] + contacts[:6]
if nameservers := data.get("nameservers"):
lines += ["", "## Nameservers"] + [f"- {ns.get('ldhName', '')}" for ns in nameservers[:8]]
return "\n".join(lines)
def _vcard(vcard) -> tuple[str, str]:
name = email = ""
if not isinstance(vcard, list) or len(vcard) < 2:
return name, email
for entry in vcard[1]:
if not isinstance(entry, list) or len(entry) < 4:
continue
if entry[0] == "fn":
name = str(entry[3])
elif entry[0] == "email":
email = str(entry[3])
return name, email
# ---------------------------------------------------- network_reference (offline)
class NetworkRefRequest(ToolModel):
lookup_type: Literal["mac_vendor", "port", "http_status"] = Field(
description="mac_vendor=manufacturer of a MAC address; port=service registered on a "
"TCP/UDP port; http_status=meaning of an HTTP status code.",
)
value: str = Field(
description="A MAC address ('00:1A:2B:3C:4D:5E'), a port number ('443'), or an HTTP status "
"code ('418').",
)
@router.post("/network_reference", operation_id="network_reference", response_model=str,
summary="Look up MAC vendor, port or HTTP status")
@tool
async def network_reference(body: NetworkRefRequest) -> str:
"""Look up a MAC address vendor, a TCP/UDP port's registered service, or an HTTP status code.
Answers from the IEEE OUI and IANA port registries held locally. Use it rather than
recalling port numbers or OUI prefixes.
"""
value = body.value.strip()
if not value:
return "CANNOT DO THAT: provide a value to look up."
if body.lookup_type == "mac_vendor":
return _lookup_mac(value)
if body.lookup_type == "port":
return _lookup_port(value)
return _lookup_http_status(value)
@lru_cache(maxsize=1)
def _oui_table() -> dict[str, str]:
"""Load Wireshark's manuf file.
Preferred over IEEE's oui.csv because it already merges the MA-L, MA-M
and MA-S registries with explicit prefix lengths. Loading only the 24-bit
registry silently mis-attributes every address in a 28- or 36-bit block
to whoever owns the enclosing /24.
"""
table: dict[str, str] = {}
path = DATA_DIR / "manuf"
if not path.exists():
return table
with path.open(encoding="utf-8", errors="replace") as handle:
for line in handle:
if line.startswith("#") or not line.strip():
continue
fields = line.rstrip("\n").split("\t")
if len(fields) < 2:
continue
prefix = fields[0].strip().upper()
vendor = (fields[2] if len(fields) > 2 and fields[2].strip() else fields[1]).strip()
table[prefix] = vendor
return table
def _lookup_mac(value: str) -> str:
hex_digits = re.sub(r"[^0-9A-Fa-f]", "", value).upper()
if len(hex_digits) < 6:
return f"CANNOT DO THAT: '{value}' is not a recognisable MAC address."
table = _oui_table()
if not table:
return ("The OUI database is not available in this build. Do not guess the vendor "
"from the prefix.")
normalised = ":".join(hex_digits[i:i + 2] for i in range(0, min(len(hex_digits), 12), 2))
# Longest-prefix match: 36-bit, then 28-bit, then 24-bit blocks.
for bits in (36, 28, 24):
nibbles = bits // 4
if len(hex_digits) < nibbles:
continue
candidate = hex_digits[:nibbles]
octets = ":".join(candidate[i:i + 2] for i in range(0, len(candidate) - len(candidate) % 2, 2))
for key in (f"{octets}/{bits}", octets):
if vendor := table.get(key.upper()):
out = [f"# {normalised}", "", f"**Vendor: {vendor}**", f"Matched OUI block: {key} ({bits}-bit)"]
out.append(_mac_flags(hex_digits))
return "\n".join(out)
return (f"# {normalised}\n\nNo vendor is registered for this OUI prefix.\n"
f"{_mac_flags(hex_digits)}\n\nDo not guess the manufacturer.")
def _mac_flags(hex_digits: str) -> str:
first = int(hex_digits[0:2], 16)
notes = []
if first & 0x01:
notes.append("multicast/group address")
else:
notes.append("unicast")
if first & 0x02:
notes.append("**locally administered** — this is a randomised or manually set address, "
"so the vendor prefix does not identify the real hardware")
else:
notes.append("globally unique (burned-in)")
return "\nFlags: " + "; ".join(notes)
@lru_cache(maxsize=1)
def _port_table() -> dict[tuple[str, str], list[tuple[str, str]]]:
table: dict[tuple[str, str], list[tuple[str, str]]] = {}
path = DATA_DIR / "iana-ports.csv"
if not path.exists():
return table
with path.open(encoding="utf-8", errors="replace", newline="") as handle:
for row in csv.DictReader(handle):
port = (row.get("Port Number") or "").strip()
proto = (row.get("Transport Protocol") or "").strip().lower()
service = (row.get("Service Name") or "").strip()
desc = (row.get("Description") or "").strip()
if not port or not proto or not service or "-" in port:
continue
table.setdefault((port, proto), []).append((service, desc))
return table
def _lookup_port(value: str) -> str:
match = re.search(r"\d+", value)
if not match:
return f"CANNOT DO THAT: '{value}' does not contain a port number."
port = match.group()
if not 0 <= int(port) <= 65535:
return f"CANNOT DO THAT: {port} is outside the valid port range 0-65535."
table = _port_table()
if not table:
return "The IANA port registry is not available in this build."
lines = [f"# Port {port}", ""]
found = False
for proto in ("tcp", "udp", "sctp", "dccp"):
for service, desc in table.get((port, proto), []):
found = True
lines.append(f"- **{proto.upper()}/{port}** — `{service}`{f': {desc}' if desc else ''}")
if not found:
lines.append("No service is registered with IANA on this port. It may still be used by "
"an application unofficially, but there is no assignment to cite.")
number = int(port)
if number < 1024:
lines += ["", "This is a well-known port (0-1023); binding it requires root on Unix."]
elif number < 49152:
lines += ["", "This is a registered port (1024-49151)."]
else:
lines += ["", "This is in the dynamic/ephemeral range (49152-65535)."]
return "\n".join(lines)
_HTTP_STATUS = {
100: "Continue", 101: "Switching Protocols", 103: "Early Hints",
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 206: "Partial Content",
301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified",
307: "Temporary Redirect", 308: "Permanent Redirect",
400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden",
404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict",
410: "Gone", 411: "Length Required", 412: "Precondition Failed",
413: "Content Too Large", 414: "URI Too Long", 415: "Unsupported Media Type",
416: "Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot",
421: "Misdirected Request", 422: "Unprocessable Content", 425: "Too Early",
426: "Upgrade Required", 428: "Precondition Required", 429: "Too Many Requests",
431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons",
500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway",
503: "Service Unavailable", 504: "Gateway Timeout", 505: "HTTP Version Not Supported",
507: "Insufficient Storage", 508: "Loop Detected", 511: "Network Authentication Required",
}
_STATUS_NOTES = {
401: "Means *unauthenticated* despite the name - no or bad credentials. Use 403 for "
"authenticated-but-not-allowed.",
403: "Authenticated but not permitted. If the user is getting this from a proxy, the "
"proxy may be blocking the destination rather than the origin refusing.",
404: "The server has no representation for this URI. It does not distinguish 'never "
"existed' from 'deleted' - use 410 for the latter.",
429: "Rate limited. Check for a Retry-After header before retrying.",
502: "An upstream server returned something invalid to a gateway or proxy. The fault is "
"usually behind the proxy, not in it.",
504: "A gateway timed out waiting for an upstream. Distinguish from 502: nothing came back at all.",
}
def _lookup_http_status(value: str) -> str:
match = re.search(r"\d{3}", value)
if not match:
return f"CANNOT DO THAT: '{value}' does not contain a three-digit HTTP status code."
code = int(match.group())
name = _HTTP_STATUS.get(code)
if not name:
klass = {1: "informational", 2: "success", 3: "redirection",
4: "client error", 5: "server error"}.get(code // 100)
if not klass:
return f"{code} is not a valid HTTP status code."
return f"# HTTP {code}\n\nNot a standard status code, but it is in the {klass} class ({code // 100}xx)."
klass = {1: "Informational", 2: "Success", 3: "Redirection",
4: "Client error", 5: "Server error"}[code // 100]
lines = [f"# HTTP {code} {name}", "", f"Class: {klass} ({code // 100}xx)"]
if note := _STATUS_NOTES.get(code):
lines += ["", note]
return "\n".join(lines)