primeiro commit

This commit is contained in:
2026-07-05 14:26:03 -03:00
commit 87a356c742
19 changed files with 1404 additions and 0 deletions

52
internal/probe/dns.go Normal file
View File

@@ -0,0 +1,52 @@
package probe
import (
"context"
"fmt"
"net"
"net/netip"
"time"
)
// DNSResult contains the outcome of one DNS lookup.
type DNSResult struct {
Reachable bool
Duration time.Duration
IPs []net.IP
Err error
}
// Resolve checks whether a domain can be resolved against a specific DNS server.
func Resolve(ctx context.Context, server string, domain string, timeout time.Duration) DNSResult {
start := time.Now()
lookupCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
dialer := net.Dialer{Timeout: timeout}
resolver := net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network string, address string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, dnsServerAddress(server))
if err != nil {
return nil, fmt.Errorf("dialing dns server %s: %w", server, err)
}
return conn, nil
},
}
ips, err := resolver.LookupIP(lookupCtx, "ip", domain)
duration := time.Since(start)
if err != nil {
return DNSResult{Duration: duration, Err: fmt.Errorf("resolving %s with %s: %w", domain, server, err)}
}
return DNSResult{Reachable: len(ips) > 0, Duration: duration, IPs: ips}
}
func dnsServerAddress(server string) string {
if _, err := netip.ParseAddrPort(server); err == nil {
return server
}
if addr, err := netip.ParseAddr(server); err == nil {
return net.JoinHostPort(addr.String(), "53")
}
return net.JoinHostPort(server, "53")
}

2
internal/probe/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package probe provides network, DNS, route, and interface probes for statuspanel.
package probe

View File

@@ -0,0 +1,23 @@
package probe
import "fmt"
// HumanizeBitsPerSecond formats a bit rate as kilobit, megabit, or gigabit.
func HumanizeBitsPerSecond(bitsPerSecond float64) string {
const (
kilobit = 1000
megabit = 1000 * kilobit
gigabit = 1000 * megabit
)
switch {
case bitsPerSecond >= gigabit:
return fmt.Sprintf("%.2f Gbit/s", bitsPerSecond/gigabit)
case bitsPerSecond >= megabit:
return fmt.Sprintf("%.2f Mbit/s", bitsPerSecond/megabit)
case bitsPerSecond >= kilobit:
return fmt.Sprintf("%.2f kbit/s", bitsPerSecond/kilobit)
default:
return fmt.Sprintf("%.0f bit/s", bitsPerSecond)
}
}

View File

@@ -0,0 +1,71 @@
package probe
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
const netDevPath = "/proc/net/dev"
// InterfaceStats contains received and transmitted byte counters.
type InterfaceStats struct {
Name string
RXBytes uint64
TXBytes uint64
}
// ReadInterfaceStats reads byte counters for a network interface.
func ReadInterfaceStats(ctx context.Context, name string) (InterfaceStats, error) {
if err := ctx.Err(); err != nil {
return InterfaceStats{}, fmt.Errorf("checking context before reading interface stats: %w", err)
}
data, err := os.ReadFile(netDevPath)
if err != nil {
return InterfaceStats{}, fmt.Errorf("reading %s: %w", netDevPath, err)
}
if err := ctx.Err(); err != nil {
return InterfaceStats{}, fmt.Errorf("checking context after reading interface stats: %w", err)
}
stats, err := parseNetDev(data, name)
if err != nil {
return InterfaceStats{}, err
}
return stats, nil
}
func parseNetDev(data []byte, name string) (InterfaceStats, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.Contains(line, ":") {
continue
}
parts := strings.SplitN(line, ":", 2)
if strings.TrimSpace(parts[0]) != name {
continue
}
fields := strings.Fields(parts[1])
if len(fields) < 16 {
return InterfaceStats{}, fmt.Errorf("parsing interface %s stats: expected at least 16 fields, got %d", name, len(fields))
}
rxBytes, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
return InterfaceStats{}, fmt.Errorf("parsing interface %s rx bytes: %w", name, err)
}
txBytes, err := strconv.ParseUint(fields[8], 10, 64)
if err != nil {
return InterfaceStats{}, fmt.Errorf("parsing interface %s tx bytes: %w", name, err)
}
return InterfaceStats{Name: name, RXBytes: rxBytes, TXBytes: txBytes}, nil
}
if err := scanner.Err(); err != nil {
return InterfaceStats{}, fmt.Errorf("scanning %s contents: %w", netDevPath, err)
}
return InterfaceStats{}, errors.New("interface not found")
}

153
internal/probe/ping.go Normal file
View File

@@ -0,0 +1,153 @@
package probe
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"time"
)
const (
icmpEchoReply = 0
icmpEcho = 8
)
// PingResult contains the outcome of one ICMP echo request.
type PingResult struct {
Reachable bool
RTT time.Duration
Err error
}
// Ping sends one ICMP echo request to an IPv4 host.
func Ping(ctx context.Context, host string, timeout time.Duration) (result PingResult) {
start := time.Now()
ip, err := resolveIPv4(ctx, host)
if err != nil {
return PingResult{Err: err}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return PingResult{Err: fmt.Errorf("opening icmp socket: %w", err)}
}
defer func() {
if closeErr := conn.Close(); closeErr != nil && result.Err == nil {
result = PingResult{Err: fmt.Errorf("closing icmp socket: %w", closeErr)}
}
}()
deadline := time.Now().Add(timeout)
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
deadline = ctxDeadline
}
if err := conn.SetDeadline(deadline); err != nil {
return PingResult{Err: fmt.Errorf("setting icmp deadline: %w", err)}
}
id, seq, err := echoIdentifiers()
if err != nil {
return PingResult{Err: err}
}
packet := echoPacket(id, seq)
if _, err := conn.WriteTo(packet, &net.IPAddr{IP: ip}); err != nil {
return PingResult{Err: fmt.Errorf("sending icmp echo to %s: %w", host, err)}
}
buffer := make([]byte, 1500)
for {
n, addr, err := conn.ReadFrom(buffer)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return PingResult{Err: fmt.Errorf("ping canceled: %w", ctxErr)}
}
return PingResult{Err: fmt.Errorf("reading icmp reply from %s: %w", host, err)}
}
if !sameIP(addr, ip) {
continue
}
if echoReplyMatches(buffer[:n], id, seq) {
return PingResult{Reachable: true, RTT: time.Since(start)}
}
}
}
func resolveIPv4(ctx context.Context, host string) (net.IP, error) {
if parsed := net.ParseIP(host); parsed != nil {
if ipv4 := parsed.To4(); ipv4 != nil {
return ipv4, nil
}
return nil, fmt.Errorf("%s is not an IPv4 address", host)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolving ping host %s: %w", host, err)
}
for _, addr := range addrs {
if ipv4 := addr.IP.To4(); ipv4 != nil {
return ipv4, nil
}
}
return nil, fmt.Errorf("resolving ping host %s: no IPv4 address found", host)
}
func echoIdentifiers() (uint16, uint16, error) {
var data [4]byte
if _, err := rand.Read(data[:]); err != nil {
return 0, 0, fmt.Errorf("generating icmp identifiers: %w", err)
}
return binary.BigEndian.Uint16(data[0:2]), binary.BigEndian.Uint16(data[2:4]), nil
}
func echoPacket(id uint16, seq uint16) []byte {
payload := []byte("statuspanel")
packet := make([]byte, 8+len(payload))
packet[0] = icmpEcho
binary.BigEndian.PutUint16(packet[4:6], id)
binary.BigEndian.PutUint16(packet[6:8], seq)
copy(packet[8:], payload)
checksum := icmpChecksum(packet)
binary.BigEndian.PutUint16(packet[2:4], checksum)
return packet
}
func echoReplyMatches(packet []byte, id uint16, seq uint16) bool {
if len(packet) < 8 {
return false
}
if packet[0] == 69 && len(packet) >= 28 {
packet = packet[20:]
}
return len(packet) >= 8 &&
packet[0] == icmpEchoReply &&
binary.BigEndian.Uint16(packet[4:6]) == id &&
binary.BigEndian.Uint16(packet[6:8]) == seq
}
func sameIP(addr net.Addr, ip net.IP) bool {
ipAddr, ok := addr.(*net.IPAddr)
if !ok {
return false
}
return ipAddr.IP.Equal(ip)
}
func icmpChecksum(data []byte) uint16 {
var sum uint32
for len(data) > 1 {
sum += uint32(binary.BigEndian.Uint16(data[:2]))
data = data[2:]
}
if len(data) == 1 {
sum += uint32(data[0]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
var errUnsupportedGateway = errors.New("gateway discovery is unsupported on this platform")

View File

@@ -0,0 +1,99 @@
package probe
import (
"net"
"testing"
)
func TestHumanizeBitsPerSecond(t *testing.T) {
tests := []struct {
name string
rate float64
want string
}{
{name: "bits", rate: 999, want: "999 bit/s"},
{name: "kilobits", rate: 1500, want: "1.50 kbit/s"},
{name: "megabits", rate: 2500000, want: "2.50 Mbit/s"},
{name: "gigabits", rate: 4200000000, want: "4.20 Gbit/s"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HumanizeBitsPerSecond(tt.rate); got != tt.want {
t.Fatalf("HumanizeBitsPerSecond(%v) = %q, want %q", tt.rate, got, tt.want)
}
})
}
}
func TestParseNetDev(t *testing.T) {
data := []byte(`Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 1000 0 0 0 0 0 0 0 2000 0 0 0 0 0 0 0
eth0: 12345 1 0 0 0 0 0 0 67890 2 0 0 0 0 0 0
`)
stats, err := parseNetDev(data, "eth0")
if err != nil {
t.Fatalf("parseNetDev() error = %v", err)
}
if stats.RXBytes != 12345 || stats.TXBytes != 67890 {
t.Fatalf("stats = %+v, want rx 12345 tx 67890", stats)
}
}
func TestLittleEndianHexIPv4(t *testing.T) {
ip, err := littleEndianHexIPv4("0102A8C0")
if err != nil {
t.Fatalf("littleEndianHexIPv4() error = %v", err)
}
if !ip.Equal(net.IPv4(192, 168, 2, 1)) {
t.Fatalf("ip = %v, want 192.168.2.1", ip)
}
}
func TestParseLinuxDefaultGateway(t *testing.T) {
t.Run("explicit gateway default route", func(t *testing.T) {
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
eth0 00000000 0102A8C0 0003 0 0 100 00000000 0 0 0
`)
ip, err := parseLinuxDefaultGateway(data, "eth0")
if err != nil {
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
}
if !ip.Equal(net.IPv4(192, 168, 2, 1)) {
t.Fatalf("ip = %v, want 192.168.2.1", ip)
}
})
t.Run("point to point peer route", func(t *testing.T) {
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
ppp0 00000000 00000000 0001 0 0 0 00000000 0 0 0
ppp0 0100400A 00000000 0005 0 0 0 FFFFFFFF 0 0 0
`)
ip, err := parseLinuxDefaultGateway(data, "ppp0")
if err != nil {
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
}
if !ip.Equal(net.IPv4(10, 64, 0, 1)) {
t.Fatalf("ip = %v, want 10.64.0.1", ip)
}
})
t.Run("prefers explicit gateway over peer route", func(t *testing.T) {
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
ppp0 00000000 FE00400A 0003 0 0 0 00000000 0 0 0
ppp0 0100400A 00000000 0005 0 0 0 FFFFFFFF 0 0 0
`)
ip, err := parseLinuxDefaultGateway(data, "ppp0")
if err != nil {
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
}
if !ip.Equal(net.IPv4(10, 64, 0, 254)) {
t.Fatalf("ip = %v, want 10.64.0.254", ip)
}
})
}

View File

@@ -0,0 +1,151 @@
//go:build linux
package probe
import (
"bufio"
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
)
const routePath = "/proc/net/route"
const (
routeFlagUp = 0x1
routeFlagGateway = 0x2
routeFlagHost = 0x4
)
type linuxRoute struct {
iface string
destination net.IP
gateway net.IP
flags int64
mask net.IP
}
// GatewayForInterface returns the default IPv4 gateway for an interface.
func GatewayForInterface(ctx context.Context, iface string) (net.IP, error) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("checking context before reading routes: %w", err)
}
data, err := os.ReadFile(routePath)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", routePath, err)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("checking context after reading routes: %w", err)
}
ip, err := parseLinuxDefaultGateway(data, iface)
if err != nil {
return nil, err
}
return ip, nil
}
func parseLinuxDefaultGateway(data []byte, iface string) (net.IP, error) {
routes, err := parseLinuxRoutes(data, iface)
if err != nil {
return nil, err
}
hasDefaultRoute := false
for _, route := range routes {
if !route.isDefault() {
continue
}
hasDefaultRoute = true
if route.hasFlag(routeFlagUp) && route.hasFlag(routeFlagGateway) && !route.gateway.Equal(net.IPv4zero) {
return route.gateway, nil
}
}
if hasDefaultRoute {
for _, route := range routes {
if route.isPointToPointPeer() {
return route.destination, nil
}
}
}
return nil, errors.New("default gateway not found")
}
func parseLinuxRoutes(data []byte, iface string) ([]linuxRoute, error) {
var routes []linuxRoute
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 8 || fields[0] != iface {
continue
}
route, err := parseLinuxRoute(fields)
if err != nil {
return nil, err
}
routes = append(routes, route)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning %s contents: %w", routePath, err)
}
return routes, nil
}
func parseLinuxRoute(fields []string) (linuxRoute, error) {
destination, err := littleEndianHexIPv4(fields[1])
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route destination for %s: %w", fields[0], err)
}
gateway, err := littleEndianHexIPv4(fields[2])
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route gateway for %s: %w", fields[0], err)
}
flags, err := strconv.ParseInt(fields[3], 16, 64)
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route flags for %s: %w", fields[0], err)
}
mask, err := littleEndianHexIPv4(fields[7])
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route mask for %s: %w", fields[0], err)
}
return linuxRoute{
iface: fields[0],
destination: destination,
gateway: gateway,
flags: flags,
mask: mask,
}, nil
}
func (r linuxRoute) isDefault() bool {
return r.destination.Equal(net.IPv4zero) && r.mask.Equal(net.IPv4zero)
}
func (r linuxRoute) isPointToPointPeer() bool {
return r.hasFlag(routeFlagUp) &&
r.hasFlag(routeFlagHost) &&
r.gateway.Equal(net.IPv4zero) &&
!r.destination.Equal(net.IPv4zero)
}
func (r linuxRoute) hasFlag(flag int64) bool {
return r.flags&flag != 0
}
func littleEndianHexIPv4(value string) (net.IP, error) {
decoded, err := hex.DecodeString(value)
if err != nil {
return nil, err
}
if len(decoded) != net.IPv4len {
return nil, fmt.Errorf("expected 4 bytes, got %d", len(decoded))
}
return net.IPv4(decoded[3], decoded[2], decoded[1], decoded[0]), nil
}

View File

@@ -0,0 +1,16 @@
//go:build !linux
package probe
import (
"context"
"net"
)
// GatewayForInterface returns the default IPv4 gateway for an interface.
func GatewayForInterface(ctx context.Context, iface string) (net.IP, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return nil, errUnsupportedGateway
}