diff --git a/frontend/src/views/VpnView.vue b/frontend/src/views/VpnView.vue index b73f26d..194d6df 100644 --- a/frontend/src/views/VpnView.vue +++ b/frontend/src/views/VpnView.vue @@ -37,11 +37,25 @@ interface User { id: number username: string } +interface Diag { + platform: string + is_root: boolean + has_cap_net_admin: boolean | null + cap_net_admin_note?: string + ip_forward: boolean | null + ip_forward_note?: string + masquerade: boolean | null + masquerade_note?: string + tun_create: string + tun_running: boolean + tun_name?: string +} const settings = ref(null) const status = ref(null) const reservations = ref([]) const users = ref([]) +const diag = ref(null) const loading = ref(false) const error = ref('') const saving = ref(false) @@ -101,6 +115,16 @@ async function fetchUsers() { } } +async function fetchDiag() { + try { + const res = await fetch('/api/admin/vpn/diag', { headers: authHeader() }) + if (!res.ok) throw new Error('加载失败') + diag.value = await res.json() + } catch (e: any) { + error.value = e.message + } +} + async function handleSave() { saving.value = true saveMsg.value = '' @@ -113,7 +137,7 @@ async function handleSave() { const data = await res.json() if (!res.ok) throw new Error(data.error || '保存失败') saveMsg.value = '保存成功' - await Promise.all([fetchSettings(), fetchStatus()]) + await Promise.all([fetchSettings(), fetchStatus(), fetchDiag()]) } catch (e: any) { saveMsg.value = e.message } finally { @@ -162,10 +186,15 @@ async function handleDeleteResv(id: number) { } } +function checkTunCreate(): boolean | null { + if (!diag.value) return null + return diag.value.tun_create.startsWith('ok') +} + async function refreshAll() { loading.value = true error.value = '' - await Promise.all([fetchSettings(), fetchStatus(), fetchReservations(), fetchUsers()]) + await Promise.all([fetchSettings(), fetchStatus(), fetchReservations(), fetchUsers(), fetchDiag()]) loading.value = false } @@ -206,6 +235,89 @@ onMounted(() => { + +
+

系统环境检测

+
+ +
+ + + +
+

TUN 设备

+

+ + +

+
+
+ + +
+ {{ diag?.is_root ? '✓' : '✗' }} +
+

Root 权限

+

{{ diag?.is_root ? '以 root 运行' : '非 root 运行' }}

+
+
+ + +
+ + + +
+

CAP_NET_ADMIN

+

+ + +

+
+
+ + +
+ + + +
+

IP 转发 (ip_forward)

+

+ + +

+
+
+ + +
+ + + +
+

NAT MASQUERADE

+

+ + +

+
+
+ + +
+ +
+

平台

+

{{ diag?.platform }}

+
+
+
+

+ 注:CAP_NET_ADMIN / ip_forward / MASQUERADE 检测仅适用于 Linux,其他平台需手动确认网络配置。 +

+
+

隧道设置

diff --git a/internal/handler/vpn.go b/internal/handler/vpn.go index f925cc4..0b4b01d 100644 --- a/internal/handler/vpn.go +++ b/internal/handler/vpn.go @@ -187,6 +187,10 @@ func GetVpnStatus(c *gin.Context) { }) } +func GetVpnDiag(c *gin.Context) { + c.JSON(http.StatusOK, vpn.Diag(vpn.VPN)) +} + type reservationResponse struct { ID uint `json:"id"` UserID uint `json:"user_id"` diff --git a/internal/router/router.go b/internal/router/router.go index abf4ba0..fb3bd39 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -38,6 +38,7 @@ func Setup(r *gin.Engine) { admin.GET("/vpn/settings", handler.GetVpnSettings) admin.PUT("/vpn/settings", handler.UpdateVpnSettings) admin.GET("/vpn/status", handler.GetVpnStatus) + admin.GET("/vpn/diag", handler.GetVpnDiag) admin.GET("/vpn/reservations", handler.ListVpnReservations) admin.POST("/vpn/reservations", handler.CreateVpnReservation) admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation) diff --git a/internal/vpn/diag.go b/internal/vpn/diag.go new file mode 100644 index 0000000..77785a1 --- /dev/null +++ b/internal/vpn/diag.go @@ -0,0 +1,52 @@ +package vpn + +import ( + "os" + "runtime" +) + +type DiagResult struct { + Platform string `json:"platform"` + IsRoot bool `json:"is_root"` + HasCapNetAdmin *bool `json:"has_cap_net_admin"` + CapNetAdminNote string `json:"cap_net_admin_note,omitempty"` + IPForward *bool `json:"ip_forward"` + IPForwardNote string `json:"ip_forward_note,omitempty"` + Masquerade *bool `json:"masquerade"` + MasqueradeNote string `json:"masquerade_note,omitempty"` + TUNCreate string `json:"tun_create"` + TUNRunning bool `json:"tun_running"` + TUNName string `json:"tun_name,omitempty"` +} + +func Diag(svc *VpnService) DiagResult { + r := DiagResult{Platform: runtime.GOOS, IsRoot: os.Getuid() == 0} + + if svc != nil { + svc.mu.RLock() + r.TUNRunning = svc.running + if svc.tun != nil { + r.TUNName = svc.tun.Name() + } + svc.mu.RUnlock() + } + + if r.TUNRunning { + r.TUNCreate = "ok: " + r.TUNName + } else { + r.TUNCreate = testTUNCreate() + } + + fillPlatformDiag(&r) + return r +} + +func testTUNCreate() string { + t, err := CreateTUN("") + if err != nil { + return "fail: " + err.Error() + } + name := t.Name() + _ = t.Close() + return "ok: " + name +} diff --git a/internal/vpn/diag_linux.go b/internal/vpn/diag_linux.go new file mode 100644 index 0000000..da2b28e --- /dev/null +++ b/internal/vpn/diag_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package vpn + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +func fillPlatformDiag(r *DiagResult) { + r.HasCapNetAdmin = ptrBool(checkCapNetAdmin()) + if r.HasCapNetAdmin == nil || !*r.HasCapNetAdmin { + r.CapNetAdminNote = "CAP_NET_ADMIN 未授权,TUN 操作需 root 或显式授予该能力" + } + + v := readIPForward() + r.IPForward = v + if v == nil || !*v { + r.IPForwardNote = "未开启,执行: sysctl -w net.ipv4.ip_forward=1" + } + + m := checkMasquerade() + r.Masquerade = m + if m == nil { + r.MasqueradeNote = "iptables 未安装或不可执行" + } else if !*m { + r.MasqueradeNote = "未检测到 MASQUERADE 规则,客户端无法出网" + } +} + +func ptrBool(b bool) *bool { return &b } + +func checkCapNetAdmin() bool { + data, err := os.ReadFile("/proc/self/status") + if err != nil { + return false + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "CapEff:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return false + } + val, err := strconv.ParseUint(fields[1], 16, 64) + if err != nil { + return false + } + return val&(1<<12) != 0 + } + return false +} + +func readIPForward() *bool { + data, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward") + if err != nil { + return nil + } + v := strings.TrimSpace(string(data)) == "1" + return &v +} + +func checkMasquerade() *bool { + cmd := exec.Command("iptables", "-t", "nat", "-L", "POSTROUTING", "-n") + out, err := cmd.Output() + if err != nil { + return nil + } + has := strings.Contains(string(out), "MASQUERADE") + return &has +} diff --git a/internal/vpn/diag_other.go b/internal/vpn/diag_other.go new file mode 100644 index 0000000..0f15bad --- /dev/null +++ b/internal/vpn/diag_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package vpn + +func fillPlatformDiag(r *DiagResult) { + r.HasCapNetAdmin = nil + r.CapNetAdminNote = "仅 Linux 适用" + r.IPForward = nil + r.IPForwardNote = "仅 Linux 适用" + r.Masquerade = nil + r.MasqueradeNote = "仅 Linux 适用" +}