diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b016134 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0d15ce1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: test +on: + pull_request: + paths-ignore: + - '*.md' + push: + branches: + - master + paths-ignore: + - '*.md' +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/setup-go@v5 + with: + go-version: 1.22.2 + - uses: actions/checkout@v4 + - run: go test ./... --cover diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a676215 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +bin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5730e0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2023 TwiN + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9572706 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: build-binaries + +build-binaries: + ./scripts/build.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..f92a7e3 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# whois +![test](https://github.com/TwiN/whois/actions/workflows/test.yml/badge.svg) + +Lightweight library for retrieving WHOIS information on a domain. + +It automatically retrieves the appropriate WHOIS server based on the domain's TLD by first querying IANA. + + +## Usage +### As an executable +To install it: +```console +go install github.com/TwiN/whois/cmd/whois@latest +``` +Alternatively, you can download whois from the [release](https://github.com/TwiN/whois/releases) section, though you'll have to unzip the file first. + +To run it: +```console +whois example.com +``` + +### As a library +```console +go get github.com/TwiN/whois +``` + +#### Query +If all you want is the text a WHOIS server would return you, you can use the `Query` method of the `whois.Client` type: +```go +package main + +import "github.com/TwiN/whois" + +func main() { + client := whois.NewClient() + output, err := client.Query("example.com") + if err != nil { + panic(err) + } + println(output) +} +``` + +#### QueryAndParse +If you want specific pieces of information, you can use the `QueryAndParse` method of the `whois.Client` type: +```go +package main + +import "github.com/TwiN/whois" + +func main() { + client := whois.NewClient() + response, err := client.QueryAndParse("example.com") + if err != nil { + panic(err) + } + println(response.ExpirationDate.String()) +} +``` +Note that because there is no standardized format for WHOIS responses, this parsing may not be successful for every single TLD. + +Currently, the only fields parsed are: +- `ExpirationDate`: The time.Time at which the domain will expire +- `DomainStatuses`: The statuses that the domain currently has (e.g. `clientTransferProhibited`) +- `NameServers`: The nameservers currently tied to the domain + +If you'd like one or more other fields to be parsed, please don't be shy and create an issue or a pull request. + +#### Caching referral WHOIS servers +The way that WHOIS scales is by having one "main" WHOIS server, namely `whois.iana.org:43`, refer to other WHOIS server +on a per-TLD basis. + +In other word, let's say that you wanted to have the WHOIS information for `example.com`. +The first step would be to query `whois.iana.org:43` with `com`, which would return `whois.verisign-grs.com`. +Then, you would query `whois.verisign-grs.com:43` for the WHOIS information on `example.com`. + +If you're querying a lot of servers, making two queries instead of one can be a little wasteful, hence `WithReferralCache(true)`: +```go +client := whois.NewClient().WithReferralCache(true) +``` +The above will cache the referral WHOIS server for each TLD, so that you can directly query the appropriate WHOIS server +instead of first querying `whois.iana.org:43` for the referral. + diff --git a/cmd/whois/main.go b/cmd/whois/main.go new file mode 100644 index 0000000..0c3fe67 --- /dev/null +++ b/cmd/whois/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "os" + + "github.com/TwiN/whois" +) + +func main() { + if len(os.Args) != 2 { + _, _ = fmt.Fprintln(os.Stderr, "you must provide exactly one domain") + os.Exit(1) + return + } + output, err := whois.NewClient().Query(os.Args[1]) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + fmt.Println(output) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b53c454 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/TwiN/whois + +go 1.22.2 diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..cc6fb8d --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +########################################################################################## +# NOTE: You are expected to call this file from the Makefile at the root of this project # +########################################################################################## + +APPLICATION_NAME="whois" + +mkdir bin +rm -rf bin/${APPLICATION_NAME}-*.zip + +env GOOS=darwin GOARCH=amd64 go build -o ${APPLICATION_NAME} ./cmd/whois/main.go +chmod +x ${APPLICATION_NAME} +zip bin/${APPLICATION_NAME}-darwin-amd64.zip ${APPLICATION_NAME} -m + +env GOOS=darwin GOARCH=arm64 go build -o ${APPLICATION_NAME} ./cmd/whois/main.go +chmod +x ${APPLICATION_NAME} +zip bin/${APPLICATION_NAME}-darwin-arm64.zip ${APPLICATION_NAME} -m + +env GOOS=linux GOARCH=amd64 go build -o ${APPLICATION_NAME} ./cmd/whois/main.go +chmod +x ${APPLICATION_NAME} +zip bin/${APPLICATION_NAME}-linux-amd64.zip ${APPLICATION_NAME} -m + +env GOOS=linux GOARCH=arm64 go build -o ${APPLICATION_NAME} ./cmd/whois/main.go +chmod +x ${APPLICATION_NAME} +zip bin/${APPLICATION_NAME}-linux-arm64.zip ${APPLICATION_NAME} -m + +env GOOS=windows GOARCH=amd64 go build -o ${APPLICATION_NAME}.exe ./cmd/whois/main.go +chmod +x ${APPLICATION_NAME}.exe +zip bin/${APPLICATION_NAME}-windows-amd64.zip ${APPLICATION_NAME}.exe -m diff --git a/whois.go b/whois.go new file mode 100644 index 0000000..5d7e8d4 --- /dev/null +++ b/whois.go @@ -0,0 +1,191 @@ +package whois + +import ( + "errors" + "io" + "net" + "strings" + "time" +) + +const ( + ianaWHOISServerAddress = "whois.iana.org:43" +) + +var tldWithoutExpirationDate = []string{"at", "be", "ch", "co.at", "com.br", "or.at", "de", "fr", "nl"} + +type Client struct { + whoisServerAddress string + + isCachingReferralWHOISServers bool + referralWHOISServersCache map[string]string +} + +func NewClient() *Client { + return &Client{ + whoisServerAddress: ianaWHOISServerAddress, + referralWHOISServersCache: make(map[string]string), + } +} + +// WithReferralCache allows you to enable or disable the referral WHOIS server cache. +// While ianaWHOISServerAddress is the "entry point" for WHOIS queries, it sometimes has +// availability issues. One way to mitigate this is to cache the referral WHOIS server. +// +// This is disabled by default +func (c *Client) WithReferralCache(enabled bool) *Client { + c.isCachingReferralWHOISServers = enabled + if enabled { + // We'll set a couple of common ones right away to avoid unnecessary queries + c.referralWHOISServersCache = map[string]string{ + "com": "whois.verisign-grs.com", + "black": "whois.nic.black", + "dev": "whois.nic.google", + "green": "whois.nic.green", + "io": "whois.nic.io", + "net": "whois.verisign-grs.com", + "org": "whois.publicinterestregistry.org", + "red": "whois.nic.red", + "sh": "whois.nic.sh", + "uk": "whois.nic.uk", + "mx": "whois.nic.mx", + } + } + return c +} + +func doesTLDHaveExpirationDate(e string) bool { + for _, a := range tldWithoutExpirationDate { + if a == e { + return true + } + } + return false +} + +func (c *Client) Query(domain string) (string, error) { + parts := strings.Split(domain, ".") + domainExtension := parts[len(parts)-1] + if doesTLDHaveExpirationDate(domainExtension) { + return "", errors.New("domain extension " + domainExtension + " does not have a grace period.") + } + if c.isCachingReferralWHOISServers { + if cachedWHOISServer, ok := c.referralWHOISServersCache[domain]; ok { + return c.query(cachedWHOISServer, domain) + } + } + var output string + var err error + switch domainExtension { + case "ua": + if len(parts) > 2 && len(parts[len(parts)-2]) < 4 { + domainExtension = parts[len(parts)-2] + "." + domainExtension + } + output, err = c.query("whois."+domainExtension+":43", domain) + default: + output, err = c.query(c.whoisServerAddress, domainExtension) + } + if err != nil { + return "", err + } + if strings.Contains(output, "whois:") { + startIndex := strings.Index(output, "whois:") + 6 + endIndex := strings.Index(output[startIndex:], "\n") + startIndex + whois := strings.TrimSpace(output[startIndex:endIndex]) + if referOutput, err := c.query(whois+":43", domain); err == nil { + if c.isCachingReferralWHOISServers { + c.referralWHOISServersCache[domain] = whois + ":43" + } + return referOutput, nil + } + return "", err + } + return output, nil +} + +func (c *Client) query(whoisServerAddress, domain string) (string, error) { + connection, err := net.DialTimeout("tcp", whoisServerAddress, 10*time.Second) + if err != nil { + return "", err + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + _, err = connection.Write([]byte(domain + "\r\n")) + if err != nil { + return "", err + } + output, err := io.ReadAll(connection) + if err != nil { + return "", err + } + return string(output), nil +} + +type Response struct { + ExpirationDate time.Time + DomainStatuses []string + NameServers []string +} + +// QueryAndParse tries to parse the response from the WHOIS server +// There is no standardized format for WHOIS responses, so this is an attempt at best. +// +// Being the selfish person that I am, I also only parse the fields that I need. +// If you need more fields, please open an issue or pull request. +func (c *Client) QueryAndParse(domain string) (*Response, error) { + text, err := c.Query(domain) + if err != nil { + return nil, err + } + response := Response{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + valueStartIndex := strings.Index(line, ":") + if valueStartIndex == -1 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:valueStartIndex])) + value := strings.TrimSpace(line[valueStartIndex+1:]) + if strings.Contains(key, "expir") { + switch { + case strings.HasSuffix(domain, ".pp.ua"): + response.ExpirationDate, _ = time.Parse("02-Jan-2006 15:04:05 MST", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".ua"): + response.ExpirationDate, _ = time.Parse("2006-01-02 15:04:05Z07", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".uk"): + response.ExpirationDate, _ = time.Parse("02-Jan-2006", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".cz"): + response.ExpirationDate, _ = time.Parse("02.01.2006", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".im"): + response.ExpirationDate, _ = time.Parse("02/01/2006 15:04:05", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".scot"): + if !strings.Contains(key, "registrar") { + response.ExpirationDate, _ = time.Parse(time.RFC3339, strings.ToUpper(value)) + } + case strings.HasSuffix(domain, ".br"): + response.ExpirationDate, _ = time.Parse("20060102", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".cn"): + response.ExpirationDate, _ = time.Parse("2006-01-02 15:04:05", strings.ToUpper(value)) + case strings.HasSuffix(domain, ".mx"): + response.ExpirationDate, _ = time.Parse(time.DateOnly, strings.ToUpper(value)) + default: + response.ExpirationDate, _ = time.Parse(time.RFC3339, strings.ToUpper(value)) + } + } else if key == "paid-till" { + // example for ru/su domains -> paid-till: 2024-05-26T21:00:00Z + if strings.HasSuffix(domain, ".ru") || strings.HasSuffix(domain, ".su") { + response.ExpirationDate, _ = time.Parse(time.RFC3339, strings.ToUpper(value)) + } + } else if strings.Contains(key, "status") { + response.DomainStatuses = append(response.DomainStatuses, value) + } else if key == "state" { + // example for ru/su domains -> state: DELEGATED, VERIFIED + if strings.HasSuffix(domain, ".ru") || strings.HasSuffix(domain, ".su") { + response.DomainStatuses = strings.Split(value, ", ") + } + } else if strings.Contains(key, "name server") || strings.Contains(key, "nserver") { + response.NameServers = append(response.NameServers, value) + } + } + return &response, nil +} diff --git a/whois_test.go b/whois_test.go new file mode 100644 index 0000000..841462d --- /dev/null +++ b/whois_test.go @@ -0,0 +1,148 @@ +package whois + +import ( + "strings" + "testing" + "time" +) + +func TestClient(t *testing.T) { + scenarios := []struct { + domain string + wantErr bool + }{ + { + domain: "name.com", + wantErr: false, + }, + { + domain: "name.org", + wantErr: false, + }, + { + domain: "name.net", + wantErr: false, + }, + { + domain: "name.sh", + wantErr: false, + }, + { + domain: "name.io", + wantErr: false, + }, + { + domain: "get.dev", + wantErr: false, + }, + { + domain: "name.red", + wantErr: false, + }, + { + domain: "name.green", + wantErr: false, + }, + { + domain: "color.black", + wantErr: false, + }, + { + domain: "name.cn", + wantErr: false, + }, + { + domain: "name.de", + wantErr: true, + }, + { + domain: "google.com.br", // name.com.br is handled weirdly by whois.registro.br, so we'll use this instead + wantErr: false, + }, + { + domain: "name.ua", + wantErr: false, + }, + { + domain: "name.pp.ua", + wantErr: false, + }, + { + domain: "name.co.uk", + wantErr: false, + }, + { + domain: "name.cz", + wantErr: false, + }, + { + domain: "name.me", + wantErr: false, + }, + { + domain: "name.im", + wantErr: false, + }, + { + domain: "name.uk", + wantErr: false, + }, + { + domain: "dot.scot", // name.scot not registered + wantErr: false, + }, + { + domain: "name.ru", // expiration date in `paid-till` field + wantErr: false, + }, + { + domain: "register.su", // expiration date in `paid-till` field + wantErr: false, + }, + { + domain: "nic.mx", + wantErr: false, + }, + } + client := NewClient().WithReferralCache(true) + for _, scenario := range scenarios { + t.Run(scenario.domain+"_Query", func(t *testing.T) { + output, err := client.Query(scenario.domain) + if scenario.wantErr && err == nil { + t.Error("expected error, got none") + t.FailNow() + } + if !scenario.wantErr { + if err != nil { + t.Error("expected no error, got", err.Error()) + } + if !strings.Contains(strings.ToLower(output), scenario.domain) { + t.Errorf("expected %s in output, got %s", scenario.domain, output) + } + } + }) + time.Sleep(50 * time.Millisecond) // Give the WHOIS servers some breathing room + t.Run(scenario.domain+"_QueryAndParse", func(t *testing.T) { + response, err := client.QueryAndParse(scenario.domain) + if scenario.wantErr && err == nil { + t.Error("expected error, got none") + t.FailNow() + } + if !scenario.wantErr { + if err != nil { + t.Error("expected no error, got", err.Error(), "for domain", scenario.domain) + } + if response.ExpirationDate.Unix() <= 0 { + t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix(), "for domain", scenario.domain) + } + if len(response.NameServers) == 0 { + t.Errorf("expected to have at least one name server for domain %s", scenario.domain) + } + if len(response.DomainStatuses) == 0 && !strings.HasSuffix(scenario.domain, ".im") && !strings.HasSuffix(scenario.domain, ".mx") && !strings.HasSuffix(scenario.domain, ".cz") { + t.Errorf("expected to have at least one domain status for domain %s", scenario.domain) + } + } + }) + time.Sleep(50 * time.Millisecond) // Give the WHOIS servers some breathing room + } +}