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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ See [Configuration Reference](docs/configuration.md) for all options.
- **Reserved ports** — Exclude specific ports from allocation ([details](docs/reserved-ports.md))
- **Cross-platform** — Linux, macOS, and Windows

### Platform Test Status

| Platform | Test Status |
|----------|-------------|
| macOS | Tested |
| Linux | Tested |
| Windows | Not tested |

## Documentation

| Document | Description |
Expand Down
58 changes: 44 additions & 14 deletions internal/scanner/netstat.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net"
"os/exec"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
Expand All @@ -32,7 +33,7 @@ func NewNetstatPortDetector(logger logger.Logger) *NetstatPortDetector {
func (n *NetstatPortDetector) DetectUsedPorts(ctx context.Context) ([]int, error) {
n.logger.Debug(ctx, "netstatを使用してポートスキャンを開始")

// netstatコマンドを実行(macOS対応)
// netstatコマンドを実行
cmd := exec.CommandContext(ctx, "netstat", "-an")
output, err := cmd.Output()
if err != nil {
Expand Down Expand Up @@ -117,11 +118,7 @@ func (n *NetstatPortDetector) IsPortInUse(ctx context.Context, port int) (bool,
func (n *NetstatPortDetector) parseNetstatOutput(output string) ([]int, error) {
lines := strings.Split(output, "\n")
ports := make(map[int]bool) // 重複を避けるためにmapを使用

// macOS/BSD系のnetstat出力形式に対応する正規表現
// 例1: tcp46 0 0 *.8080 *.* LISTEN
// 例2: tcp4 0 0 127.0.0.1.3333 *.* LISTEN
re := regexp.MustCompile(`(?:tcp|udp)\S*\s+\d+\s+\d+\s+(?:\*|\d+\.\d+\.\d+\.\d+)\.(\d+)\s+.*LISTEN`)
re := n.listenLineRegexByCurrentOS()

for _, line := range lines {
// LISTENステートのみを対象とする
Expand All @@ -130,15 +127,16 @@ func (n *NetstatPortDetector) parseNetstatOutput(output string) ([]int, error) {
}

matches := re.FindStringSubmatch(line)
if len(matches) >= 2 {
portStr := matches[1]
port, err := strconv.Atoi(portStr)
if err != nil {
// ポート番号の変換に失敗した場合はスキップ
continue
}
ports[port] = true
if len(matches) < 2 {
continue
}

port, err := strconv.Atoi(matches[1])
if err != nil {
// ポート番号の変換に失敗した場合はスキップ
continue
}
ports[port] = true
}

// mapからスライスに変換
Expand All @@ -150,6 +148,38 @@ func (n *NetstatPortDetector) parseNetstatOutput(output string) ([]int, error) {
return result, nil
}

var (
// macOS/BSD系のnetstat出力形式
// 例1: tcp46 0 0 *.8080 *.* LISTEN
// 例2: tcp4 0 0 127.0.0.1.3333 *.* LISTEN
netstatListenRegexBSD = regexp.MustCompile(`(?:tcp|udp)\S*\s+\d+\s+\d+\s+(?:\*|\d+\.\d+\.\d+\.\d+)\.(\d+)\s+.*LISTEN`)

// Linux系のnetstat出力形式
// 例1: tcp 0 0 0.0.0.0:11211 0.0.0.0:* LISTEN
// 例2: tcp6 0 0 :::11211 :::* LISTEN
netstatListenRegexLinux = regexp.MustCompile(`(?:tcp|udp)\S*\s+\d+\s+\d+\s+\S+:(\d+)\s+.*LISTEN`)
)

// listenLineRegexByCurrentOS は実行OSごとの netstat LISTEN 行パーサを返します。
func (n *NetstatPortDetector) listenLineRegexByCurrentOS() *regexp.Regexp {
return netstatListenRegexByOS(runtime.GOOS)
}

// netstatListenRegexByOS はOSごとに対応する netstat LISTEN 行パーサを返します。
func netstatListenRegexByOS(goos string) *regexp.Regexp {
switch goos {
case "linux":
return netstatListenRegexLinux
case "darwin", "freebsd", "openbsd", "netbsd":
return netstatListenRegexBSD
case "windows":
// Windowsは未検証のため、Linux形式をベースにベストエフォートで解析する
return netstatListenRegexLinux
default:
return netstatListenRegexBSD
}
}

// PortAllocatorImpl はポート割り当ての実装です。
type PortAllocatorImpl struct {
detector PortDetector
Expand Down
62 changes: 55 additions & 7 deletions internal/scanner/netstat_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package scanner

import (
"runtime"
"sort"
"testing"

Expand All @@ -9,26 +10,31 @@ import (

func TestParseNetstatOutput(t *testing.T) {
testLogger := testutil.NewTestLogger()
runningStyle := netstatStyleForOS(runtime.GOOS)

tests := []struct {
name string
style string
input string
expectedPorts []int
}{
{
name: "empty output returns empty ports",
style: "any",
input: "",
expectedPorts: []int{},
},
{
name: "single tcp LISTEN line extracts correct port",
name: "single tcp LISTEN line extracts correct port",
style: "bsd",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.8080 *.* LISTEN`,
expectedPorts: []int{8080},
},
{
name: "multiple LISTEN lines with duplicate are deduplicated",
name: "multiple LISTEN lines with duplicate are deduplicated",
style: "bsd",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.8080 *.* LISTEN
Expand All @@ -37,7 +43,8 @@ tcp4 0 0 127.0.0.1.3000 *.* LISTEN`,
expectedPorts: []int{3000, 8080},
},
{
name: "ignore non-LISTEN lines like ESTABLISHED and TIME_WAIT",
name: "ignore non-LISTEN lines like ESTABLISHED and TIME_WAIT",
style: "bsd",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.8080 *.* LISTEN
Expand All @@ -46,21 +53,44 @@ tcp4 0 0 192.168.1.5.52342 93.184.216.34.80 TIME_WAIT`,
expectedPorts: []int{8080},
},
{
name: "tcp46 format is parsed correctly",
name: "tcp46 format is parsed correctly",
style: "bsd",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp46 0 0 *.9090 *.* LISTEN`,
expectedPorts: []int{9090},
},
{
name: "udp LISTEN line is parsed correctly",
name: "udp LISTEN line is parsed correctly",
style: "bsd",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
udp4 0 0 127.0.0.1.5353 *.* LISTEN`,
expectedPorts: []int{5353},
},
{
name: "no LISTEN lines at all returns empty ports",
name: "linux netstat format parses ipv4 and ipv6 LISTEN lines",
style: "linux",
input: `tcp 0 0 0.0.0.0:11211 0.0.0.0:* LISTEN
tcp6 0 0 :::11211 :::* LISTEN`,
expectedPorts: []int{11211},
},
{
name: "linux ipv6 single LISTEN line is parsed correctly",
style: "linux",
input: `tcp6 0 0 :::443 :::* LISTEN`,
expectedPorts: []int{443},
},
{
name: "linux format ignores non-LISTEN and keeps LISTEN port only",
style: "linux",
input: `tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN
tcp 0 0 10.0.2.15:52341 93.184.216.34:443 ESTABLISHED`,
expectedPorts: []int{8080},
},
{
name: "no LISTEN lines at all returns empty ports",
style: "any",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 192.168.1.5.52341 93.184.216.34.443 ESTABLISHED
Expand All @@ -69,7 +99,8 @@ tcp4 0 0 192.168.1.5.52343 93.184.216.34.80 CLOSE_WAIT`,
expectedPorts: []int{},
},
{
name: "mixed LISTEN and non-LISTEN returns only LISTEN ports",
name: "mixed LISTEN and non-LISTEN returns only LISTEN ports",
style: "bsd",
input: `Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 127.0.0.1.5432 *.* LISTEN
Expand All @@ -84,6 +115,10 @@ tcp4 0 0 192.168.1.5.52343 93.184.216.34.80 CLOSE_WAIT`,

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.style != "any" && tt.style != runningStyle {
t.Skipf("skipping %s-only case on %s", tt.style, runningStyle)
}

detector := NewNetstatPortDetector(testLogger)
ports, err := detector.parseNetstatOutput(tt.input)
if err != nil {
Expand All @@ -110,3 +145,16 @@ tcp4 0 0 192.168.1.5.52343 93.184.216.34.80 CLOSE_WAIT`,
})
}
}

func netstatStyleForOS(goos string) string {
switch goos {
case "linux":
return "linux"
case "windows":
return "linux"
case "darwin", "freebsd", "openbsd", "netbsd":
return "bsd"
default:
return "bsd"
}
}
32 changes: 24 additions & 8 deletions testdata/gopose/gopose_e2e_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,33 @@ steps:
- desc: "Port conflict resolution works"
exec:
command: |
ROOT_DIR=$(pwd)
PID_FILE=/tmp/gopose_test.pid
OCCUPY_BIN="$ROOT_DIR/occupy_port"
FIXTURE_DIR="$ROOT_DIR/fixtures"

cleanup() {
if [ -f "$PID_FILE" ]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
rm -f "$PID_FILE"
fi
rm -f "$OCCUPY_BIN" "$FIXTURE_DIR/{{ vars.override_file }}"
}
trap cleanup EXIT

# Build and run Go port occupier
go build -o occupy_port occupy_port.go
./occupy_port 8080 > /dev/null 2>&1 & echo $! > /tmp/gopose_test.pid
go build -o "$OCCUPY_BIN" occupy_port.go
"$OCCUPY_BIN" 8080 > /dev/null 2>&1 & echo $! > "$PID_FILE"
sleep 2

# Test conflict resolution
cd fixtures
../../../gopose up -f port-conflict.yml --verbose

# Cleanup
kill $(cat /tmp/gopose_test.pid) 2>/dev/null || true
rm -f /tmp/gopose_test.pid {{ vars.override_file }} occupy_port
cd "$FIXTURE_DIR"
output=$(../../../gopose up -f port-conflict.yml --verbose 2>&1)
echo "$output"

# Assert port conflicts are actually detected and reported
echo "$output" | grep -q "Port Conflicts:"
echo "$output" | grep -q "SERVICE"
echo "$output" | grep -q "web1"
test: |
current.exit_code == 0