Skip to content

feat: Add rate limiting, configurable workers and network optimizations#24

Merged
curantes merged 2 commits into
masterfrom
feat/improve-scanning
Jan 27, 2026
Merged

feat: Add rate limiting, configurable workers and network optimizations#24
curantes merged 2 commits into
masterfrom
feat/improve-scanning

Conversation

@curantes

@curantes curantes commented Jan 21, 2026

Copy link
Copy Markdown
Member
- Add rate limiting for HTTP probes (default 50/sec via -scanratelimit)
- Make worker count configurable (default 16 via -workers)
- Skip network and broadcast addresses by default (-skipnetworkbroadcast)
- Use context-aware DNS lookups for proper cancellation
- Change missing PTR record log from error to debug level
- Add summary logging of discovered exporters
- Update .goreleaser.yaml to v2 format
- Update README with new configuration option

mfld
mfld previously approved these changes Jan 21, 2026
Comment thread main.go Outdated
totalLive += len(hosts)
}
}
logrus.Infof("Port scan complete: found %d hosts with port %s open across all networks", totalLive, config.ExpoterExporterPort)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ostrukturerad log, kanske nåt sånt här:

logrus.WithFields(logrus.Fields{
	"hosts_with_open_port": totalLive,
	"port":                 config.ExpoterExporterPort,
	"scan_type":            "port_scan",
	"status":               "complete",
}).Info("Port scan completed")

Samma för alla andra loggrader

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces Layer 2 ARP scanning with Layer 3 TCP port scanning to enable network discovery across routers. It introduces a two-phase discovery approach: first scanning for open TCP ports, then probing only those hosts with HTTP requests to reduce network traffic by 80-90%.

Changes:

  • Added TCP port scanner with no external dependencies (new portscan.go)
  • Implemented two-phase discovery: TCP connect scan followed by selective HTTP probing
  • Added network optimization features including rate limiting, reduced default workers (128→16), and automatic network/broadcast address skipping
  • Added custom DNS server support for PTR lookups
  • Enhanced logging with discovery summaries and phase-specific messages
  • Updated build configuration (.goreleaser.yaml v2, .env support in Makefile)

Reviewed changes

Copilot reviewed 7 out of 9 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
portscan.go New TCP port scanner implementation with concurrent worker pool and context support
models.go Added configuration fields for workers, rate limiting, port scanning, and DNS server
main.go Integrated two-phase discovery, rate limiting, custom DNS lookups, and helper functions for network/broadcast filtering
go.mod, go.sum Added golang.org/x/time dependency for rate limiting
README.md Comprehensive documentation of new features, configuration options, and usage examples
Makefile Simplified with .env file support for environment variables
.goreleaser.yaml Updated to v2 format with cosign bundle support
.gitignore Added .env, output/, and build artifacts

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.go Outdated
return d.DialContext(ctx, "udp", dnsServer)
},
}
return resolver.LookupAddr(context.Background(), ip)

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lookupPTR function creates a new background context instead of accepting a context parameter. This prevents proper cancellation propagation - if the parent context is cancelled, DNS lookups will continue running. Consider adding a context parameter to the function and using it for the LookupAddr call.

Suggested change
return resolver.LookupAddr(context.Background(), ip)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return resolver.LookupAddr(ctx, ip)

Copilot uses AI. Check for mistakes.
Comment thread Makefile Outdated
# Sets DOCKER_HOST to the Podman socket if it exists, otherwise defaults to the Docker socket
export DOCKER_HOST := unix://$(or $(wildcard /run/user/$(shell id -u)/podman/podman.sock),/var/run/docker.sock)

include .env

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Makefile unconditionally includes .env file with 'include .env', but .env is in .gitignore and may not exist. This will cause make to fail if the .env file doesn't exist. Consider using '-include .env' (with a hyphen) to make the include optional and prevent errors when the file is missing.

Suggested change
include .env
-include .env

Copilot uses AI. Check for mistakes.
Comment thread models.go
Comment on lines +24 to +25
// SkipNetworkBroadcast skips network and broadcast addresses. Default true
SkipNetworkBroadcast bool `default:"true"`

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SkipNetworkBroadcast configuration field is defined but never used in the code. Network and broadcast addresses are always skipped in both discoverNetwork and ScanNetwork functions. Either remove this unused configuration field or implement conditional skipping behavior based on its value.

Suggested change
// SkipNetworkBroadcast skips network and broadcast addresses. Default true
SkipNetworkBroadcast bool `default:"true"`

Copilot uses AI. Check for mistakes.
Comment thread main.go Outdated
skippedCount := 0
for ip := networkip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
// Skip network and broadcast addresses - these will never have hosts
// and cause unnecessary ARP requests

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment references "unnecessary ARP requests" but this PR replaces ARP scanning with TCP port scanning. Update the comment to be accurate, such as "these will never have hosts and cause unnecessary connection attempts" or simply remove the reference to ARP requests.

Suggested change
// and cause unnecessary ARP requests
// and cause unnecessary connection attempts

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +40 to +44
| `-skipnetworkbroadcast` | true | Skip network and broadcast addresses |
| `-exporterexporterport` | 9999 | Port where exporter_exporter is listening |
| `-port` | 8080 | Port for the internal webserver |
| `-dnsserver` | (empty) | DNS server for PTR lookups (e.g., "8.8.8.8:53"), empty uses OS default |

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation describes the skipnetworkbroadcast flag, but the corresponding SkipNetworkBroadcast configuration field is not used in the code. Network and broadcast addresses are always skipped unconditionally. Either remove this documentation or implement the conditional behavior in the code.

Suggested change
| `-skipnetworkbroadcast` | true | Skip network and broadcast addresses |
| `-exporterexporterport` | 9999 | Port where exporter_exporter is listening |
| `-port` | 8080 | Port for the internal webserver |
| `-dnsserver` | (empty) | DNS server for PTR lookups (e.g., "8.8.8.8:53"), empty uses OS default |
| `-exporterexporterport` | 9999 | Port where exporter_exporter is listening |
| `-port` | 8080 | Port for the internal webserver |
| `-dnsserver` | (empty) | DNS server for PTR lookups (e.g., "8.8.8.8:53"), empty uses OS default |

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +97 to +98
-skipnetworkbroadcast
Change value of SkipNetworkBroadcast. (default true)

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation describes the skipnetworkbroadcast flag, but the corresponding SkipNetworkBroadcast configuration field is not used in the code. Network and broadcast addresses are always skipped unconditionally. Either remove this documentation or implement the conditional behavior in the code.

Copilot uses AI. Check for mistakes.
Comment thread main.go Outdated
Comment on lines +236 to +241
// isNetworkOrBroadcast checks if the IP is the network address or broadcast address of the given subnet
func isNetworkOrBroadcast(ip net.IP, ipnet *net.IPNet) bool {
ip = ip.To4()
if ip == nil {
return false
}

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isNetworkOrBroadcast function only handles IPv4 addresses and returns false for IPv6. This means IPv6 networks won't have the network address filtered out. Consider documenting this IPv4-only limitation in the function comment or adding IPv6 support if needed.

Copilot uses AI. Check for mistakes.
Comment thread main.go Outdated
}
}

// discoverHosts probes a specific list of IPs (from ARP scan results)

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment references "ARP scan results" but this PR replaces ARP scanning with TCP port scanning. The comment should be updated to reference "TCP port scan results" or "port scan results" for accuracy.

Suggested change
// discoverHosts probes a specific list of IPs (from ARP scan results)
// discoverHosts probes a specific list of IPs (from port scan results)

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (1)

main.go:373

  • The logic inside the queue function for discoverHosts (lines 332-373) is nearly identical to the logic in discoverNetwork (lines 278-320). This code duplication makes maintenance harder. Consider extracting the common logic into a separate function that both can call, passing only the necessary parameters like ip, port, network, ctx, config, and exporter channel.
		queue <- func(ctx context.Context) {
			for _, data := range exporterConfig {
				if ctx.Err() != nil {
					return
				}
				port := data.port
				logrus.Debugf("probing live host: %s:%s", ip, port)
				exporters, err := checkExporterExporter(ctx, ip, port)
				if err != nil {
					if !errors.Is(err, context.DeadlineExceeded) && !IsTimeout(err) {
						logrus.Debugf("error fetching from exporter_exporter: %s", err)
					}
					logrus.Debugf("%s:%s exporter_exporter not responding", ip, port)
					continue
				}

				logrus.Info(net.JoinHostPort(ip, port), " is alive")
				addr, _ := lookupPTR(ctx, ip, config.DNSServer) // #nosec
				hostname := strings.TrimRight(getFirst(addr), ".")
				if hostname == "" {
					logrus.Debugf("skipping %s: missing reverse record", ip)
					continue
				}
				if isVip(hostname) && !strings.HasPrefix(hostname, "k8s-") {
					logrus.Info("skipping vip ", hostname, ip)
					continue
				}

				if len(exporters) > 0 {
					for _, filename := range exporters {
						a := Address{
							IP:       strings.TrimSpace(ip),
							Hostname: strings.TrimSpace(hostname),
							Subnet:   strings.TrimSpace(network),
							Exporter: filename,
							Port:     port,
						}
						exporter <- &a
					}
				}
			}
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread main.go Outdated
// Use custom DNS server
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context parameter 'network' in the Dial function shadows the 'network' parameter from the outer scope. While this code works correctly (the inner 'network' parameter is intentionally unused as you're always using UDP), consider renaming one of them to avoid confusion and follow best practices. For example, rename the inner parameter to underscore or netType.

Suggested change
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
Dial: func(ctx context.Context, _ , address string) (net.Conn, error) {

Copilot uses AI. Check for mistakes.
Comment thread main.go Outdated
Comment on lines +207 to +210
logrus.Debugf("exporter %s: %d targets", name, len(addresses))
totalTargets += len(addresses)
}
logrus.Debugf("found %d unique exporters with %d total targets", len(exporters), totalTargets)

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The summary logging uses Debug level when it should arguably be Info level. This information about the total number of discovered exporters and targets is valuable operational data that operators would want to see by default. Consider changing these from Debugf to Infof to make them visible at the default log level.

Suggested change
logrus.Debugf("exporter %s: %d targets", name, len(addresses))
totalTargets += len(addresses)
}
logrus.Debugf("found %d unique exporters with %d total targets", len(exporters), totalTargets)
logrus.Infof("exporter %s: %d targets", name, len(addresses))
totalTargets += len(addresses)
}
logrus.Infof("found %d unique exporters with %d total targets", len(exporters), totalTargets)

Copilot uses AI. Check for mistakes.
Comment thread portscan.go Outdated
Comment on lines +38 to +44
for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
ipCopy := make(net.IP, len(ip))
copy(ipCopy, ip)
if skipNetworkBroadcast && isNetworkOrBroadcast(ipCopy, ipnet) {
continue
}
ips = append(ips, ipCopy.String())

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inc function mutates the IP address in-place. Since you're creating a copy in the next lines, consider moving the inc(ip) call after the copy to avoid potential confusion. The current code is correct but the order could be clearer: first copy the current IP, check and add it, then increment the original for the next iteration.

Suggested change
for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
ipCopy := make(net.IP, len(ip))
copy(ipCopy, ip)
if skipNetworkBroadcast && isNetworkOrBroadcast(ipCopy, ipnet) {
continue
}
ips = append(ips, ipCopy.String())
for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); {
ipCopy := make(net.IP, len(ip))
copy(ipCopy, ip)
if skipNetworkBroadcast && isNetworkOrBroadcast(ipCopy, ipnet) {
inc(ip)
continue
}
ips = append(ips, ipCopy.String())
inc(ip)

Copilot uses AI. Check for mistakes.
Comment thread portscan.go Outdated
var resultsMu sync.Mutex

// Worker pool
ipChan := make(chan string, len(ips))

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel ipChan is created with a fixed size equal to len(ips). For large CIDR blocks (e.g., /16 or /8), this could allocate a very large buffer in memory. Consider using a smaller fixed buffer size (e.g., 1000) or using s.concurrency * 2 to limit memory usage while still allowing efficient work distribution.

Suggested change
ipChan := make(chan string, len(ips))
bufferSize := s.concurrency * 2
if bufferSize < 1 {
bufferSize = 1
}
if bufferSize > len(ips) {
bufferSize = len(ips)
}
ipChan := make(chan string, bufferSize)

Copilot uses AI. Check for mistakes.
Comment thread portscan.go Outdated
Comment on lines +1 to +127
package main

import (
"context"
"net"
"strings"
"sync"
"time"

"github.com/sirupsen/logrus"
)

// PortScanner provides methods for discovering live hosts by checking if a TCP port is open
type PortScanner struct {
timeout time.Duration
port string
concurrency int
}

// NewPortScanner creates a new TCP port scanner
func NewPortScanner(timeout time.Duration, port string, concurrency int) *PortScanner {
return &PortScanner{
timeout: timeout,
port: port,
concurrency: concurrency,
}
}

// ScanNetwork performs a TCP port scan on the given network CIDR and returns IPs with open port
func (s *PortScanner) ScanNetwork(ctx context.Context, network string, skipNetworkBroadcast bool) ([]string, error) {
_, ipnet, err := net.ParseCIDR(network)
if err != nil {
return nil, err
}

// Generate all IPs to scan
var ips []string
for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
ipCopy := make(net.IP, len(ip))
copy(ipCopy, ip)
if skipNetworkBroadcast && isNetworkOrBroadcast(ipCopy, ipnet) {
continue
}
ips = append(ips, ipCopy.String())
}

// Results
var results []string
var resultsMu sync.Mutex

// Worker pool
ipChan := make(chan string, len(ips))
var wg sync.WaitGroup

// Start workers
for i := 0; i < s.concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
dialer := &net.Dialer{Timeout: s.timeout}

for {
select {
case <-ctx.Done():
return
case ip, ok := <-ipChan:
if !ok {
return
}

addr := net.JoinHostPort(ip, s.port)
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
// Port closed or host unreachable - this is expected for most IPs
continue
}
conn.Close()

// Port is open!
resultsMu.Lock()
results = append(results, ip)
resultsMu.Unlock()
logrus.Debugf("TCP port %s open on %s", s.port, ip)
}
}
}()
}

// Feed IPs to workers
for _, ip := range ips {
select {
case <-ctx.Done():
close(ipChan)
wg.Wait()
return results, ctx.Err()
case ipChan <- ip:
}
}
close(ipChan)
wg.Wait()

return results, nil
}

// DiscoverLiveHosts scans multiple networks and returns hosts with the target port open
func (s *PortScanner) DiscoverLiveHosts(ctx context.Context, networks []string, skipNetworkBroadcast bool) map[string][]string {
results := make(map[string][]string)

for _, network := range networks {
network = strings.TrimSpace(network)
if network == "" {
continue
}

logrus.Infof("TCP scanning port %s on network: %s", s.port, network)
hosts, err := s.ScanNetwork(ctx, network, skipNetworkBroadcast)
if err != nil {
logrus.Warnf("TCP scan failed for %s: %v", network, err)
results[network] = nil
} else {
logrus.Infof("Found %d hosts with port %s open in %s", len(hosts), s.port, network)
results[network] = hosts
}
}

return results
}

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new PortScanner functionality lacks test coverage. Consider adding tests for ScanNetwork and DiscoverLiveHosts methods, including edge cases like empty networks, invalid CIDRs, context cancellation, and network/broadcast address skipping logic.

Copilot uses AI. Check for mistakes.
Comment thread Makefile
go run .

test:
go test ./... -count=1 -cover

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lint and test-coverage targets were removed from the Makefile. While these may have been unused, removing lint checks could reduce code quality enforcement. Consider keeping the lint target or documenting why it was removed in the PR description.

Suggested change
go test ./... -count=1 -cover
go test ./... -count=1 -cover
lint:
go vet ./...
test-coverage:
go test ./... -count=1 -coverprofile=coverage.out

Copilot uses AI. Check for mistakes.
Comment thread main.go Outdated
Comment on lines +93 to +94
d := net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, "udp", dnsServer)

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DNS query timeout is hardcoded to 5 seconds. Consider making this configurable via a Config field, especially since other timeouts like PortScanTimeout are already configurable. Some networks may need shorter or longer DNS timeouts depending on their DNS infrastructure.

Copilot uses AI. Check for mistakes.
@jonaz

jonaz commented Jan 22, 2026

Copy link
Copy Markdown
Member

Jag förstår inte riktigt vad AIn har gjort eller varför?
Den tcp ansluter mot porten istället för att prova att göra ett http anrop direkt mot porten?

Dessutom säger den att den ARP scannade förut? det gjorde den ju inte? Den anslöt med TCP vilket i sin tur såklart gör ett arp uppslag ifall kerneln inte känner till mac adressen för IPt. Dvs det inte finns. Men BARA på det lokala layer2'et.

Så resultatet betyder att den kommer anropa alla hostar som har porten öppen dubbelt nu istället? En gång i tcp och en gång med http klienten?

AIn har alltså bara gjort koden sämre?

@jonaz
jonaz self-requested a review January 22, 2026 08:33
@curantes
curantes force-pushed the feat/improve-scanning branch from 5a740c5 to e433656 Compare January 22, 2026 10:59
@curantes curantes changed the title feat: Replace ARP scanning with TCP port scanner and add network opti… feat: Add rate limiting, configurable workers and network optimizations Jan 22, 2026
@curantes

Copy link
Copy Markdown
Member Author

Jag förstår inte riktigt vad AIn har gjort eller varför? Den tcp ansluter mot porten istället för att prova att göra ett http anrop direkt mot porten?

Dessutom säger den att den ARP scannade förut? det gjorde den ju inte? Den anslöt med TCP vilket i sin tur såklart gör ett arp uppslag ifall kerneln inte känner till mac adressen för IPt. Dvs det inte finns. Men BARA på det lokala layer2'et.

Så resultatet betyder att den kommer anropa alla hostar som har porten öppen dubbelt nu istället? En gång i tcp och en gång med http klienten?

AIn har alltså bara gjort koden sämre?

Tack Jonas för en bra review. Din observation är helt korrekt. Tog bort de saker som gjorde koden sämre och behöll de verkliga förbättringarna.

Är tacksam för ett bar nya ögon på den senaste force pushen.

Comment thread README.md Outdated
- Add rate limiting for HTTP probes (default 50/sec via -scanratelimit)
- Make worker count configurable (default 16 via -workers)
- Skip network and broadcast addresses by default (-skipnetworkbroadcast)
- Use context-aware DNS lookups for proper cancellation
- Change missing PTR record log from error to debug level
- Add summary logging of discovered exporters
- Fix typo in configuration field (Expoter -> Exporter)
- Update .goreleaser.yaml to v2 format
- Update README with new configuration options
@curantes
curantes force-pushed the feat/improve-scanning branch from e433656 to 4d0ac8a Compare January 22, 2026 12:08
@itsachance
itsachance self-requested a review January 22, 2026 12:54
@curantes
curantes requested a review from tsfcode January 22, 2026 13:02
Comment thread main.go
return ipv4.Equal(networkIP) || ipv4.Equal(broadcast)
}

// For IPv6, check if it's the network address (IPv6 doesn't have broadcast)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Använder vi ens IPv6?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nej bara ipv4

@jonaz

jonaz commented Jan 22, 2026 via email

Copy link
Copy Markdown
Member

itsachance
itsachance previously approved these changes Jan 23, 2026
Comment thread main.go Outdated
@curantes
curantes merged commit c88145b into master Jan 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants