feat: scan host LAN web devices via proxy

This commit is contained in:
Yoilun
2026-05-15 01:17:44 +08:00
parent f8a6d9803d
commit 1114ee00c1
7 changed files with 421 additions and 22 deletions

View File

@@ -1,10 +1,13 @@
package webdevice
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"sort"
"strconv"
"strings"
@@ -59,6 +62,7 @@ type Service struct {
allowed map[string]time.Time
forwarders map[string]*webDeviceForwarder
interfaceGetter InterfaceGetter
hostLANGetter InterfaceGetter
tcpScanner TCPScanner
newForwarder ForwarderFactory
proxyTarget ProxyTargetResolver
@@ -70,6 +74,7 @@ func NewService() *Service {
allowed: make(map[string]time.Time),
forwarders: make(map[string]*webDeviceForwarder),
interfaceGetter: defaultInterfaceGetter,
hostLANGetter: defaultHostLANInterfaceGetter,
tcpScanner: scanTCP,
newForwarder: newWebDeviceForwarder,
proxyTarget: defaultProxyTarget,
@@ -82,14 +87,15 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
if err != nil {
return nil, err
}
scheme, host := requestBase(r)
interfaces = appendRequestHostInterface(interfaces, host)
_, host := requestBase(r)
scanInterfaces, scanWarnings := s.scanInterfaces(interfaces, host)
if len(interfaces) == 0 {
if len(scanInterfaces) == 0 {
return &ScanResult{
Interfaces: []InterfaceInfo{},
Devices: []DeviceInfo{},
Message: "未找到有效的网卡",
Errors: scanWarnings,
}, nil
}
@@ -97,14 +103,17 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
for _, iface := range interfaces {
excludeIPs[iface.IP] = true
}
result := &ScanResult{
Interfaces: interfaces,
Devices: []DeviceInfo{},
Errors: []string{},
for _, iface := range scanInterfaces {
excludeIPs[iface.IP] = true
}
for _, iface := range interfaces {
result := &ScanResult{
Interfaces: scanInterfaces,
Devices: []DeviceInfo{},
Errors: scanWarnings,
}
for _, iface := range scanInterfaces {
devices, scanErr := s.tcpScanner(iface.IP, iface.Netmask, 80, excludeIPs)
if scanErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", iface.Name, scanErr))
@@ -117,10 +126,6 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
}
s.allowIP(device.IP)
forwardPort, forwardErr := s.EnsureForwarder(device.IP)
if forwardErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: 启动网页直连转发失败: %v", device.IP, forwardErr))
}
deviceInfo := DeviceInfo{
IP: device.IP,
@@ -129,10 +134,6 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
TargetURL: fmt.Sprintf("http://%s/", device.IP),
ProxyURL: fmt.Sprintf("/proxy/web/%s/", device.IP),
}
if forwardErr == nil {
deviceInfo.ForwardPort = forwardPort
deviceInfo.DirectURL = buildDirectURL(scheme, host, forwardPort)
}
result.Devices = append(result.Devices, deviceInfo)
}
}
@@ -144,6 +145,21 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
return result, nil
}
func (s *Service) scanInterfaces(containerInterfaces []InterfaceInfo, requestHost string) ([]InterfaceInfo, []string) {
if s.hostLANGetter != nil {
hostInterfaces, err := s.hostLANGetter()
if err == nil && len(hostInterfaces) > 0 {
return hostInterfaces, nil
}
if err != nil {
return appendRequestHostInterface(containerInterfaces, requestHost), []string{
"宿主机局域网探测失败,已回退到请求地址网段: " + err.Error(),
}
}
}
return appendRequestHostInterface(containerInterfaces, requestHost), nil
}
func appendRequestHostInterface(interfaces []InterfaceInfo, host string) []InterfaceInfo {
if !IsPrivateIPv4Literal(host) {
return interfaces
@@ -183,6 +199,12 @@ func (s *Service) SetInterfaceGetter(getter InterfaceGetter) {
}
}
func (s *Service) SetHostLANGetter(getter InterfaceGetter) {
if getter != nil {
s.hostLANGetter = getter
}
}
func (s *Service) SetTCPScanner(scanner TCPScanner) {
if scanner != nil {
s.tcpScanner = scanner
@@ -403,6 +425,109 @@ func defaultInterfaceGetter() ([]InterfaceInfo, error) {
return interfaces, nil
}
func defaultHostLANInterfaceGetter() ([]InterfaceInfo, error) {
image := strings.TrimSpace(os.Getenv("MANAGED_PORTAL_HOST_SCAN_IMAGE"))
if image == "" {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
"docker",
"run",
"--rm",
"--network",
"host",
"--entrypoint",
"/sbin/ip",
image,
"-o",
"-4",
"addr",
"show",
"scope",
"global",
)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("读取宿主机网卡超时")
}
if err != nil {
return nil, fmt.Errorf("读取宿主机网卡失败: %w: %s", err, strings.TrimSpace(string(output)))
}
return parseHostLANInterfaces(string(output)), nil
}
func parseHostLANInterfaces(output string) []InterfaceInfo {
var interfaces []InterfaceInfo
seen := make(map[string]bool)
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
name := strings.TrimSuffix(fields[1], ":")
if ignoredInterfaceName(name) {
continue
}
inetIndex := -1
for index, field := range fields {
if field == "inet" {
inetIndex = index
break
}
}
if inetIndex == -1 || inetIndex+1 >= len(fields) {
continue
}
ip, ipNet, err := net.ParseCIDR(fields[inetIndex+1])
if err != nil || ip.To4() == nil || ipNet == nil {
continue
}
ipString := ip.String()
if !IsPrivateIPv4Literal(ipString) || seen[ipString] {
continue
}
ones, bits := ipNet.Mask.Size()
if bits != 32 {
continue
}
mask := net.IP(net.CIDRMask(ones, bits)).String()
interfaces = append(interfaces, InterfaceInfo{
Name: name,
IP: ipString,
Netmask: mask,
})
seen[ipString] = true
}
return interfaces
}
func ignoredInterfaceName(name string) bool {
lower := strings.ToLower(name)
if lower == "lo" ||
strings.Contains(lower, "docker") ||
strings.Contains(lower, "veth") ||
strings.Contains(lower, "br-") ||
strings.Contains(lower, "tun") ||
strings.Contains(lower, "tap") ||
strings.Contains(lower, "wg") ||
strings.Contains(lower, "tailscale") ||
strings.Contains(lower, "utun") ||
strings.Contains(lower, "ppp") ||
strings.Contains(lower, "zerotier") ||
strings.HasPrefix(lower, "zt") {
return true
}
return false
}
func scanTCP(ip string, netmask string, port int, excludeIPs map[string]bool) ([]TCPDevice, error) {
ipRange, err := calculateIPRange(ip, netmask)
if err != nil {