Merge pull request #23 from towerops-app/icmp-fixes

fix ICMP ping in Docker by trying raw sockets before unprivileged UDP
This commit is contained in:
Graham McIntire 2026-02-12 11:55:11 -06:00 committed by GitHub
commit d141633586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 21 deletions

View file

@ -10,9 +10,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o towerops-agent .
FROM alpine:3.23
RUN apk add --no-cache ca-certificates iputils libcap && \
setcap cap_net_raw+p /bin/ping
RUN apk add --no-cache ca-certificates iputils libcap
COPY --from=builder /app/towerops-agent /usr/local/bin/towerops-agent
RUN adduser -D -u 1000 towerops && chown towerops /usr/local/bin/towerops-agent
RUN adduser -D -u 1000 towerops && \
chown towerops /usr/local/bin/towerops-agent && \
setcap cap_net_raw+ep /usr/local/bin/towerops-agent && \
setcap cap_net_raw+p /bin/ping
USER towerops
CMD ["towerops-agent"]

62
ping.go
View file

@ -16,7 +16,8 @@ import (
)
// icmpPing sends a single ICMP echo request and returns the round-trip time in milliseconds.
// Uses unprivileged UDP sockets where available, falling back to raw sockets.
// Tries raw ICMP sockets first (requires CAP_NET_RAW or root), then falls back to
// unprivileged UDP-based ICMP (requires ping_group_range sysctl).
func icmpPing(ip string, timeoutMs int) (float64, error) {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
@ -24,26 +25,50 @@ func icmpPing(ip string, timeoutMs int) (float64, error) {
}
isIPv4 := parsedIP.To4() != nil
var network string
var dst net.Addr
var msgType icmp.Type
// Try raw ICMP first (works with CAP_NET_RAW or as root)
var rawNet string
if isIPv4 {
network = "udp4"
dst = &net.UDPAddr{IP: parsedIP}
msgType = ipv4.ICMPTypeEcho
rawNet = "ip4:icmp"
} else {
network = "udp6"
dst = &net.UDPAddr{IP: parsedIP}
msgType = ipv6.ICMPTypeEchoRequest
rawNet = "ip6:ipv6-icmp"
}
ms, err := doICMPPing(parsedIP, rawNet, isIPv4, timeoutMs)
if err == nil {
return ms, nil
}
if _, ok := err.(*errICMPUnavailable); !ok {
return 0, err
}
// Fall back to unprivileged UDP ICMP (works with ping_group_range sysctl)
var udpNet string
if isIPv4 {
udpNet = "udp4"
} else {
udpNet = "udp6"
}
return doICMPPing(parsedIP, udpNet, isIPv4, timeoutMs)
}
// doICMPPing performs an ICMP ping over the given network type.
func doICMPPing(ip net.IP, network string, isIPv4 bool, timeoutMs int) (float64, error) {
conn, err := icmp.ListenPacket(network, "")
if err != nil {
return 0, &errICMPUnavailable{err: fmt.Errorf("icmp listen: %w", err)}
return 0, &errICMPUnavailable{err: fmt.Errorf("icmp listen %s: %w", network, err)}
}
defer func() { _ = conn.Close() }()
var msgType icmp.Type
var proto int
if isIPv4 {
msgType = ipv4.ICMPTypeEcho
proto = 1
} else {
msgType = ipv6.ICMPTypeEchoRequest
proto = 58
}
id := os.Getpid() & 0xffff
msg := icmp.Message{
Type: msgType,
@ -55,18 +80,19 @@ func icmpPing(ip string, timeoutMs int) (float64, error) {
},
}
var proto int
if isIPv4 {
proto = 1 // ICMP
} else {
proto = 58 // ICMPv6
}
wb, err := msg.Marshal(nil)
if err != nil {
return 0, fmt.Errorf("icmp marshal: %w", err)
}
// Destination address type depends on network
var dst net.Addr
if strings.HasPrefix(network, "udp") {
dst = &net.UDPAddr{IP: ip}
} else {
dst = &net.IPAddr{IP: ip}
}
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
if err := conn.SetDeadline(deadline); err != nil {
return 0, fmt.Errorf("set deadline: %w", err)