Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/release-notes/v2.1.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- Add `--timeout` to set how long an API request can take.
- Add `--max-response-mb` to set the largest response to read.
- Stop requests that exceed these limits without printing or saving partial results.
- Add checks for large bulk lookups and Arabic requests.

Existing commands and output formats remain available.
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
timeout-minutes: 15
strategy:
matrix:
os: [ubuntu-24.04, macos-15, windows-2025]
os: [ubuntu-24.04, macos-15-intel, windows-2025]
go: ["1.26.10"]
include:
- os: ubuntu-24.04
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- name: Check release notes
run: test -s ".github/release-notes/$GITHUB_REF_NAME.md"
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.10"
Expand All @@ -30,4 +32,4 @@ jobs:
- name: Publish archives and checksums
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --title "$GITHUB_REF_NAME" --generate-notes
run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --title "$GITHUB_REF_NAME" --notes-file ".github/release-notes/$GITHUB_REF_NAME.md"
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,33 +154,33 @@ For local HTTPS regression checks, install Node.js and Postman CLI 1.73.0, then
### Download Prebuilt Binaries

#### Overview
These are prebuilt binaries for the IPGeolocation.io CLI tool, version **2.1.1**. Users can download these files directly from [GitHub Releases](https://github.com/IPGeolocation/cli/releases) or from the table below without needing to build from source.
These are prebuilt binaries for the IPGeolocation.io CLI tool, version **2.1.2**. Users can download these files directly from [GitHub Releases](https://github.com/IPGeolocation/cli/releases) or from the table below without needing to build from source.

The CLI provides geolocation information, time zone, user-agent parsing, bulk IP lookups, and more.

#### Prebuilt Binaries

| Platform | Architecture | File Name / Download Link |
|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
| Linux | amd64 | [**ipgeolocation-2.1.1-linux-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-linux-amd64.tar.gz) |
| Linux | arm64 | [**ipgeolocation-2.1.1-linux-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-linux-arm64.tar.gz) |
| macOS | amd64 | [**ipgeolocation-2.1.1-darwin-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-darwin-amd64.tar.gz) |
| macOS | arm64 | [**ipgeolocation-2.1.1-darwin-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-darwin-arm64.tar.gz) |
| Windows | amd64 | [**ipgeolocation-2.1.1-windows-amd64.zip**](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/ipgeolocation-2.1.1-windows-amd64.zip) |
| Linux | amd64 | [**ipgeolocation-2.1.2-linux-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.2/ipgeolocation-2.1.2-linux-amd64.tar.gz) |
| Linux | arm64 | [**ipgeolocation-2.1.2-linux-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.2/ipgeolocation-2.1.2-linux-arm64.tar.gz) |
| macOS | amd64 | [**ipgeolocation-2.1.2-darwin-amd64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.2/ipgeolocation-2.1.2-darwin-amd64.tar.gz) |
| macOS | arm64 | [**ipgeolocation-2.1.2-darwin-arm64.tar.gz**](https://github.com/IPGeolocation/cli/releases/download/v2.1.2/ipgeolocation-2.1.2-darwin-arm64.tar.gz) |
| Windows | amd64 | [**ipgeolocation-2.1.2-windows-amd64.zip**](https://github.com/IPGeolocation/cli/releases/download/v2.1.2/ipgeolocation-2.1.2-windows-amd64.zip) |

Archives include [SHA256SUMS](https://github.com/IPGeolocation/cli/releases/download/v2.1.1/SHA256SUMS) for download verification.
Archives include [SHA256SUMS](https://github.com/IPGeolocation/cli/releases/download/v2.1.2/SHA256SUMS) for download verification.

#### Installation Instructions

**Linux:**
1. Download the `.tar.gz` file for your architecture.
2. Extract it to a folder in your PATH, e.g., `/usr/local/bin`:
```bash
tar -xzf ipgeolocation-2.1.1-linux-amd64.tar.gz -C /usr/local/bin
tar -xzf ipgeolocation-2.1.2-linux-amd64.tar.gz -C /usr/local/bin
```
3. Rename the binary for simplicity:
```bash
mv /usr/local/bin/ipgeolocation-2.1.1-linux-amd64 /usr/local/bin/ipgeolocation
mv /usr/local/bin/ipgeolocation-2.1.2-linux-amd64 /usr/local/bin/ipgeolocation
```
4. Make the binary executable:
```bash
Expand All @@ -195,11 +195,11 @@ Archives include [SHA256SUMS](https://github.com/IPGeolocation/cli/releases/down
1. Download the `.tar.gz` file for your architecture (amd64 or arm64).
2. Extract to a folder in your PATH, e.g., `/usr/local/bin`:
```bash
tar -xzf ipgeolocation-2.1.1-darwin-amd64.tar.gz -C /usr/local/bin
tar -xzf ipgeolocation-2.1.2-darwin-amd64.tar.gz -C /usr/local/bin
```
3. Rename the binary:
```bash
mv /usr/local/bin/ipgeolocation-2.1.1-darwin-amd64 /usr/local/bin/ipgeolocation
mv /usr/local/bin/ipgeolocation-2.1.2-darwin-amd64 /usr/local/bin/ipgeolocation
```
4. Make executable:
```bash
Expand All @@ -212,7 +212,7 @@ Archives include [SHA256SUMS](https://github.com/IPGeolocation/cli/releases/down

**Windows:**
1. Download the `.zip` file.
2. Extract the `ipgeolocation-2.1.1-windows-amd64.exe` to a folder included in your system `PATH`.
2. Extract the `ipgeolocation-2.1.2-windows-amd64.exe` to a folder included in your system `PATH`.
3. Rename the binary to `ipgeolocation.exe` for convenience.
4. Open Command Prompt and verify:
```cmd
Expand All @@ -222,7 +222,7 @@ Archives include [SHA256SUMS](https://github.com/IPGeolocation/cli/releases/down
> [!NOTE]
> - Ensure execution permissions on Linux/macOS.
> - Recommended folder for binaries: `/usr/local/bin` or any folder in your PATH.
> - Prebuilt binaries include version **2.1.1** in their filename. Rename them after extraction for easier use.
> - Prebuilt binaries include version **2.1.2** in their filename. Rename them after extraction for easier use.
> - For updates, check GitHub Releases.

### Troubleshooting
Expand Down Expand Up @@ -292,6 +292,8 @@ These flags are available for all commands:
| Flag | Description |
|--------------|----------------------------|
| `-h, --help` | Show help for the command. |
| `--timeout` | Maximum time for one API request. Default: `5m` (5 minutes). For a longer request, use `--timeout 10m`. |
| `--max-response-mb` | Maximum response size in MB. Default: `256`. For larger bulk results, use `--max-response-mb 512` or another positive whole number. |

> [!TIP]
> You can also check the version for `ipgeolocation` using the `--version` flag:
Expand Down
70 changes: 67 additions & 3 deletions cmd/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ func (fixtureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
"ip": "2001:db8::1", "location": map[string]string{"city": "Test City"},
"_request": map[string]interface{}{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query(), "payload": payload},
}
if r.URL.Query().Get("lang") == "ar" {
result["location"] = map[string]string{"city": "مدينة"}
}
var response interface{} = result
if strings.HasSuffix(r.URL.Path, "-bulk") {
response = []interface{}{result}
Expand Down Expand Up @@ -85,21 +88,82 @@ func TestCLIProcess(t *testing.T) {
t.Fatal("missing CLI arguments")
}

func runCLI(t *testing.T, home string, args ...string) string {
t.Helper()
func newCLIProcess(home string, args ...string) *exec.Cmd {
process := exec.Command(os.Args[0], append([]string{"-test.run=^TestCLIProcess$", "--"}, args...)...)
if binary := os.Getenv("CLI_TEST_BINARY"); binary != "" {
process = exec.Command(binary, args...)
}
process.Dir = home
process.Env = append(os.Environ(), "CLI_TEST_PROCESS=1", "HOME="+home, "USERPROFILE="+home, "GORACE=atexit_sleep_ms=0")
return process
}

func runCLI(t *testing.T, home string, args ...string) string {
t.Helper()
process := newCLIProcess(home, args...)
output, err := process.CombinedOutput()
if err != nil {
t.Fatalf("CLI %v failed: %v\n%s", args, err, output)
}
return strings.TrimSpace(string(output))
}

func TestCLIArabic(t *testing.T) {
home := configuredHome(t, testKey)
for _, args := range [][]string{
{"ipgeo", "--ip", "8.8.8.8"},
{"bulk-ip-geo", "--ips", "8.8.8.8,1.1.1.1"},
{"timezone", "--ip", "8.8.8.8"},
{"astronomy", "--ip", "8.8.8.8"},
{"astronomy-timeseries", "--ip", "8.8.8.8", "--start-date", "2026-11-01", "--end-date", "2026-11-02"},
} {
t.Run(args[0], func(t *testing.T) {
output := runCLI(t, home, append(args, "--lang", "ar", "--output", "raw")...)
var response interface{}
if err := json.Unmarshal([]byte(output), &response); err != nil {
t.Fatalf("Arabic response invalid: %v", err)
}
if args[0] == "bulk-ip-geo" {
response = response.([]interface{})[0]
}
result := response.(map[string]interface{})
query := result["_request"].(map[string]interface{})["query"].(map[string]interface{})
if !reflect.DeepEqual(query["lang"], []interface{}{"ar"}) || result["location"].(map[string]interface{})["city"] != "مدينة" {
t.Fatal("Arabic request or response changed")
}
})
}
}

func TestCLIRequestLimitFlags(t *testing.T) {
home := configuredHome(t, testKey)
if output := runCLI(t, home, "my-ip", "--timeout", "10m", "--max-response-mb", "512", "--output", "raw"); !strings.Contains(output, "2001:db8::1") {
t.Fatalf("limit overrides failed: %s", output)
}
for _, args := range [][]string{
{"ipgeo", "--timeout", "0s"},
{"ipgeo", "--timeout=-1s"},
{"ipgeo", "--timeout", "not-a-duration"},
{"ipgeo", "--timeout", "999999999999999h"},
{"ipgeo", "--max-response-mb", "0"},
{"ipgeo", "--max-response-mb=-1"},
{"ipgeo", "--max-response-mb", "1.5"},
{"ipgeo", "--max-response-mb", "9223372036855"},
} {
process := newCLIProcess(home, args...)
output, err := process.CombinedOutput()
if err == nil || !strings.Contains(string(output), "Error") || strings.Contains(string(output), `"_request"`) || strings.Contains(string(output), testKey) {
t.Fatalf("invalid limits not rejected: args=%v err=%v output=%s", args, err, output)
}
}
for _, command := range []string{"ipgeo", "bulk-ip-geo", "my-ip", "config"} {
output := runCLI(t, home, command, "--help")
if !strings.Contains(output, "--timeout") || !strings.Contains(output, "--max-response-mb") || !strings.Contains(output, "default 5m0s") || !strings.Contains(output, "default 256") {
t.Fatalf("request controls absent from help for %s", command)
}
}
}

func configuredHome(t *testing.T, key string) string {
t.Helper()
home := t.TempDir()
Expand Down Expand Up @@ -203,7 +267,7 @@ func TestCLIConfigAndBulkFiles(t *testing.T) {
if output := runCLI(t, home, "config"); strings.Contains(output, "short") || !strings.Contains(output, "********") {
t.Fatalf("short key exposed: %s", output)
}
if output := runCLI(t, home, "--version"); output != "ipgeolocation version 2.1.1" {
if output := runCLI(t, home, "--version"); output != "ipgeolocation version 2.1.2" {
t.Fatalf("incorrect version: %s", output)
}
if output := runCLI(t, home, "--help"); !strings.Contains(output, "time-conversion") {
Expand Down
4 changes: 2 additions & 2 deletions cmd/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestReleaseGuardsKeepExistingArtifacts(t *testing.T) {
for _, tc := range []struct{ compiler, tag, want string }{
{"go1.24.5", "", "Use Go"}, {"go1.26.9", "", "Use Go"}, {"go1.27.2", "", "Use Go"},
{"go1.28rc1", "", "Use Go"}, {"go1.26.10", "v2.1.0", "Release tag must match"},
{"go1.26.10", "v2.1.1", "checks-started"}, {"go1.27.3", "", "checks-started"}, {"go1.28.0", "", "checks-started"},
{"go1.26.10", "v2.1.2", "checks-started"}, {"go1.27.3", "", "checks-started"}, {"go1.28.0", "", "checks-started"},
} {
t.Run(tc.compiler+tc.tag, func(t *testing.T) {
dir := t.TempDir()
Expand All @@ -29,7 +29,7 @@ func TestReleaseGuardsKeepExistingArtifacts(t *testing.T) {
}
for name, content := range map[string]string{
"build-release.sh": string(source), "dist/keep": "existing archive",
"go": "#!/bin/sh\ncase \"$1\" in\nenv) echo \"$TEST_COMPILER\";;\nrun) echo 'ipgeolocation version 2.1.1';;\n*) echo checks-started >&2; exit 7;;\nesac\n",
"go": "#!/bin/sh\ncase \"$1\" in\nenv) echo \"$TEST_COMPILER\";;\nrun) echo 'ipgeolocation version 2.1.2';;\n*) echo checks-started >&2; exit 7;;\nesac\n",
"govulncheck": "#!/bin/sh\nexit 0\n",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0700); err != nil {
Expand Down
150 changes: 150 additions & 0 deletions cmd/request_limits_http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cmd

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
)

func TestCLIRequestLimitsHTTP(t *testing.T) {
if os.Getenv("CLI_HTTP_TEST") != "1" {
t.Skip("requires the local HTTP simulation")
}
for _, key := range []string{"SLOW_HEADERS", "SLOW_BODY"} {
home := configuredHome(t, key)
for _, args := range [][]string{{"ipgeo"}, {"parse-user-agent", "--user-agent", "test"}} {
output := runCLI(t, home, append(args, "--timeout", "100ms")...)
if !strings.Contains(output, "request timed out") || strings.Contains(output, key) || strings.Contains(output, `"_request"`) {
t.Fatalf("timeout did not stop safely: %s", output)
}
}
output := runCLI(t, home, "ipgeo", "--timeout", "2s", "--output", "raw")
if !json.Valid([]byte(output)) || !strings.Contains(output, "2001:db8::1") {
t.Fatalf("longer timeout did not complete: %s", output)
}
}
for _, key := range []string{"LIMIT_RESPONSE", "SLOW_BODY"} {
home := configuredHome(t, key)
for _, command := range []string{"bulk-ip-geo", "bulk-ip-security"} {
for _, exists := range []bool{false, true} {
name := fmt.Sprintf("%s-%v", command, exists)
file := filepath.Join(home, name+".json")
if exists {
if err := os.WriteFile(file, []byte("keep existing results"), 0600); err != nil {
t.Fatal(err)
}
}
args := []string{command, "--ips", "8.8.8.8", "--output-file", name}
want := "response exceeds 1 MB"
if key == "LIMIT_RESPONSE" {
args = append(args, "--max-response-mb", "1")
} else {
args = append(args, "--timeout", "100ms")
want = "request timed out"
}
output := runCLI(t, home, args...)
if !strings.Contains(output, want) || strings.Contains(output, key) || strings.Contains(output, `"_request"`) || strings.Contains(output, "Output saved") {
t.Fatalf("failed bulk request produced output: %s", output)
}
data, err := os.ReadFile(file)
if exists {
if err != nil || string(data) != "keep existing results" {
t.Fatalf("failed request changed output file: %v", err)
}
} else if !os.IsNotExist(err) {
t.Fatal("failed request created an output file")
}
}
}
if key == "LIMIT_RESPONSE" {
output := runCLI(t, home, "bulk-ip-geo", "--ips", "8.8.8.8", "--max-response-mb", "2", "--output", "raw", "--output-file", "complete")
if !strings.Contains(output, "Output saved to file:") {
t.Fatal("larger response limit did not complete")
}
data, err := os.ReadFile(filepath.Join(home, "complete.json"))
var result []struct{ Padding string }
if err != nil || json.Unmarshal(data, &result) != nil || len(result) != 1 || len(result[0].Padding) != 1_100_000 {
t.Fatal("larger response limit lost data")
}
}
}
}

func TestCLILargeBulkHTTP(t *testing.T) {
if os.Getenv("CLI_HTTP_TEST") != "1" {
t.Skip("requires the local HTTP simulation")
}
home := configuredHome(t, "BULK_50K")
ips, err := os.Create(filepath.Join(home, "ips.txt"))
if err != nil {
t.Fatal(err)
}
writer := bufio.NewWriter(ips)
for i := 0; i < 50_000; i++ {
fmt.Fprintf(writer, "2001:db8::%x\n", i)
}
if err := writer.Flush(); err != nil {
t.Fatal(err)
}
ips.Close()
for _, command := range []string{"bulk-ip-geo", "bulk-ip-security"} {
t.Run(command, func(t *testing.T) {
output, err := os.Create(filepath.Join(home, command+".stdout"))
if err != nil {
t.Fatal(err)
}
defer output.Close()
args := []string{command, "--file", "ips.txt", "--output", "raw"}
city := "Test City"
if command == "bulk-ip-geo" {
args = append(args, "--lang", "ar")
city = "مدينة"
}
process := newCLIProcess(home, args...)
var stderr bytes.Buffer
process.Stdout, process.Stderr = output, &stderr
if err := process.Run(); err != nil {
t.Fatalf("large bulk request failed: %v %s", err, stderr.String())
}
info, err := output.Stat()
if err != nil || info.Size() < 200_000_000 || info.Size() > 256_000_000 {
t.Fatalf("unexpected large response size: %v %v", info, err)
}
if _, err := output.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
decoder := json.NewDecoder(output)
if token, err := decoder.Token(); err != nil || token != json.Delim('[') {
t.Fatalf("bulk output is not a JSON array: %v %v", token, err)
}
count := 0
for decoder.More() {
var result struct {
IP string
Location struct{ City string }
Company struct{ Name string }
}
if err := decoder.Decode(&result); err != nil {
t.Fatal(err)
}
if result.IP != fmt.Sprintf("2001:db8::%x", count) || result.Location.City != city || len(result.Company.Name) != 4000 {
t.Fatalf("bulk result %d changed", count)
}
count++
}
if token, err := decoder.Token(); err != nil || token != json.Delim(']') || count != 50_000 {
t.Fatalf("bulk output incomplete: count=%d token=%v err=%v", count, token, err)
}
if _, err := decoder.Token(); err != io.EOF {
t.Fatalf("unexpected data after bulk results: %v", err)
}
t.Logf("Verified %d ordered results and %d output bytes with default limits", count, info.Size())
})
}
}
Loading