feat: scan host LAN web devices via proxy
This commit is contained in:
@@ -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:
|
||||
|
||||
40
docs/plans/2026-05-15-host-lan-webdevice-scan.md
Normal file
40
docs/plans/2026-05-15-host-lan-webdevice-scan.md
Normal 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`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user