diff --git a/cli/cli.go b/cli/cli.go index c8e04d9..4f2d81b 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -38,6 +38,14 @@ type Flags struct { // Special query modes RecAXFR bool `long:"recaxfr" description:"Perform recursive AXFR"` + // Registry lookup (RDAP / port-43 WHOIS) — distinct from -w (bgptools ASN enrichment) + Registry bool `short:"g" long:"registry" description:"Resolve target via RDAP with WHOIS fallback"` + RegistryRDAP bool `long:"registry-rdap" description:"Resolve target via RDAP only"` + RDAPServer string `long:"rdap-server" description:"RDAP base URL override (skips bootstrap)"` + RIR string `long:"rir" description:"Force RIR for registry lookups: iana, arin, ripe, apnic, lacnic, afrinic" default:"iana"` + RegistryWhois bool `long:"registry-whois" description:"Resolve target via port-43 WHOIS only"` + WhoisServer string `long:"whois-server" description:"Port-43 WHOIS server override (host or host:port)"` + // Output Format string `short:"f" long:"format" description:"Output format (pretty, column, json, yaml, raw)" default:"pretty"` PrettyTTLs bool `long:"pretty-ttls" description:"Format TTLs in human readable format (default: true)"` diff --git a/go.mod b/go.mod index 05270ef..1624877 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/miekg/dns v1.1.72 github.com/natesales/bgptools-go v0.0.0-20230212051756-2b519d61269c + github.com/openrdap/rdap v0.9.1 github.com/quic-go/quic-go v0.59.0 github.com/sthorne/odoh-go v1.0.4 github.com/stretchr/testify v1.11.1 @@ -19,6 +20,8 @@ require ( require ( github.com/AdguardTeam/golibs v0.35.9 // indirect + github.com/alecthomas/kingpin/v2 v2.3.2 // indirect + github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/ameshkov/dnsstamps v1.0.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect @@ -37,12 +40,14 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/go.sum b/go.sum index 464fdc1..836f253 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ git.schwanenlied.me/yawning/x448.git v0.0.0-20170617130356-01b048fb03d6/go.mod h1:wQaGCqEu44ykB17jZHCevrgSVl3KJnwQBObUtrKU4uU= github.com/AdguardTeam/golibs v0.35.9 h1:KmmPBgE+PnXmxd+sp5fACRjU6oYT8r7okWEU3L9B6Qo= github.com/AdguardTeam/golibs v0.35.9/go.mod h1:kuLQ0yNRTl0Em2FmmXtSri7ZdVT7p62oojyc51RvP38= +github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= +github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/ameshkov/dnscrypt/v2 v2.4.0 h1:if6ZG2cuQmcP2TwSY+D0+8+xbPfoatufGlOQTMNkI9o= github.com/ameshkov/dnscrypt/v2 v2.4.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= @@ -40,6 +44,8 @@ github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= +github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jedisct1/go-dnsstamps v0.0.0-20251112173516-191fc465df31 h1:O2qUxpVUeJxoBU5FJ0vGD03g5bY9uRrouaLlJeVOHr8= github.com/jedisct1/go-dnsstamps v0.0.0-20251112173516-191fc465df31/go.mod h1:mEGEFZsGe4sG5Mb3Xi89pmsy+TZ0946ArbYMGKAM5uA= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= @@ -58,6 +64,8 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -67,6 +75,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/natesales/bgptools-go v0.0.0-20230212051756-2b519d61269c h1:bblm7D7Ld1/zkWvMU1j60lMk5h/F4AiKW23j1yffM5Y= github.com/natesales/bgptools-go v0.0.0-20230212051756-2b519d61269c/go.mod h1:jl8YnQACciyOXRgNIRhURrCF9FmRHjnfT8UCj3LBkyY= +github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM= +github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -81,9 +91,12 @@ github.com/sthorne/odoh-go v1.0.4 h1:RPJceVs/dIpNE3gyzk6wdSay+nR+tI1YXBUwykvCEXI github.com/sthorne/odoh-go v1.0.4/go.mod h1:KdB/NGiepr9bLVs3k26uWl4HHPHqa2DaoPUgUfKNmJU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -119,6 +132,7 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 9d72290..d72237d 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "github.com/natesales/q/cli" "github.com/natesales/q/output" + "github.com/natesales/q/registry" "github.com/natesales/q/transport" "github.com/natesales/q/util" tlsutil "github.com/natesales/q/util/tls" @@ -347,6 +348,33 @@ All long form (--) flags can be toggled with the dig-standard +[no]flag notation } } + // Registry lookup (RDAP / port-43 WHOIS) + // rdap first, whois fallback, autoresolve LIR via -SUFFIX + if opts.Registry || opts.RegistryRDAP || opts.RegistryWhois { + if opts.Name == "" { + return fmt.Errorf("registry lookup: target required") + } + useRDAP := opts.RegistryRDAP || opts.Registry + useWHOIS := opts.RegistryWhois || opts.Registry + ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) + defer cancel() + text, err := registry.Query(ctx, registry.Options{ + Target: opts.Name, + UseRDAP: useRDAP, + UseWHOIS: useWHOIS, + RDAPServer: opts.RDAPServer, + WhoisServer: opts.WhoisServer, + RIR: strings.ToLower(opts.RIR), + Format: opts.Format, + Timeout: opts.Timeout, + }) + if err != nil { + return err + } + util.MustWritef(out, "%s\n", text) + return nil + } + // If no RR types are defined, set a list of default ones if len(rrTypes) < 1 { if opts.Name == "" { diff --git a/registry/rdap.go b/registry/rdap.go new file mode 100644 index 0000000..f5d91dd --- /dev/null +++ b/registry/rdap.go @@ -0,0 +1,84 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/openrdap/rdap" +) + +// queryRDAP performs an RDAP lookup using github.com/openrdap/rdap. +// When opts.RDAPServer or a non-IANA RIR is set, the server URL is supplied +// directly on the request, bypassing bootstrap. +func queryRDAP(ctx context.Context, opts Options) (string, error) { + kind := Classify(opts.Target) + + req, err := buildRDAPRequest(kind, opts.Target) + if err != nil { + return "", err + } + + base := opts.RDAPServer + if base == "" { + base = rirRDAPBase(opts.RIR) + } + if base != "" { + serverURL, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("registry: parse rdap server %q: %w", base, err) + } + req = req.WithServer(serverURL) + } + req = req.WithContext(ctx) + req.Timeout = opts.Timeout + + client := &rdap.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("registry: rdap: %w", err) + } + if resp == nil || resp.Object == nil { + return "", fmt.Errorf("registry: rdap: empty response") + } + + return formatRDAP(resp, opts.Format) +} + +func buildRDAPRequest(kind Kind, target string) (*rdap.Request, error) { + switch kind { + case KindDomain: + return &rdap.Request{Type: rdap.DomainRequest, Query: target}, nil + case KindIPv4, KindIPv6: + return &rdap.Request{Type: rdap.IPRequest, Query: target}, nil + case KindASN: + num := strings.TrimPrefix(strings.ToUpper(target), "AS") + return &rdap.Request{Type: rdap.AutnumRequest, Query: num}, nil + case KindNICHandle: + return &rdap.Request{Type: rdap.EntityRequest, Query: target}, nil + } + return nil, fmt.Errorf("registry: cannot classify rdap target %q", target) +} + +func formatRDAP(resp *rdap.Response, format string) (string, error) { + switch strings.ToLower(format) { + case "json", "raw": + b, err := json.MarshalIndent(resp.Object, "", " ") + if err != nil { + return "", fmt.Errorf("registry: marshal rdap: %w", err) + } + return string(b), nil + } + + if pretty := formatRDAPPretty(resp); pretty != "" { + return pretty, nil + } + + b, err := json.MarshalIndent(resp.Object, "", " ") + if err != nil { + return "", fmt.Errorf("registry: marshal rdap: %w", err) + } + return string(b), nil +} diff --git a/registry/rdap_pretty.go b/registry/rdap_pretty.go new file mode 100644 index 0000000..a506b7b --- /dev/null +++ b/registry/rdap_pretty.go @@ -0,0 +1,250 @@ +package registry + +import ( + "fmt" + "sort" + "strings" + + "github.com/openrdap/rdap" +) + +// formatRDAPPretty renders an RDAP response in whois-style key:value form for +// all four topmost object types (Domain, IPNetwork, Autnum, Entity). Returns +// an empty string if the object type isn't recognized +func formatRDAPPretty(resp *rdap.Response) string { + switch v := resp.Object.(type) { + case *rdap.Domain: + return prettyDomain(v) + case *rdap.IPNetwork: + return prettyIPNetwork(v) + case *rdap.Autnum: + return prettyAutnum(v) + case *rdap.Entity: + return prettyEntity(v) + } + return "" +} + +// kvBuilder accumulates aligned key:value lines and emits them with a +// consistent column width. +type kvBuilder struct { + sections [][]kvLine + current []kvLine +} + +type kvLine struct { + key, value string +} + +func (b *kvBuilder) add(key, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + b.current = append(b.current, kvLine{key, value}) +} + +func (b *kvBuilder) addMulti(key string, values []string) { + for _, v := range values { + b.add(key, v) + } +} + +// section closes the current group of lines so the next add starts a fresh +// visual section. Empty sections are dropped +func (b *kvBuilder) section() { + if len(b.current) > 0 { + b.sections = append(b.sections, b.current) + b.current = nil + } +} + +func (b *kvBuilder) String() string { + b.section() + if len(b.sections) == 0 { + return "" + } + + var sb strings.Builder + for i, s := range b.sections { + if i > 0 { + sb.WriteByte('\n') + } + width := 0 + for _, l := range s { + if n := len(l.key); n > width { + width = n + } + } + width++ // for the colon + for _, l := range s { + fmt.Fprintf(&sb, "%-*s %s\n", width, l.key+":", l.value) + } + } + return strings.TrimRight(sb.String(), "\n") +} + +func eventDate(events []rdap.Event, action string) string { + for _, e := range events { + if strings.EqualFold(e.Action, action) { + return e.Date + } + } + return "" +} + +func prettyDomain(d *rdap.Domain) string { + var b kvBuilder + name := d.LDHName + if name == "" { + name = d.UnicodeName + } + b.add("Domain Name", strings.ToUpper(name)) + b.add("Handle", d.Handle) + b.add("Status", strings.Join(d.Status, ", ")) + b.add("Registration", eventDate(d.Events, "registration")) + b.add("Expiration", eventDate(d.Events, "expiration")) + b.add("Last Changed", eventDate(d.Events, "last changed")) + b.add("Port43", d.Port43) + + for _, ns := range d.Nameservers { + nsName := ns.LDHName + if nsName == "" { + nsName = ns.UnicodeName + } + b.add("Name Server", strings.ToUpper(nsName)) + } + + if d.SecureDNS != nil && d.SecureDNS.DelegationSigned != nil { + b.add("DNSSEC", boolStr(*d.SecureDNS.DelegationSigned, "signedDelegation", "unsigned")) + } + + for _, e := range d.Entities { + appendEntity(&b, &e) + } + return b.String() +} + +func prettyIPNetwork(n *rdap.IPNetwork) string { + var b kvBuilder + if n.StartAddress != "" || n.EndAddress != "" { + b.add("NetRange", strings.TrimSpace(fmt.Sprintf("%s - %s", n.StartAddress, n.EndAddress))) + } + b.add("Handle", n.Handle) + b.add("NetName", n.Name) + b.add("NetType", n.Type) + b.add("Country", n.Country) + b.add("ParentHandle", n.ParentHandle) + b.add("IPVersion", n.IPVersion) + b.add("Status", strings.Join(n.Status, ", ")) + b.add("Registration", eventDate(n.Events, "registration")) + b.add("Last Changed", eventDate(n.Events, "last changed")) + b.add("Port43", n.Port43) + + for _, e := range n.Entities { + appendEntity(&b, &e) + } + return b.String() +} + +func prettyAutnum(a *rdap.Autnum) string { + var b kvBuilder + if a.StartAutnum != nil { + if a.EndAutnum != nil && *a.EndAutnum != *a.StartAutnum { + b.add("ASRange", fmt.Sprintf("AS%d - AS%d", *a.StartAutnum, *a.EndAutnum)) + } else { + b.add("ASNumber", fmt.Sprintf("AS%d", *a.StartAutnum)) + } + } + b.add("ASName", a.Name) + b.add("Handle", a.Handle) + b.add("Type", a.Type) + b.add("Country", a.Country) + b.add("Status", strings.Join(a.Status, ", ")) + b.add("Registration", eventDate(a.Events, "registration")) + b.add("Last Changed", eventDate(a.Events, "last changed")) + b.add("Port43", a.Port43) + + for _, e := range a.Entities { + appendEntity(&b, &e) + } + return b.String() +} + +func prettyEntity(e *rdap.Entity) string { + var b kvBuilder + appendEntity(&b, e) + return b.String() +} + +// appendEntity writes a vCard-derived block (org, name, address, contact info) +// for a single Entity. Each entity becomes its own visual section. +// https://www.arin.net/resources/registry/whois/rdap/ +func appendEntity(b *kvBuilder, e *rdap.Entity) { + b.section() + + roles := strings.Join(e.Roles, ", ") + if roles == "" { + roles = "Entity" + } + header := strings.ToUpper(roles[:1]) + roles[1:] + b.add(header, e.Handle) + + if e.VCard != nil { + v := e.VCard + if org := vcardOrg(v); org != "" { + b.add("Organization", org) + } + b.add("Name", v.Name()) + if street := v.StreetAddress(); street != "" { + b.add("Address", street) + } + if poBox := v.POBox(); poBox != "" { + b.add("PO Box", poBox) + } + if ext := v.ExtendedAddress(); ext != "" { + b.add("Address Line", ext) + } + b.add("City", v.Locality()) + b.add("State/Province", v.Region()) + b.add("Postal Code", v.PostalCode()) + b.add("Country", v.Country()) + b.add("Phone", v.Tel()) + b.add("Fax", v.Fax()) + b.add("Email", v.Email()) + } + b.add("Status", strings.Join(e.Status, ", ")) + b.add("Registration", eventDate(e.Events, "registration")) + b.add("Last Changed", eventDate(e.Events, "last changed")) + + for _, child := range e.Entities { + appendEntity(b, &child) + } + + // Stable Public IDs + if len(e.PublicIDs) > 0 { + ids := make([]string, 0, len(e.PublicIDs)) + for _, id := range e.PublicIDs { + ids = append(ids, fmt.Sprintf("%s=%s", id.Type, id.Identifier)) + } + sort.Strings(ids) + b.addMulti("Public ID", ids) + } +} + +// vcardOrg pulls the first "org" property out of a vCard, joining multi-value +// org strings with " / ". +func vcardOrg(v *rdap.VCard) string { + p := v.GetFirst("org") + if p == nil { + return "" + } + return strings.Join(p.Values(), " / ") +} + +func boolStr(b bool, t, f string) string { + if b { + return t + } + return f +} diff --git a/registry/registry.go b/registry/registry.go new file mode 100644 index 0000000..5532d9a --- /dev/null +++ b/registry/registry.go @@ -0,0 +1,171 @@ +package registry + +import ( + "context" + "fmt" + "net" + "regexp" + "strings" + "time" + + "github.com/charmbracelet/log" +) + +// Kind classifies a registry-lookup target. +type Kind int + +const ( + KindUnknown Kind = iota + KindIPv4 + KindIPv6 + KindASN + KindDomain + KindNICHandle +) + +func (k Kind) String() string { + switch k { + case KindIPv4: + return "ipv4" + case KindIPv6: + return "ipv6" + case KindASN: + return "asn" + case KindDomain: + return "domain" + case KindNICHandle: + return "nic-handle" + } + return "unknown" +} + +var ( + asnRe = regexp.MustCompile(`^(?i)AS\d+$`) + handleRe = regexp.MustCompile(`^[A-Za-z0-9-]+$`) +) + +// Classify inspects target and returns its Kind +func Classify(target string) Kind { + t := strings.TrimSpace(target) + if t == "" { + return KindUnknown + } + if ip := net.ParseIP(t); ip != nil { + if ip.To4() != nil { + return KindIPv4 + } + return KindIPv6 + } + if asnRe.MatchString(t) { + return KindASN + } + if strings.Contains(t, ".") { + return KindDomain + } + if handleRe.MatchString(t) { + return KindNICHandle + } + return KindUnknown +} + +// Options controls a registry lookup. +type Options struct { + Target string + UseRDAP bool + UseWHOIS bool + RDAPServer string + WhoisServer string + RIR string + Format string + Timeout time.Duration +} + +// when passed --registry, attempts rdap first, then whois if fials +func Query(ctx context.Context, opts Options) (string, error) { + if strings.TrimSpace(opts.Target) == "" { + return "", fmt.Errorf("registry: empty target") + } + if !opts.UseRDAP && !opts.UseWHOIS { + return "", fmt.Errorf("registry: neither RDAP nor WHOIS requested") + } + if opts.Timeout <= 0 { + opts.Timeout = 10 * time.Second + } + opts.RIR = strings.ToLower(strings.TrimSpace(opts.RIR)) + if opts.RIR == "" { + opts.RIR = "iana" + } + + if Classify(opts.Target) == KindNICHandle && + opts.RIR == "iana" && + opts.RDAPServer == "" && + opts.WhoisServer == "" { + if detected := DetectRIRFromHandle(opts.Target); detected != "" { + log.Debugf("registry: detected RIR %s from handle %q", detected, opts.Target) + opts.RIR = detected + } + } + + if opts.UseRDAP { + out, err := queryRDAP(ctx, opts) + if err == nil { + return out, nil + } + if opts.UseWHOIS { + log.Debugf("registry: RDAP failed (%s), falling back to WHOIS", err) + return queryWHOIS(ctx, opts) + } + return "", err + } + return queryWHOIS(ctx, opts) +} + +// matching whois(1) behavior to autoguess RIR based off of prefix +func DetectRIRFromHandle(handle string) string { + h := strings.ToUpper(strings.TrimSpace(handle)) + switch { + case strings.HasSuffix(h, "-RIPE"): + return "ripe" + case strings.HasSuffix(h, "-ARIN"), strings.HasPrefix(h, "ARIN-"): + return "arin" + case strings.HasSuffix(h, "-AP"): + return "apnic" + case strings.HasSuffix(h, "-LACNIC"): + return "lacnic" + case strings.HasSuffix(h, "-AFRINIC"): + return "afrinic" + } + return "" +} + +func rirRDAPBase(rir string) string { + switch strings.ToLower(rir) { + case "arin": + return "https://rdap.arin.net/registry" + case "ripe": + return "https://rdap.db.ripe.net" + case "apnic": + return "https://rdap.apnic.net" + case "lacnic": + return "https://rdap.lacnic.net/rdap" + case "afrinic": + return "https://rdap.afrinic.net/rdap" + } + return "" +} + +func rirWhoisHost(rir string) string { + switch strings.ToLower(rir) { + case "arin": + return "whois.arin.net" + case "ripe": + return "whois.ripe.net" + case "apnic": + return "whois.apnic.net" + case "lacnic": + return "whois.lacnic.net" + case "afrinic": + return "whois.afrinic.net" + } + return "whois.iana.org" +} diff --git a/registry/whois.go b/registry/whois.go new file mode 100644 index 0000000..c7fdc2c --- /dev/null +++ b/registry/whois.go @@ -0,0 +1,159 @@ +package registry + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "strings" + "time" +) + +// whoisHopLimit caps the number of refer-chain hops to avoid infinite loops +const whoisHopLimit = 5 + +type whoisHop struct { + Server string `json:"server"` + Body string `json:"body"` +} + +// queryWHOIS performs a port-43 WHOIS lookup, following refer/whois/ReferralServer +// pointers up to whoisHopLimit times +func queryWHOIS(ctx context.Context, opts Options) (string, error) { + start := opts.WhoisServer + if start == "" { + start = rirWhoisHost(opts.RIR) + } + + hops, err := whoisChain(ctx, opts.Target, start, opts.Timeout) + if err != nil && len(hops) == 0 { + return "", err + } + + if strings.ToLower(opts.Format) == "json" { + b, mErr := json.MarshalIndent(struct { + Hops []whoisHop `json:"hops"` + }{Hops: hops}, "", " ") + if mErr != nil { + return "", fmt.Errorf("registry: marshal whois: %w", mErr) + } + return string(b), nil + } + + var sb strings.Builder + for i, h := range hops { + if i > 0 { + sb.WriteString("\n") + } + fmt.Fprintf(&sb, ";; Server: %s\n", h.Server) + sb.WriteString(h.Body) + if !strings.HasSuffix(h.Body, "\n") { + sb.WriteString("\n") + } + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func whoisChain(ctx context.Context, target, start string, timeout time.Duration) ([]whoisHop, error) { + if timeout <= 0 { + timeout = 10 * time.Second + } + + var hops []whoisHop + server := start + seen := map[string]bool{} + + for i := 0; i < whoisHopLimit; i++ { + host := withDefaultPort(server, "43") + if seen[host] { + break + } + seen[host] = true + + body, err := whoisExchange(ctx, host, target, timeout) + if err != nil { + if len(hops) == 0 { + return nil, fmt.Errorf("registry: whois %s: %w", host, err) + } + hops = append(hops, whoisHop{Server: host, Body: fmt.Sprintf(";; error: %v", err)}) + return hops, nil + } + hops = append(hops, whoisHop{Server: host, Body: body}) + + next := parseWhoisReferral(body) + if next == "" || next == server { + return hops, nil + } + server = next + } + return hops, nil +} + +func whoisExchange(ctx context.Context, hostport, query string, timeout time.Duration) (string, error) { + dialer := &net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", hostport) + if err != nil { + return "", err + } + defer conn.Close() + + deadline := time.Now().Add(timeout) + _ = conn.SetDeadline(deadline) + + if _, err := conn.Write([]byte(query + "\r\n")); err != nil { + return "", err + } + b, err := io.ReadAll(conn) + if err != nil { + return string(b), err + } + return string(b), nil +} + +// parseWhoisReferral scans the body for a refer/whois/ReferralServer line +// and returns the host (with optional port) it points at, or "" if none +func parseWhoisReferral(body string) string { + scanner := bufio.NewScanner(strings.NewReader(body)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, ':') + if idx <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:idx])) + value := strings.TrimSpace(line[idx+1:]) + if value == "" { + continue + } + switch key { + case "refer", "whois": + return value + case "referralserver": + return stripWhoisScheme(value) + } + } + return "" +} + +func stripWhoisScheme(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "whois://") + v = strings.TrimPrefix(v, "rwhois://") + return strings.TrimRight(v, "/") +} + +func withDefaultPort(hostport, port string) string { + if hostport == "" { + return "" + } + if _, _, err := net.SplitHostPort(hostport); err == nil { + return hostport + } + return net.JoinHostPort(hostport, port) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..850a4cc --- /dev/null +++ b/shell.nix @@ -0,0 +1,7 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + packages = with pkgs; [ + go + ]; +}