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

@@ -17,6 +17,7 @@ services:
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
MANAGED_PORTAL_HTTP_ADDR: ":9080" MANAGED_PORTAL_HTTP_ADDR: ":9080"
MANAGED_PORTAL_REGISTRY_PATH: "/app/managed_services.yaml" MANAGED_PORTAL_REGISTRY_PATH: "/app/managed_services.yaml"
MANAGED_PORTAL_HOST_SCAN_IMAGE: "managed-portal:${IMAGE_VERSION:-dev}"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
networks: networks:

View File

@@ -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/<ip>/` 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`

View File

@@ -3,11 +3,13 @@ package webdevice
import ( import (
"errors" "errors"
"io" "io"
"log"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
) )
var ( var (
@@ -36,6 +38,14 @@ func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, pr
rawQuery := r.URL.RawQuery rawQuery := r.URL.RawQuery
proxy := &httputil.ReverseProxy{ 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) { Director: func(req *http.Request) {
req.URL.Scheme = targetURL.Scheme req.URL.Scheme = targetURL.Scheme
req.URL.Host = targetURL.Host 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.RawPath = ""
req.URL.RawQuery = rawQuery req.URL.RawQuery = rawQuery
req.Host = targetURL.Host 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 { ModifyResponse: func(resp *http.Response) error {
proxyPrefix := "/proxy/web/" + targetIP proxyPrefix := "/proxy/web/" + targetIP
@@ -77,6 +88,7 @@ func (s *Service) ProxyHTTP(w http.ResponseWriter, r *http.Request, targetIP, pr
return nil return nil
}, },
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { 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) 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 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 { type closeNotifyWriter struct {
http.ResponseWriter http.ResponseWriter
} }

View File

@@ -6,6 +6,7 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestRewriteLocation(t *testing.T) { func TestRewriteLocation(t *testing.T) {
@@ -91,3 +92,64 @@ func TestProxyHTTPServesAllowedTarget(t *testing.T) {
t.Fatalf("body = %s", rec.Body.String()) 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)
}
}

View File

@@ -1,10 +1,13 @@
package webdevice package webdevice
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"os"
"os/exec"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -59,6 +62,7 @@ type Service struct {
allowed map[string]time.Time allowed map[string]time.Time
forwarders map[string]*webDeviceForwarder forwarders map[string]*webDeviceForwarder
interfaceGetter InterfaceGetter interfaceGetter InterfaceGetter
hostLANGetter InterfaceGetter
tcpScanner TCPScanner tcpScanner TCPScanner
newForwarder ForwarderFactory newForwarder ForwarderFactory
proxyTarget ProxyTargetResolver proxyTarget ProxyTargetResolver
@@ -70,6 +74,7 @@ func NewService() *Service {
allowed: make(map[string]time.Time), allowed: make(map[string]time.Time),
forwarders: make(map[string]*webDeviceForwarder), forwarders: make(map[string]*webDeviceForwarder),
interfaceGetter: defaultInterfaceGetter, interfaceGetter: defaultInterfaceGetter,
hostLANGetter: defaultHostLANInterfaceGetter,
tcpScanner: scanTCP, tcpScanner: scanTCP,
newForwarder: newWebDeviceForwarder, newForwarder: newWebDeviceForwarder,
proxyTarget: defaultProxyTarget, proxyTarget: defaultProxyTarget,
@@ -82,14 +87,15 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
scheme, host := requestBase(r) _, host := requestBase(r)
interfaces = appendRequestHostInterface(interfaces, host) scanInterfaces, scanWarnings := s.scanInterfaces(interfaces, host)
if len(interfaces) == 0 { if len(scanInterfaces) == 0 {
return &ScanResult{ return &ScanResult{
Interfaces: []InterfaceInfo{}, Interfaces: []InterfaceInfo{},
Devices: []DeviceInfo{}, Devices: []DeviceInfo{},
Message: "未找到有效的网卡", Message: "未找到有效的网卡",
Errors: scanWarnings,
}, nil }, nil
} }
@@ -97,14 +103,17 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
for _, iface := range interfaces { for _, iface := range interfaces {
excludeIPs[iface.IP] = true excludeIPs[iface.IP] = true
} }
for _, iface := range scanInterfaces {
result := &ScanResult{ excludeIPs[iface.IP] = true
Interfaces: interfaces,
Devices: []DeviceInfo{},
Errors: []string{},
} }
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) devices, scanErr := s.tcpScanner(iface.IP, iface.Netmask, 80, excludeIPs)
if scanErr != nil { if scanErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", iface.Name, scanErr)) 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) 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{ deviceInfo := DeviceInfo{
IP: device.IP, IP: device.IP,
@@ -129,10 +134,6 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
TargetURL: fmt.Sprintf("http://%s/", device.IP), TargetURL: fmt.Sprintf("http://%s/", device.IP),
ProxyURL: fmt.Sprintf("/proxy/web/%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) result.Devices = append(result.Devices, deviceInfo)
} }
} }
@@ -144,6 +145,21 @@ func (s *Service) Scan(r *http.Request) (*ScanResult, error) {
return result, nil 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 { func appendRequestHostInterface(interfaces []InterfaceInfo, host string) []InterfaceInfo {
if !IsPrivateIPv4Literal(host) { if !IsPrivateIPv4Literal(host) {
return interfaces 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) { func (s *Service) SetTCPScanner(scanner TCPScanner) {
if scanner != nil { if scanner != nil {
s.tcpScanner = scanner s.tcpScanner = scanner
@@ -403,6 +425,109 @@ func defaultInterfaceGetter() ([]InterfaceInfo, error) {
return interfaces, nil 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) { func scanTCP(ip string, netmask string, port int, excludeIPs map[string]bool) ([]TCPDevice, error) {
ipRange, err := calculateIPRange(ip, netmask) ipRange, err := calculateIPRange(ip, netmask)
if err != nil { if err != nil {

View File

@@ -39,7 +39,7 @@ func TestWebDeviceForwardPort(t *testing.T) {
} }
} }
func TestScanBuildsDirectURLAndAllowList(t *testing.T) { func TestScanBuildsProxyURLAndAllowList(t *testing.T) {
t.Parallel() t.Parallel()
svc := NewService() svc := NewService()
@@ -68,8 +68,11 @@ func TestScanBuildsDirectURLAndAllowList(t *testing.T) {
if !svc.IsAllowed("192.168.1.124") { if !svc.IsAllowed("192.168.1.124") {
t.Fatal("expected IP to be allowed after scan") t.Fatal("expected IP to be allowed after scan")
} }
if result.Devices[0].DirectURL != "http://10.8.0.14:31124/" { if result.Devices[0].ProxyURL != "/proxy/web/192.168.1.124/" {
t.Fatalf("DirectURL = %q", result.Devices[0].DirectURL) 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() t.Parallel()
svc := NewService() svc := NewService()
svc.hostLANGetter = nil
svc.interfaceGetter = func() ([]InterfaceInfo, error) { svc.interfaceGetter = func() ([]InterfaceInfo, error) {
return []InterfaceInfo{{ return []InterfaceInfo{{
Name: "eth0", 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) { func TestCalculateIPRangeCapsLargeSubnetToLocal24(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -99,7 +99,7 @@ async function handleScan() {
} }
function openDevice(row) { function openDevice(row) {
const url = row?.direct_url || row?.proxy_url; const url = row?.proxy_url || row?.direct_url;
if (!url) { if (!url) {
ElMessage.error("设备打开地址无效"); ElMessage.error("设备打开地址无效");
return; return;