Skip to content

Commit 525940c

Browse files
authored
Merge pull request #43 from aaaaninja/feature/linux-support
Linux環境における `netstat -an` 出力形式に対応したポート検出正規表現を追加
2 parents 4912acf + 2c53ca1 commit 525940c

File tree

4 files changed

+131
-29
lines changed

4 files changed

+131
-29
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ See [Configuration Reference](docs/configuration.md) for all options.
122122
- **Reserved ports** — Exclude specific ports from allocation ([details](docs/reserved-ports.md))
123123
- **Cross-platform** — Linux, macOS, and Windows
124124

125+
### Platform Test Status
126+
127+
| Platform | Test Status |
128+
|----------|-------------|
129+
| macOS | Tested |
130+
| Linux | Tested |
131+
| Windows | Not tested |
132+
125133
## Documentation
126134

127135
| Document | Description |

internal/scanner/netstat.go

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net"
77
"os/exec"
88
"regexp"
9+
"runtime"
910
"sort"
1011
"strconv"
1112
"strings"
@@ -32,7 +33,7 @@ func NewNetstatPortDetector(logger logger.Logger) *NetstatPortDetector {
3233
func (n *NetstatPortDetector) DetectUsedPorts(ctx context.Context) ([]int, error) {
3334
n.logger.Debug(ctx, "netstatを使用してポートスキャンを開始")
3435

35-
// netstatコマンドを実行(macOS対応)
36+
// netstatコマンドを実行
3637
cmd := exec.CommandContext(ctx, "netstat", "-an")
3738
output, err := cmd.Output()
3839
if err != nil {
@@ -117,11 +118,7 @@ func (n *NetstatPortDetector) IsPortInUse(ctx context.Context, port int) (bool,
117118
func (n *NetstatPortDetector) parseNetstatOutput(output string) ([]int, error) {
118119
lines := strings.Split(output, "\n")
119120
ports := make(map[int]bool) // 重複を避けるためにmapを使用
120-
121-
// macOS/BSD系のnetstat出力形式に対応する正規表現
122-
// 例1: tcp46 0 0 *.8080 *.* LISTEN
123-
// 例2: tcp4 0 0 127.0.0.1.3333 *.* LISTEN
124-
re := regexp.MustCompile(`(?:tcp|udp)\S*\s+\d+\s+\d+\s+(?:\*|\d+\.\d+\.\d+\.\d+)\.(\d+)\s+.*LISTEN`)
121+
re := n.listenLineRegexByCurrentOS()
125122

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

132129
matches := re.FindStringSubmatch(line)
133-
if len(matches) >= 2 {
134-
portStr := matches[1]
135-
port, err := strconv.Atoi(portStr)
136-
if err != nil {
137-
// ポート番号の変換に失敗した場合はスキップ
138-
continue
139-
}
140-
ports[port] = true
130+
if len(matches) < 2 {
131+
continue
141132
}
133+
134+
port, err := strconv.Atoi(matches[1])
135+
if err != nil {
136+
// ポート番号の変換に失敗した場合はスキップ
137+
continue
138+
}
139+
ports[port] = true
142140
}
143141

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

151+
var (
152+
// macOS/BSD系のnetstat出力形式
153+
// 例1: tcp46 0 0 *.8080 *.* LISTEN
154+
// 例2: tcp4 0 0 127.0.0.1.3333 *.* LISTEN
155+
netstatListenRegexBSD = regexp.MustCompile(`(?:tcp|udp)\S*\s+\d+\s+\d+\s+(?:\*|\d+\.\d+\.\d+\.\d+)\.(\d+)\s+.*LISTEN`)
156+
157+
// Linux系のnetstat出力形式
158+
// 例1: tcp 0 0 0.0.0.0:11211 0.0.0.0:* LISTEN
159+
// 例2: tcp6 0 0 :::11211 :::* LISTEN
160+
netstatListenRegexLinux = regexp.MustCompile(`(?:tcp|udp)\S*\s+\d+\s+\d+\s+\S+:(\d+)\s+.*LISTEN`)
161+
)
162+
163+
// listenLineRegexByCurrentOS は実行OSごとの netstat LISTEN 行パーサを返します。
164+
func (n *NetstatPortDetector) listenLineRegexByCurrentOS() *regexp.Regexp {
165+
return netstatListenRegexByOS(runtime.GOOS)
166+
}
167+
168+
// netstatListenRegexByOS はOSごとに対応する netstat LISTEN 行パーサを返します。
169+
func netstatListenRegexByOS(goos string) *regexp.Regexp {
170+
switch goos {
171+
case "linux":
172+
return netstatListenRegexLinux
173+
case "darwin", "freebsd", "openbsd", "netbsd":
174+
return netstatListenRegexBSD
175+
case "windows":
176+
// Windowsは未検証のため、Linux形式をベースにベストエフォートで解析する
177+
return netstatListenRegexLinux
178+
default:
179+
return netstatListenRegexBSD
180+
}
181+
}
182+
153183
// PortAllocatorImpl はポート割り当ての実装です。
154184
type PortAllocatorImpl struct {
155185
detector PortDetector

internal/scanner/netstat_test.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package scanner
22

33
import (
4+
"runtime"
45
"sort"
56
"testing"
67

@@ -9,26 +10,31 @@ import (
910

1011
func TestParseNetstatOutput(t *testing.T) {
1112
testLogger := testutil.NewTestLogger()
13+
runningStyle := netstatStyleForOS(runtime.GOOS)
1214

1315
tests := []struct {
1416
name string
17+
style string
1518
input string
1619
expectedPorts []int
1720
}{
1821
{
1922
name: "empty output returns empty ports",
23+
style: "any",
2024
input: "",
2125
expectedPorts: []int{},
2226
},
2327
{
24-
name: "single tcp LISTEN line extracts correct port",
28+
name: "single tcp LISTEN line extracts correct port",
29+
style: "bsd",
2530
input: `Active Internet connections (including servers)
2631
Proto Recv-Q Send-Q Local Address Foreign Address (state)
2732
tcp4 0 0 127.0.0.1.8080 *.* LISTEN`,
2833
expectedPorts: []int{8080},
2934
},
3035
{
31-
name: "multiple LISTEN lines with duplicate are deduplicated",
36+
name: "multiple LISTEN lines with duplicate are deduplicated",
37+
style: "bsd",
3238
input: `Active Internet connections (including servers)
3339
Proto Recv-Q Send-Q Local Address Foreign Address (state)
3440
tcp4 0 0 127.0.0.1.8080 *.* LISTEN
@@ -37,7 +43,8 @@ tcp4 0 0 127.0.0.1.3000 *.* LISTEN`,
3743
expectedPorts: []int{3000, 8080},
3844
},
3945
{
40-
name: "ignore non-LISTEN lines like ESTABLISHED and TIME_WAIT",
46+
name: "ignore non-LISTEN lines like ESTABLISHED and TIME_WAIT",
47+
style: "bsd",
4148
input: `Active Internet connections (including servers)
4249
Proto Recv-Q Send-Q Local Address Foreign Address (state)
4350
tcp4 0 0 127.0.0.1.8080 *.* LISTEN
@@ -46,21 +53,44 @@ tcp4 0 0 192.168.1.5.52342 93.184.216.34.80 TIME_WAIT`,
4653
expectedPorts: []int{8080},
4754
},
4855
{
49-
name: "tcp46 format is parsed correctly",
56+
name: "tcp46 format is parsed correctly",
57+
style: "bsd",
5058
input: `Active Internet connections (including servers)
5159
Proto Recv-Q Send-Q Local Address Foreign Address (state)
5260
tcp46 0 0 *.9090 *.* LISTEN`,
5361
expectedPorts: []int{9090},
5462
},
5563
{
56-
name: "udp LISTEN line is parsed correctly",
64+
name: "udp LISTEN line is parsed correctly",
65+
style: "bsd",
5766
input: `Active Internet connections (including servers)
5867
Proto Recv-Q Send-Q Local Address Foreign Address (state)
5968
udp4 0 0 127.0.0.1.5353 *.* LISTEN`,
6069
expectedPorts: []int{5353},
6170
},
6271
{
63-
name: "no LISTEN lines at all returns empty ports",
72+
name: "linux netstat format parses ipv4 and ipv6 LISTEN lines",
73+
style: "linux",
74+
input: `tcp 0 0 0.0.0.0:11211 0.0.0.0:* LISTEN
75+
tcp6 0 0 :::11211 :::* LISTEN`,
76+
expectedPorts: []int{11211},
77+
},
78+
{
79+
name: "linux ipv6 single LISTEN line is parsed correctly",
80+
style: "linux",
81+
input: `tcp6 0 0 :::443 :::* LISTEN`,
82+
expectedPorts: []int{443},
83+
},
84+
{
85+
name: "linux format ignores non-LISTEN and keeps LISTEN port only",
86+
style: "linux",
87+
input: `tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN
88+
tcp 0 0 10.0.2.15:52341 93.184.216.34:443 ESTABLISHED`,
89+
expectedPorts: []int{8080},
90+
},
91+
{
92+
name: "no LISTEN lines at all returns empty ports",
93+
style: "any",
6494
input: `Active Internet connections (including servers)
6595
Proto Recv-Q Send-Q Local Address Foreign Address (state)
6696
tcp4 0 0 192.168.1.5.52341 93.184.216.34.443 ESTABLISHED
@@ -69,7 +99,8 @@ tcp4 0 0 192.168.1.5.52343 93.184.216.34.80 CLOSE_WAIT`,
6999
expectedPorts: []int{},
70100
},
71101
{
72-
name: "mixed LISTEN and non-LISTEN returns only LISTEN ports",
102+
name: "mixed LISTEN and non-LISTEN returns only LISTEN ports",
103+
style: "bsd",
73104
input: `Active Internet connections (including servers)
74105
Proto Recv-Q Send-Q Local Address Foreign Address (state)
75106
tcp4 0 0 127.0.0.1.5432 *.* LISTEN
@@ -84,6 +115,10 @@ tcp4 0 0 192.168.1.5.52343 93.184.216.34.80 CLOSE_WAIT`,
84115

85116
for _, tt := range tests {
86117
t.Run(tt.name, func(t *testing.T) {
118+
if tt.style != "any" && tt.style != runningStyle {
119+
t.Skipf("skipping %s-only case on %s", tt.style, runningStyle)
120+
}
121+
87122
detector := NewNetstatPortDetector(testLogger)
88123
ports, err := detector.parseNetstatOutput(tt.input)
89124
if err != nil {
@@ -110,3 +145,16 @@ tcp4 0 0 192.168.1.5.52343 93.184.216.34.80 CLOSE_WAIT`,
110145
})
111146
}
112147
}
148+
149+
func netstatStyleForOS(goos string) string {
150+
switch goos {
151+
case "linux":
152+
return "linux"
153+
case "windows":
154+
return "linux"
155+
case "darwin", "freebsd", "openbsd", "netbsd":
156+
return "bsd"
157+
default:
158+
return "bsd"
159+
}
160+
}

testdata/gopose/gopose_e2e_test.yml

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,33 @@ steps:
2929
- desc: "Port conflict resolution works"
3030
exec:
3131
command: |
32+
ROOT_DIR=$(pwd)
33+
PID_FILE=/tmp/gopose_test.pid
34+
OCCUPY_BIN="$ROOT_DIR/occupy_port"
35+
FIXTURE_DIR="$ROOT_DIR/fixtures"
36+
37+
cleanup() {
38+
if [ -f "$PID_FILE" ]; then
39+
kill "$(cat "$PID_FILE")" 2>/dev/null || true
40+
rm -f "$PID_FILE"
41+
fi
42+
rm -f "$OCCUPY_BIN" "$FIXTURE_DIR/{{ vars.override_file }}"
43+
}
44+
trap cleanup EXIT
45+
3246
# Build and run Go port occupier
33-
go build -o occupy_port occupy_port.go
34-
./occupy_port 8080 > /dev/null 2>&1 & echo $! > /tmp/gopose_test.pid
47+
go build -o "$OCCUPY_BIN" occupy_port.go
48+
"$OCCUPY_BIN" 8080 > /dev/null 2>&1 & echo $! > "$PID_FILE"
3549
sleep 2
3650
3751
# Test conflict resolution
38-
cd fixtures
39-
../../../gopose up -f port-conflict.yml --verbose
40-
41-
# Cleanup
42-
kill $(cat /tmp/gopose_test.pid) 2>/dev/null || true
43-
rm -f /tmp/gopose_test.pid {{ vars.override_file }} occupy_port
52+
cd "$FIXTURE_DIR"
53+
output=$(../../../gopose up -f port-conflict.yml --verbose 2>&1)
54+
echo "$output"
55+
56+
# Assert port conflicts are actually detected and reported
57+
echo "$output" | grep -q "Port Conflicts:"
58+
echo "$output" | grep -q "SERVICE"
59+
echo "$output" | grep -q "web1"
4460
test: |
4561
current.exit_code == 0

0 commit comments

Comments
 (0)