Files
opsx ef7eb378d2 feat: 新增 GRE 隧道协议,管理端口调整为 18080
- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2
- backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动
- backend: OpenVPN 认证辅助与用户口令同步逻辑完善
- frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行)
- frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化
- i18n: 中英文文案同步更新
- test: 新增 binmgr/common/gre 单测,openvpn 测试补充
- docs: README 补充 GRE 说明,默认监听端口 8080 → 18080
- chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
2026-10-20 20:58:58 +08:00

329 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package gre 实现 GRE 隧道管理(Linux ip tunnel)。
// GRE 是简单的 IP-over-IP 封装,依赖 iproute2 的 ip 命令。
package gre
import (
"fmt"
"net/netip"
"regexp"
"strings"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
type Service struct{}
var interfaceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,15}$`)
func New() *Service { return &Service{} }
func (s *Service) Name() string { return "gre" }
func (s *Service) DisplayName() string { return "GRE 隧道" }
func (s *Service) Available() (bool, string) {
if p, ok := service.LookBin("ip"); ok {
return true, p
}
return false, "未检测到 iproute2(ip),GRE 隧道需要 ip 命令"
}
func ifaceName(t *model.Tunnel) string {
return fmt.Sprintf("gre%d", t.ID)
}
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
if err := service.MustValidate(cfg, "local_endpoint", "remote_endpoint", "local_tunnel_ip", "remote_tunnel_ip"); err != nil {
return nil, err
}
out := service.CloneConfig(cfg)
// Validate IPs
localEndpoint, err := netip.ParseAddr(service.Str(out, "local_endpoint"))
if err != nil || !localEndpoint.Is4() {
return nil, fmt.Errorf("local_endpoint 必须是有效的 IP 地址")
}
remoteEndpoint, err := netip.ParseAddr(service.Str(out, "remote_endpoint"))
if err != nil || !remoteEndpoint.Is4() {
return nil, fmt.Errorf("remote_endpoint 必须是有效的 IP 地址")
}
localTunnel, err := netip.ParsePrefix(service.Str(out, "local_tunnel_ip"))
if err != nil || !localTunnel.Addr().Is4() {
return nil, fmt.Errorf("local_tunnel_ip 必须是 CIDR 格式(如 10.10.0.1/30)")
}
remoteTunnel, err := netip.ParsePrefix(service.Str(out, "remote_tunnel_ip"))
if err != nil || !remoteTunnel.Addr().Is4() {
return nil, fmt.Errorf("remote_tunnel_ip 必须是 CIDR 格式(如 10.10.0.2/30)")
}
if localTunnel == remoteTunnel || localTunnel.Masked() != remoteTunnel.Masked() || localTunnel.Bits() != remoteTunnel.Bits() {
return nil, fmt.Errorf("local_tunnel_ip 与 remote_tunnel_ip 必须位于同一隧道子网")
}
if remoteSubnet := service.Str(out, "remote_subnet"); remoteSubnet != "" {
prefix, err := netip.ParsePrefix(remoteSubnet)
if err != nil || !prefix.Addr().Is4() {
return nil, fmt.Errorf("remote_subnet 必须是 IPv4 CIDR")
}
}
if outbound := service.Str(out, "outbound_interface"); outbound != "" && !interfaceNamePattern.MatchString(outbound) {
return nil, fmt.Errorf("outbound_interface 包含非法字符")
}
// Defaults
if service.Num(out, "ttl", 0) == 0 {
out["ttl"] = 255
}
if service.Num(out, "mtu", 0) == 0 {
out["mtu"] = 1476
}
if ttl := int(service.Num(out, "ttl", 255)); ttl < 1 || ttl > 255 {
return nil, fmt.Errorf("ttl 必须在 1-255")
}
if mtu := int(service.Num(out, "mtu", 1476)); mtu < 576 || mtu > 9000 {
return nil, fmt.Errorf("mtu 必须在 576-9000")
}
return out, nil
}
func (s *Service) Create(t *model.Tunnel) error {
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
cfg, err = s.Validate(cfg)
if err != nil {
return err
}
// Save canonicalised config
public, sec := service.MergeSecrets(cfg)
return service.SaveTunnelConfig(t, public, sec)
}
func (s *Service) Start(t *model.Tunnel) error {
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
ip, ok := service.LookBin("ip")
if !ok {
return fmt.Errorf("无法启动 GRE:未检测到 ip 命令")
}
if err := service.RequireRoot("GRE"); err != nil {
return err
}
ifName := ifaceName(t)
localEP := service.Str(cfg, "local_endpoint")
remoteEP := service.Str(cfg, "remote_endpoint")
ttl := int(service.Num(cfg, "ttl", 255))
mtu := int(service.Num(cfg, "mtu", 1476))
localTunnelIP := service.Str(cfg, "local_tunnel_ip")
// Clean up any stale interface first
_, _ = service.Run(ip, "tunnel", "del", ifName)
// ip tunnel add greN mode gre local X remote Y ttl T
if _, err := service.Run(ip, "tunnel", "add", ifName,
"mode", "gre",
"local", localEP,
"remote", remoteEP,
"ttl", fmt.Sprintf("%d", ttl),
); err != nil {
return fmt.Errorf("创建 GRE 隧道接口失败: %w", err)
}
// ip link set greN mtu M
if _, err := service.Run(ip, "link", "set", ifName, "mtu", fmt.Sprintf("%d", mtu)); err != nil {
_, _ = service.Run(ip, "tunnel", "del", ifName)
return fmt.Errorf("设置 GRE MTU 失败: %w", err)
}
// ip addr add local_tunnel_ip dev greN
if _, err := service.Run(ip, "addr", "add", localTunnelIP, "dev", ifName); err != nil {
_, _ = service.Run(ip, "tunnel", "del", ifName)
return fmt.Errorf("分配 GRE 隧道 IP 失败: %w", err)
}
// ip link set greN up
if _, err := service.Run(ip, "link", "set", ifName, "up"); err != nil {
_, _ = service.Run(ip, "tunnel", "del", ifName)
return fmt.Errorf("启用 GRE 接口失败: %w", err)
}
// Optional: route remote subnet via remote tunnel IP
if remoteSubnet := service.Str(cfg, "remote_subnet"); remoteSubnet != "" {
remoteTunnelIP := service.Str(cfg, "remote_tunnel_ip")
// Extract just the IP (without prefix) for the route gateway
if addr, err := netip.ParsePrefix(remoteTunnelIP); err == nil {
gw := addr.Addr().String()
_, _ = service.Run(ip, "route", "add", remoteSubnet, "via", gw, "dev", ifName)
}
}
// Optional: NAT / MASQUERADE
if service.Bool(cfg, "enable_nat", false) {
if err := s.installFirewall(t, cfg); err != nil {
_, _ = service.Run(ip, "tunnel", "del", ifName)
return err
}
}
return nil
}
func (s *Service) Stop(t *model.Tunnel) error {
ip, ok := service.LookBin("ip")
if !ok {
return fmt.Errorf("无法停止 GRE:未检测到 ip 命令")
}
ifName := ifaceName(t)
_, _ = service.Run(ip, "tunnel", "del", ifName)
s.removeFirewall(t)
return nil
}
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
ip, ok := service.LookBin("ip")
if !ok {
return service.NowStatus(false, "undeployed", "未检测到 ip 命令,GRE 隧道未部署"), nil
}
ifName := ifaceName(t)
// Check tunnel exists
if _, err := service.Run(ip, "tunnel", "show", ifName); err != nil {
return service.NowStatus(false, "stopped", "GRE 隧道接口不存在"), nil
}
// Check link is UP
out, err := service.Run(ip, "link", "show", ifName)
if err != nil {
return service.NowStatus(false, "stopped", "无法读取 GRE 接口状态"), nil
}
if strings.Contains(out, "UP") {
cfg, _ := store.TunnelConfig(t)
detail := fmt.Sprintf("GRE 隧道 %s 运行中", ifName)
if le := service.Str(cfg, "local_endpoint"); le != "" {
detail += fmt.Sprintf(",本端 %s → 对端 %s", le, service.Str(cfg, "remote_endpoint"))
}
return service.NowStatus(true, "running", detail), nil
}
return service.NowStatus(false, "stopped", "GRE 隧道接口存在但未 UP"), nil
}
func (s *Service) Delete(t *model.Tunnel) error {
_ = s.Stop(t)
return service.RemoveInstance(t)
}
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
cfg, err := store.TunnelConfig(t)
if err != nil {
return "", err
}
ttl := int(service.Num(cfg, "ttl", 255))
mtu := int(service.Num(cfg, "mtu", 1476))
localEP := service.Str(cfg, "local_endpoint")
remoteEP := service.Str(cfg, "remote_endpoint")
localTunnelIP := service.Str(cfg, "local_tunnel_ip")
remoteTunnelIP := service.Str(cfg, "remote_tunnel_ip")
// Extract just the address portion for the route gateway
gw := remoteTunnelIP
if prefix, err := netip.ParsePrefix(remoteTunnelIP); err == nil {
gw = prefix.Addr().String()
}
return fmt.Sprintf(`======== GRE 隧道对端参考配置 ========
本端公网地址(local_endpoint) : %s
对端公网地址(remote_endpoint) : %s
本端隧道 IP(local_tunnel_ip) : %s
对端隧道 IP(remote_tunnel_ip) : %s
TTL : %d
MTU : %d
----- 对端配置指引 -----
ip tunnel add greN mode gre local %s remote %s ttl %d
ip addr add %s dev greN
ip link set greN up
注意:
- 双端 local_endpoint / remote_endpoint 需交叉对应
- 隧道 IP 建议使用同一 /30 子网
- 如需 NAT 请在各自防火墙中添加 MASQUERADE 规则
- remote_subnet 路由示例: ip route add <子网> via %s dev greN
`,
localEP,
remoteEP,
localTunnelIP,
remoteTunnelIP,
ttl,
mtu,
remoteEP,
localEP,
ttl,
remoteTunnelIP,
gw,
), nil
}
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
outbound := service.Str(cfg, "outbound_interface")
if outbound == "" {
var err error
outbound, err = service.DefaultRouteInterface()
if err != nil {
return fmt.Errorf("无法确定 NAT 出口接口,请设置 outbound_interface: %w", err)
}
}
comment := fmt.Sprintf("opsx-vpn-gre-%d", t.ID)
remoteSubnet := service.Str(cfg, "remote_subnet")
if remoteSubnet == "" {
// Use the tunnel IP prefix as the source for MASQUERADE
if prefix, err := netip.ParsePrefix(service.Str(cfg, "remote_tunnel_ip")); err == nil {
remoteSubnet = prefix.Masked().String()
}
}
if remoteSubnet != "" {
rules := []service.FirewallRule{
{Table: "filter", Chain: "FORWARD", Args: []string{"-i", ifaceName(t), "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{Table: "filter", Chain: "FORWARD", Args: []string{"-o", ifaceName(t), "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
{Table: "nat", Chain: "POSTROUTING", Args: []string{"-s", remoteSubnet, "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
}
return service.ApplyFirewallRules(rules)
}
return nil
}
func (s *Service) removeFirewall(t *model.Tunnel) {
comment := fmt.Sprintf("opsx-vpn-gre-%d", t.ID)
iptables, ok := service.LookBin("iptables")
if !ok {
return
}
// Remove rules matching our comment in FORWARD and POSTROUTING chains
for _, chain := range []struct{ table, name string }{
{"filter", "FORWARD"},
{"nat", "POSTROUTING"},
} {
for {
out, err := service.Run(iptables, "-t", chain.table, "-L", chain.name, "-n", "--line-numbers")
if err != nil || !strings.Contains(out, comment) {
break
}
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, comment) {
continue
}
fields := strings.Fields(line)
if len(fields) > 0 {
_, _ = service.Run(iptables, "-t", chain.table, "-D", chain.name, fields[0])
}
}
break
}
}
}