Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/api/handlers/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ func (h *SystemHandler) GetInterfaces(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": interfaces})
}

// GetIPv6Info 获取本机 IPv6 状态(全局地址 + 出站可达性)
func (h *SystemHandler) GetIPv6Info(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": utils.GetIPv6Info()})
}

// ChangePassword 修改管理员密码
func (h *SystemHandler) ChangePassword(c *gin.Context) {
var req struct {
Expand Down
1 change: 1 addition & 0 deletions backend/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func NewRouter(opts RouterOptions) *gin.Engine {
auth.GET("/system/info", sysHandler.GetInfo)
auth.GET("/system/stats", sysHandler.GetStats)
auth.GET("/system/interfaces", sysHandler.GetInterfaces)
auth.GET("/system/ipv6", sysHandler.GetIPv6Info)
auth.POST("/system/change-password", sysHandler.ChangePassword)
admin.GET("/system/config", sysHandler.GetConfig)
admin.PUT("/system/config", sysHandler.UpdateConfig)
Expand Down
78 changes: 78 additions & 0 deletions backend/pkg/utils/ipv6.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package utils

import (
"net"
"time"
)

// IPv6Info 本机 IPv6 检测结果
type IPv6Info struct {
// GlobalAddresses 全局单播 IPv6 地址(排除 loopback/link-local/ULA)
GlobalAddresses []string `json:"global_addresses"`
// HasGlobal 是否存在公网可达的全局 IPv6 地址
HasGlobal bool `json:"has_global"`
// PublicReachable 通过外部端点实测 IPv6 出站可达性(无法实测时为 false)
PublicReachable bool `json:"public_reachable"`
}

// GetIPv6Info 检测本机全局 IPv6 地址与出站可达性。
// 可达性实测走 https://api64.ipify.org(双栈域名,IPv6 优先),3 秒超时。
func GetIPv6Info() IPv6Info {
info := IPv6Info{GlobalAddresses: []string{}}

ifaces, err := net.Interfaces()
if err != nil {
return info
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ip := ipFromAddr(addr)
if ip == nil || !isGlobalIPv6(ip) {
continue
}
info.GlobalAddresses = append(info.GlobalAddresses, ip.String())
}
}
info.HasGlobal = len(info.GlobalAddresses) > 0

if info.HasGlobal {
info.PublicReachable = probeIPv6Outbound()
}
return info
}

func ipFromAddr(addr net.Addr) net.IP {
switch v := addr.(type) {
case *net.IPNet:
return v.IP
case *net.IPAddr:
return v.IP
}
return nil
}

// isGlobalIPv6 判断是否全局单播 IPv6(2000::/3,排除 fd00::/8 ULA 与 fe80::/10 链路本地)
func isGlobalIPv6(ip net.IP) bool {
if ip.To4() != nil || ip.To16() == nil {
return false
}
return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast()
}

// probeIPv6Outbound 强制走 IPv6 拨号验证出站连通性
func probeIPv6Outbound() bool {
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.Dial("tcp6", "api64.ipify.org:443")
if err != nil {
return false
}
conn.Close()
return true
}
1 change: 1 addition & 0 deletions webpage/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ export const systemApi = {
updateConfig: (data: any) => request.put('/v1/system/config', data),
changePassword: (data: any) => request.post('/v1/system/change-password', data),
getInterfaces: () => request.get('/v1/system/interfaces'),
getIPv6Info: () => request.get('/v1/system/ipv6'),
login: (data: any) => request.post('/v1/auth/login', data),
}

Expand Down
12 changes: 12 additions & 0 deletions webpage/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,18 @@ const en = {
speedtestPopup: 'Speed Test Popup',
speedtestPopupTip: 'Show ⚡ speed test button on TunService page for instant line probing',
about: 'About',
ipv6: {
title: 'IPv6 Status',
checking: 'Checking…',
globalAddr: 'Global Address',
yes: 'Yes',
none: 'None',
reachable: 'Outbound Reachability',
reachableOk: 'Reachable',
reachableFail: 'Unreachable',
addresses: 'Addresses',
noGlobalTip: 'No global IPv6 address detected. Check whether your router assigns an IPv6 prefix.',
},
},
// Tunnel services
tunservice: {
Expand Down
12 changes: 12 additions & 0 deletions webpage/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,18 @@ const zh = {
speedtestPopup: '测速弹窗',
speedtestPopupTip: '开启后 TunService 页面显示 ⚡ 测速按钮,可手动触发线路即时测速',
about: '关于',
ipv6: {
title: 'IPv6 状态',
checking: '检测中…',
globalAddr: '全局 IPv6 地址:',
yes: '有',
none: '无',
reachable: '公网可达性:',
reachableOk: '出站正常',
reachableFail: '不可达',
addresses: '地址列表:',
noGlobalTip: '未检测到全局 IPv6 地址。若有公网 IPv6 需求,请检查路由器是否开启 IPv6 前缀分配。',
},
},
// 内网穿透管理
tunservice: {
Expand Down
58 changes: 57 additions & 1 deletion webpage/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ const Settings: React.FC = () => {
// 测速弹窗开关(SystemConfig: speedtest_popup_enabled,默认开启)
const [speedtestEnabled, setSpeedtestEnabled] = useState(true)
const [speedtestLoaded, setSpeedtestLoaded] = useState(false)
// IPv6 状态卡片
const [ipv6, setIpv6] = useState<{ has_global: boolean; public_reachable: boolean; global_addresses: string[] } | null>(null)

// 读取系统配置中的测速弹窗开关
// 读取系统配置中的测速弹窗开关与 IPv6 状态
const loadSpeedtestSwitch = async () => {
try {
const res = await systemApi.getConfig()
Expand All @@ -40,6 +42,12 @@ const Settings: React.FC = () => {
} catch {
// 读取失败保持默认开启
}
try {
const res = await systemApi.getIPv6Info()
setIpv6(res?.data ?? null)
} catch {
// IPv6 检测失败不阻塞页面
}
}
if (!speedtestLoaded) {
setSpeedtestLoaded(true)
Expand Down Expand Up @@ -207,6 +215,54 @@ const Settings: React.FC = () => {
</div>
</Card>
</Col>
{/* IPv6 状态 */}
<Col xs={24} lg={12}>
<Card
title={
<Space>
<GlobalOutlined style={{ color: '#0071e3' }} />
{t('settings.ipv6.title')}
</Space>
}
style={{ borderRadius: 8 }}
>
{!ipv6 ? (
<Text type="secondary">{t('settings.ipv6.checking')}</Text>
) : (
<div style={{ lineHeight: 2 }}>
<div>
<Text type="secondary">{t('settings.ipv6.globalAddr')}</Text>
{ipv6.has_global ? (
<Tag color="green">{t('settings.ipv6.yes')}</Tag>
) : (
<Tag color="default">{t('settings.ipv6.none')}</Tag>
)}
</div>
{ipv6.has_global && (
<div>
<Text type="secondary">{t('settings.ipv6.reachable')}</Text>
{ipv6.public_reachable ? (
<Tag color="green">{t('settings.ipv6.reachableOk')}</Tag>
) : (
<Tag color="orange">{t('settings.ipv6.reachableFail')}</Tag>
)}
</div>
)}
{ipv6.has_global && (
<div>
<Text type="secondary">{t('settings.ipv6.addresses')}</Text>
{ipv6.global_addresses.map((a) => (
<Tag key={a} color="blue" style={{ fontFamily: 'monospace' }}>{a}</Tag>
))}
</div>
)}
{!ipv6.has_global && (
<Text type="secondary">{t('settings.ipv6.noGlobalTip')}</Text>
)}
</div>
)}
</Card>
</Col>
</Row>
</div>
)
Expand Down
Loading