diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f95218 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + ci: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup Go + uses: actions/setup-go@v6.4.0 + with: + go-version: '1.25.x' + + - name: Setup Dagger + uses: dagger/dagger-for-github@v7.0.6 + + - name: Lint + run: cd ci && dagger run go run . lint + + - name: Test + run: cd ci && dagger run go run . test + + - name: Build multi-arch binaries + run: cd ci && dagger run go run . build diff --git a/.gitignore b/.gitignore index aaadf73..deca5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ go.work.sum # env file .env +# build output +dist/ + # Editor/IDE # .idea/ # .vscode/ + +# local CI helper build artifact +/ci/ci diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76b7973 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod ./ +COPY nextip.go ./ +COPY cmd/next-ip ./cmd/next-ip +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/next-ip ./cmd/next-ip + +FROM scratch +COPY --from=builder /out/next-ip /next-ip +ENTRYPOINT ["/next-ip"] diff --git a/README.md b/README.md index 7791104..0e93255 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ # next-ip -Small binary that allow to get next IPs in a given range + +Small Go binary that returns the next IP address(es) from a CIDR input. + +## Usage + +```bash +go run ./cmd/next-ip --help + +go run ./cmd/next-ip 192.168.100.12/24 +# 192.168.100.13 + +go run ./cmd/next-ip --count 3 192.168.100.13/24 +# 192.168.100.14 +# 192.168.100.15 +# 192.168.100.16 + +go run ./cmd/next-ip --step 2 192.168.100.100/24 +# 192.168.100.102 + +go run ./cmd/next-ip --count 3 --step 3 192.168.100.102/24 +# 192.168.100.105 +# 192.168.100.108 +# 192.168.100.111 +``` + +Flags: +- `--count`, `-c`: number of next IP addresses to output +- `--step`, `-s`: increment step between emitted addresses + +## Docker image + +```bash +docker build -t next-ip . +docker run --rm next-ip 192.168.100.12/24 +``` + +The final image is based on `scratch` and runs a static binary. + +## CI (Dagger) + +The CI workflow (`/.github/workflows/ci.yml`) runs Dagger pipelines for: + +- lint (`go fmt` check + `go vet`) +- tests (`go test ./...`) +- multi-arch builds (`linux/amd64` and `linux/arm64`) diff --git a/ci/go.mod b/ci/go.mod new file mode 100644 index 0000000..e056074 --- /dev/null +++ b/ci/go.mod @@ -0,0 +1,24 @@ +module github.com/hoverkraft-tech/next-ip/ci + +go 1.25.0 + +require dagger.io/dagger v0.20.8 + +require ( + github.com/99designs/gqlgen v0.17.90 // indirect + github.com/Khan/genqlient v0.8.1 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/sosodev/duration v1.4.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.33 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect +) diff --git a/ci/go.sum b/ci/go.sum new file mode 100644 index 0000000..877a1ec --- /dev/null +++ b/ci/go.sum @@ -0,0 +1,51 @@ +dagger.io/dagger v0.20.8 h1:n+Xtzp9ufNwCH3Ftob92Smu3smfUDoQshDYM6Ys4yf0= +dagger.io/dagger v0.20.8/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es= +github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk= +github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI= +github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= +github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +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/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= +github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/ci/main.go b/ci/main.go new file mode 100644 index 0000000..4348529 --- /dev/null +++ b/ci/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "dagger.io/dagger" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: go run ./ci ") + os.Exit(1) + } + + ctx := context.Background() + client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to connect to Dagger: %v\n", err) + os.Exit(1) + } + defer func() { _ = client.Close() }() + + sourceRoot, err := getSourceRoot() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + src := client.Host().Directory(sourceRoot, dagger.HostDirectoryOpts{Exclude: []string{".git", "dist"}}) + + switch os.Args[1] { + case "lint": + err = lint(ctx, client, src) + case "test": + err = test(ctx, client, src) + case "build": + err = build(ctx, client, src, filepath.Join(sourceRoot, "dist")) + default: + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func getSourceRoot() (string, error) { + workingDirectory, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to resolve current directory: %w", err) + } + + return filepath.Clean(filepath.Join(workingDirectory, "..")), nil +} + +func baseContainer(client *dagger.Client, src *dagger.Directory) *dagger.Container { + return client.Container(). + From("golang:1.24-alpine"). + WithEnvVariable("CGO_ENABLED", "0"). + WithMountedDirectory("/src", src). + WithWorkdir("/src") +} + +func lint(ctx context.Context, client *dagger.Client, src *dagger.Directory) error { + _, err := baseContainer(client, src). + WithExec([]string{"sh", "-c", "test -z \"$(gofmt -l .)\""}). + WithExec([]string{"go", "vet", "./..."}). + Sync(ctx) + if err != nil { + return fmt.Errorf("lint failed: %w", err) + } + + return nil +} + +func test(ctx context.Context, client *dagger.Client, src *dagger.Directory) error { + _, err := baseContainer(client, src). + WithExec([]string{"go", "test", "./..."}). + Sync(ctx) + if err != nil { + return fmt.Errorf("tests failed: %w", err) + } + + return nil +} + +func build(ctx context.Context, client *dagger.Client, src *dagger.Directory, distPath string) error { + platforms := []string{"linux/amd64", "linux/arm64"} + artifacts := client.Directory() + + for _, platform := range platforms { + goos, goarch, found := strings.Cut(platform, "/") + if !found { + return fmt.Errorf("invalid platform %q", platform) + } + + container := baseContainer(client, src). + WithEnvVariable("GOOS", goos). + WithEnvVariable("GOARCH", goarch). + WithExec([]string{"go", "build", "-trimpath", "-ldflags=-s -w", "-o", "/tmp/next-ip", "./cmd/next-ip"}) + + fileName := fmt.Sprintf("next-ip-%s", platformToArtifactSuffix(platform)) + artifacts = artifacts.WithFile(fileName, container.File("/tmp/next-ip")) + } + + if _, err := artifacts.Export(ctx, filepath.Clean(distPath)); err != nil { + return fmt.Errorf("failed to export build artifacts: %w", err) + } + + return nil +} + +func platformToArtifactSuffix(platform string) string { + switch platform { + case "linux/amd64": + return "linux-amd64" + case "linux/arm64": + return "linux-arm64" + default: + return platform + } +} diff --git a/cmd/next-ip/main.go b/cmd/next-ip/main.go new file mode 100644 index 0000000..28409bb --- /dev/null +++ b/cmd/next-ip/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "flag" + "fmt" + "os" + + nextip "github.com/hoverkraft-tech/next-ip" +) + +func main() { + var count int + var step int + flag.IntVar(&count, "count", 1, "number of next IP addresses to output") + flag.IntVar(&count, "c", 1, "number of next IP addresses to output (shorthand)") + flag.IntVar(&step, "step", 1, "step used to increase IP addresses") + flag.IntVar(&step, "s", 1, "step used to increase IP addresses (shorthand)") + flag.Parse() + + if flag.NArg() != 1 { + fmt.Fprintln(os.Stderr, "usage: next-ip [--count N|-c N] [--step N|-s N] ") + os.Exit(1) + } + + ips, err := nextip.NextIPsWithStep(flag.Arg(0), count, step) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + for _, ip := range ips { + fmt.Println(ip.String()) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9010205 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/hoverkraft-tech/next-ip + +go 1.24.0 diff --git a/nextip.go b/nextip.go new file mode 100644 index 0000000..1b60d00 --- /dev/null +++ b/nextip.go @@ -0,0 +1,59 @@ +package nextip + +import ( + "errors" + "fmt" + "net" +) + +var ErrOutOfSubnet = errors.New("next IP is out of subnet") + +func NextIPs(cidr string, count int) ([]net.IP, error) { + return NextIPsWithStep(cidr, count, 1) +} + +func NextIPsWithStep(cidr string, count int, step int) ([]net.IP, error) { + if count <= 0 { + return nil, fmt.Errorf("count must be greater than 0") + } + if step <= 0 { + return nil, fmt.Errorf("step must be greater than 0") + } + + ip, subnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR: %w", err) + } + + current := cloneIP(ip) + result := make([]net.IP, 0, count) + + for range count { + current = incrementIP(current, step) + if !subnet.Contains(current) { + return nil, fmt.Errorf("%w for subnet %s", ErrOutOfSubnet, subnet.String()) + } + result = append(result, cloneIP(current)) + } + + return result, nil +} + +func incrementIP(ip net.IP, step int) net.IP { + next := cloneIP(ip) + for range step { + for i := len(next) - 1; i >= 0; i-- { + next[i]++ + if next[i] != 0 { + break + } + } + } + return next +} + +func cloneIP(ip net.IP) net.IP { + cloned := make(net.IP, len(ip)) + copy(cloned, ip) + return cloned +} diff --git a/nextip_test.go b/nextip_test.go new file mode 100644 index 0000000..6337d89 --- /dev/null +++ b/nextip_test.go @@ -0,0 +1,67 @@ +package nextip + +import ( + "errors" + "testing" +) + +func TestNextIPsReturnsRequestedCount(t *testing.T) { + next, err := NextIPs("192.168.100.13/24", 3) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := []string{"192.168.100.14", "192.168.100.15", "192.168.100.16"} + if len(next) != len(expected) { + t.Fatalf("expected %d IPs, got %d", len(expected), len(next)) + } + + for i, ip := range next { + if ip.String() != expected[i] { + t.Fatalf("unexpected IP at index %d: got %s want %s", i, ip.String(), expected[i]) + } + } +} + +func TestNextIPsWithStepReturnsRequestedCount(t *testing.T) { + next, err := NextIPsWithStep("192.168.100.102/24", 3, 3) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := []string{"192.168.100.105", "192.168.100.108", "192.168.100.111"} + if len(next) != len(expected) { + t.Fatalf("expected %d IPs, got %d", len(expected), len(next)) + } + + for i, ip := range next { + if ip.String() != expected[i] { + t.Fatalf("unexpected IP at index %d: got %s want %s", i, ip.String(), expected[i]) + } + } +} + +func TestNextIPsReturnsErrorWhenOutOfSubnet(t *testing.T) { + _, err := NextIPs("192.168.100.255/24", 1) + if err == nil { + t.Fatal("expected an error") + } + + if !errors.Is(err, ErrOutOfSubnet) { + t.Fatalf("expected ErrOutOfSubnet, got %v", err) + } +} + +func TestNextIPsReturnsErrorForInvalidCount(t *testing.T) { + _, err := NextIPs("192.168.100.1/24", 0) + if err == nil { + t.Fatal("expected an error") + } +} + +func TestNextIPsWithStepReturnsErrorForInvalidStep(t *testing.T) { + _, err := NextIPsWithStep("192.168.100.1/24", 1, 0) + if err == nil { + t.Fatal("expected an error") + } +}