From 1114ee00c1de3d41ee4266ac6c8017b3077ea1f8 Mon Sep 17 00:00:00 2001 From: Yoilun Date: Fri, 15 May 2026 01:17:44 +0800 Subject: [PATCH] feat: scan host LAN web devices via proxy --- deploy/docker-compose.yml | 1 + .../2026-05-15-host-lan-webdevice-scan.md | 40 +++++ internal/webdevice/proxy.go | 100 ++++++++++- internal/webdevice/proxy_test.go | 62 +++++++ internal/webdevice/service.go | 159 ++++++++++++++++-- internal/webdevice/service_test.go | 79 ++++++++- web/src/views/WebDevices.vue | 2 +- 7 files changed, 421 insertions(+), 22 deletions(-) create mode 100644 docs/plans/2026-05-15-host-lan-webdevice-scan.md diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 68c3dc2..6152ac1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -17,6 +17,7 @@ services: TZ: ${TZ:-Asia/Shanghai} MANAGED_PORTAL_HTTP_ADDR: ":9080" MANAGED_PORTAL_REGISTRY_PATH: "/app/managed_services.yaml" + MANAGED_PORTAL_HOST_SCAN_IMAGE: "managed-portal:${IMAGE_VERSION:-dev}" volumes: - /var/run/docker.sock:/var/run/docker.sock networks: diff --git a/docs/plans/2026-05-15-host-lan-webdevice-scan.md b/docs/plans/2026-05-15-host-lan-webdevice-scan.md new file mode 100644 index 0000000..88cb278 --- /dev/null +++ b/docs/plans/2026-05-15-host-lan-webdevice-scan.md @@ -0,0 +1,40 @@ +# Host LAN Web Device Scan Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Scan the server host LAN for web devices even when users access the portal through `10.8.0.x:13000`, and open devices through the portal proxy. + +**Architecture:** The Go backend will derive scan interfaces from the server host LAN instead of the request host. The frontend will open `/proxy/web//` first so clients do not need direct LAN reachability. + +**Tech Stack:** Go backend, Vue frontend, Docker Compose deployment. + +--- + +### Task 1: Backend Scan Source + +**Files:** +- Modify: `internal/webdevice/service.go` +- Test: `internal/webdevice/service_test.go` + +**Steps:** +1. Add tests for filtering Docker/VPN interfaces and retaining physical LAN interfaces. +2. Add a host interface discovery helper that can parse host network data. +3. Use host LAN interfaces in `Scan`, falling back to existing interface discovery if none are found. +4. Keep scanning TCP port 80 and the existing `/24` cap. + +### Task 2: Frontend Proxy Open + +**Files:** +- Modify: `web/src/views/WebDevices.vue` + +**Steps:** +1. Make `openDevice` prefer `proxy_url`. +2. Fall back to `direct_url` only if no proxy URL exists. + +### Task 3: Verify And Deploy + +**Commands:** +- `go test ./internal/webdevice ./internal/server` +- `pnpm --dir web build` +- `rsync` changed files to `xiaozheng@10.8.0.18:/home/xiaozheng/code/managed-portal/` +- `docker compose --env-file managed-portal.env up -d --build managed-portal managed-portal-web` diff --git a/internal/webdevice/proxy.go b/internal/webdevice/proxy.go index db1966a..696e22e 100644 --- a/internal/webdevice/proxy.go +++ b/internal/webdevice/proxy.go @@ -3,11 +3,13 @@ package webdevice import ( "errors" "io" + "log" "net/http" "net/http/httputil" "net/url" "strconv" "strings" + "time" ) var ( @@ -36,6 +38,14 @@ func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, pr rawQuery := r.URL.RawQuery proxy := &httputil.ReverseProxy{ + Transport: retryTransport{ + base: &http.Transport{ + DisableKeepAlives: true, + ResponseHeaderTimeout: 5 * time.Second, + }, + attempts: 3, + delay: 80 * time.Millisecond, + }, Director: func(req *http.Request) { req.URL.Scheme = targetURL.Scheme req.URL.Host = targetURL.Host @@ -43,7 +53,8 @@ func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, pr req.URL.RawPath = "" req.URL.RawQuery = rawQuery req.Host = targetURL.Host - req.Header.Del("Accept-Encoding") + req.Header = sanitizeProxyRequestHeader(req.Header, req.URL.Path) + req.Close = true }, ModifyResponse: func(resp *http.Response) error { proxyPrefix := "/proxy/web/" + targetIP @@ -77,6 +88,7 @@ func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, pr return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("web device proxy failed target=%s path=%s error=%v", targetURL.String(), r.URL.RequestURI(), err) http.Error(w, "代理访问失败: "+err.Error(), http.StatusBadGateway) }, } @@ -85,6 +97,92 @@ func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, pr return nil } +type retryTransport struct { + base http.RoundTripper + attempts int + delay time.Duration +} + +func (t retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + attempts := t.attempts + if attempts < 1 { + attempts = 1 + } + + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + nextReq := req + if attempt > 0 { + nextReq = req.Clone(req.Context()) + nextReq.Header = req.Header.Clone() + nextReq.Close = true + } + + resp, err := base.RoundTrip(nextReq) + if err == nil { + return resp, nil + } + lastErr = err + if !shouldRetryProxyRequest(req, err) || attempt == attempts-1 { + return nil, err + } + time.Sleep(t.delay) + } + return nil, lastErr +} + +func shouldRetryProxyRequest(req *http.Request, err error) bool { + if req == nil || err == nil { + return false + } + switch req.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + default: + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "eof") || + strings.Contains(message, "connection reset") || + strings.Contains(message, "broken pipe") +} + +func sanitizeProxyRequestHeader(source http.Header, upstreamPath string) http.Header { + header := make(http.Header) + copyHeaderValue(header, source, "Accept") + copyHeaderValue(header, source, "Content-Type") + copyHeaderValue(header, source, "Authorization") + + userAgent := strings.TrimSpace(source.Get("User-Agent")) + if userAgent == "" { + userAgent = "Mozilla/5.0" + } + header.Set("User-Agent", userAgent) + header.Set("Connection", "close") + + if !isLoginPagePath(upstreamPath) { + copyHeaderValue(header, source, "Cookie") + } + return header +} + +func copyHeaderValue(target, source http.Header, key string) { + if value := source.Values(key); len(value) > 0 { + target.Del(key) + for _, item := range value { + target.Add(key, item) + } + } +} + +func isLoginPagePath(path string) bool { + path = strings.ToLower(path) + return strings.HasSuffix(path, "/doc/page/login.asp") || path == "/doc/page/login.asp" +} + type closeNotifyWriter struct { http.ResponseWriter } diff --git a/internal/webdevice/proxy_test.go b/internal/webdevice/proxy_test.go index a57d753..fe454d1 100644 --- a/internal/webdevice/proxy_test.go +++ b/internal/webdevice/proxy_test.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" "testing" + "time" ) func TestRewriteLocation(t *testing.T) { @@ -91,3 +92,64 @@ func TestProxyHTTPServesAllowedTarget(t *testing.T) { t.Fatalf("body = %s", rec.Body.String()) } } + +func TestProxyHTTPClosesUpstreamConnection(t *testing.T) { + t.Parallel() + + closeSeen := make(chan bool, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + closeSeen <- r.Close + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) + })) + defer upstream.Close() + + svc := NewService() + svc.allowIP("192.168.1.124") + svc.proxyTarget = func(ip string) string { + return upstream.URL + } + + req := httptest.NewRequest(http.MethodGet, "http://portal/proxy/web/192.168.1.124/", nil) + rec := httptest.NewRecorder() + + if err := svc.ProxyHTTP(rec, req, "192.168.1.124", "/"); err != nil { + t.Fatalf("ProxyHTTP() error = %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + + select { + case got := <-closeSeen: + if !got { + t.Fatal("upstream request Close = false, want true") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for upstream request") + } +} + +func TestSanitizeProxyRequestHeaderDropsLoginCookie(t *testing.T) { + source := http.Header{} + source.Set("User-Agent", "browser") + source.Set("Cookie", "SID=1") + source.Set("Referer", "http://10.8.0.18:13000/proxy/web/192.168.0.108/") + source.Set("X-Forwarded-For", "10.8.0.1") + + loginHeader := sanitizeProxyRequestHeader(source, "/doc/page/login.asp") + if got := loginHeader.Get("Cookie"); got != "" { + t.Fatalf("login Cookie = %q, want empty", got) + } + if got := loginHeader.Get("Referer"); got != "" { + t.Fatalf("login Referer = %q, want empty", got) + } + if got := loginHeader.Get("X-Forwarded-For"); got != "" { + t.Fatalf("login X-Forwarded-For = %q, want empty", got) + } + + apiHeader := sanitizeProxyRequestHeader(source, "/ISAPI/Security/userCheck") + if got := apiHeader.Get("Cookie"); got != "SID=1" { + t.Fatalf("api Cookie = %q, want SID=1", got) + } +} diff --git a/internal/webdevice/service.go b/internal/webdevice/service.go index 8a71197..6428a01 100644 --- a/internal/webdevice/service.go +++ b/internal/webdevice/service.go @@ -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 { diff --git a/internal/webdevice/service_test.go b/internal/webdevice/service_test.go index 6e58c49..e4583c5 100644 --- a/internal/webdevice/service_test.go +++ b/internal/webdevice/service_test.go @@ -39,7 +39,7 @@ func TestWebDeviceForwardPort(t *testing.T) { } } -func TestScanBuildsDirectURLAndAllowList(t *testing.T) { +func TestScanBuildsProxyURLAndAllowList(t *testing.T) { t.Parallel() svc := NewService() @@ -68,8 +68,11 @@ func TestScanBuildsDirectURLAndAllowList(t *testing.T) { if !svc.IsAllowed("192.168.1.124") { t.Fatal("expected IP to be allowed after scan") } - if result.Devices[0].DirectURL != "http://10.8.0.14:31124/" { - t.Fatalf("DirectURL = %q", result.Devices[0].DirectURL) + if result.Devices[0].ProxyURL != "/proxy/web/192.168.1.124/" { + t.Fatalf("ProxyURL = %q", result.Devices[0].ProxyURL) + } + if result.Devices[0].DirectURL != "" { + t.Fatalf("DirectURL = %q, want empty", result.Devices[0].DirectURL) } } @@ -77,6 +80,7 @@ func TestScanIncludesPrivateRequestHostSubnet(t *testing.T) { t.Parallel() svc := NewService() + svc.hostLANGetter = nil svc.interfaceGetter = func() ([]InterfaceInfo, error) { return []InterfaceInfo{{ Name: "eth0", @@ -113,6 +117,75 @@ func TestScanIncludesPrivateRequestHostSubnet(t *testing.T) { } } +func TestScanPrefersHostLANInterfaces(t *testing.T) { + t.Parallel() + + svc := NewService() + svc.interfaceGetter = func() ([]InterfaceInfo, error) { + return []InterfaceInfo{{ + Name: "eth0", + IP: "172.20.0.4", + Netmask: "255.255.0.0", + }}, nil + } + svc.hostLANGetter = func() ([]InterfaceInfo, error) { + return []InterfaceInfo{{ + Name: "eno1", + IP: "192.168.0.117", + Netmask: "255.255.255.0", + }}, nil + } + svc.tcpScanner = func(ip, netmask string, port int, excludeIPs map[string]bool) ([]TCPDevice, error) { + if ip != "192.168.0.117" { + t.Fatalf("scan ip = %q, want host LAN ip", ip) + } + if netmask != "255.255.255.0" { + t.Fatalf("netmask = %q, want 255.255.255.0", netmask) + } + if !excludeIPs["172.20.0.4"] || !excludeIPs["192.168.0.117"] { + t.Fatalf("excludeIPs = %#v, want container and host IPs", excludeIPs) + } + return []TCPDevice{{IP: "192.168.0.108", Port: 80}}, nil + } + svc.newForwarder = func(ip string, port int, listenAddress, targetAddress string) (*webDeviceForwarder, error) { + return &webDeviceForwarder{ip: ip, port: port, targetAddress: targetAddress}, nil + } + + req := httptest.NewRequest(http.MethodGet, "http://10.8.0.18:13000/api/web-devices/scan", nil) + result, err := svc.Scan(req) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + if result.Count != 1 { + t.Fatalf("result.Count = %d, want 1", result.Count) + } + if result.Devices[0].Interface != "eno1" { + t.Fatalf("Interface = %q, want eno1", result.Devices[0].Interface) + } +} + +func TestParseHostLANInterfacesFiltersVirtualInterfaces(t *testing.T) { + t.Parallel() + + output := `2: eno1 inet 192.168.0.117/24 brd 192.168.0.255 scope global eno1 +3: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 +4: tun0 inet 10.8.0.18/24 scope global tun0 +5: wlan0 inet 192.168.5.10/23 brd 192.168.5.255 scope global wlan0 +` + + interfaces := parseHostLANInterfaces(output) + + if len(interfaces) != 2 { + t.Fatalf("len(interfaces) = %d, want 2: %#v", len(interfaces), interfaces) + } + if interfaces[0].Name != "eno1" || interfaces[0].IP != "192.168.0.117" || interfaces[0].Netmask != "255.255.255.0" { + t.Fatalf("interfaces[0] = %#v", interfaces[0]) + } + if interfaces[1].Name != "wlan0" || interfaces[1].IP != "192.168.5.10" || interfaces[1].Netmask != "255.255.254.0" { + t.Fatalf("interfaces[1] = %#v", interfaces[1]) + } +} + func TestCalculateIPRangeCapsLargeSubnetToLocal24(t *testing.T) { t.Parallel() diff --git a/web/src/views/WebDevices.vue b/web/src/views/WebDevices.vue index 2bf4c90..a4bf223 100644 --- a/web/src/views/WebDevices.vue +++ b/web/src/views/WebDevices.vue @@ -99,7 +99,7 @@ async function handleScan() { } function openDevice(row) { - const url = row?.direct_url || row?.proxy_url; + const url = row?.proxy_url || row?.direct_url; if (!url) { ElMessage.error("设备打开地址无效"); return;