feat: scan host LAN web devices via proxy
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
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 (
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user