Add per-instance/user access whitelist
Features:
- New Instance.AccessMode: "open" (default) or "whitelist"
- New Instance.AllowNetworks + VPNUser.AllowNetworks: list of CIDRs
- Effective whitelist = instance allow_networks ∪ user allow_networks (dedup)
- Auto-generates client-connect.sh / client-disconnect.sh for OpenVPN:
* Reads ccd/<cn> to extract CIDRs
* Pushes "route <ip> <mask>" to client (client side)
* Inserts iptables ACCEPT rules in FORWARD chain (server side, defense in depth)
* Cleans up rules on disconnect
- server.conf auto-includes client-connect / client-disconnect directives
and push "redirect-gateway def1 bypass-dhcp" in whitelist mode
- ccd/<cn> file format: first line ifconfig-push (static IP), then one CIDR per line
- Editing instance allow_networks refreshes all users' ccd automatically
- New PUT /api/instances/:id/users/:uid endpoint
- CIDR format validation; reject malformed inputs with friendly errors
- Dashboard shows whitelist_instances count and per-instance allow_networks table
Docs:
- README: new section "三、访问控制(白名单模式)" with usage, validation, pitfalls
- docs/API.md: updated Instance / VPNUser model + create/update payloads
- Renumbered client usage section as 四
This commit is contained in:
@@ -57,6 +57,7 @@ func (s *Server) Router(distDir string) *gin.Engine {
|
||||
|
||||
auth.GET("/instances/:id/users", s.listUsers)
|
||||
auth.POST("/instances/:id/users", s.createUser)
|
||||
auth.PUT("/instances/:id/users/:uid", s.updateUser)
|
||||
auth.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
|
||||
auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
|
||||
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN)
|
||||
@@ -140,15 +141,29 @@ func (s *Server) dashboard(c *gin.Context) {
|
||||
cl, _ := s.Svc.ListOnline(in.ID)
|
||||
online += len(cl)
|
||||
}
|
||||
whitelistN := 0
|
||||
overview := []map[string]any{}
|
||||
for _, in := range instances {
|
||||
if in.AccessMode == model.AccessWhitelist {
|
||||
whitelistN++
|
||||
}
|
||||
overview = append(overview, map[string]any{
|
||||
"name": in.Name,
|
||||
"access_mode": in.AccessMode,
|
||||
"allow_networks": in.AllowNetworks,
|
||||
})
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"instances": len(instances),
|
||||
"running": countByStatus(instances, "running"),
|
||||
"users": len(users),
|
||||
"active_users": countEnabled(users),
|
||||
"online": online,
|
||||
"expiring_certs": expiring,
|
||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
||||
"instances": len(instances),
|
||||
"running": countByStatus(instances, "running"),
|
||||
"whitelist_instances": whitelistN,
|
||||
"users": len(users),
|
||||
"active_users": countEnabled(users),
|
||||
"online": online,
|
||||
"expiring_certs": expiring,
|
||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
||||
"access_overview": overview,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -261,6 +276,23 @@ func (s *Server) createUser(c *gin.Context) {
|
||||
c.JSON(200, out)
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(c *gin.Context) {
|
||||
var u model.VPNUser
|
||||
if err := c.ShouldBindJSON(&u); err != nil {
|
||||
c.JSON(400, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
u.ID = c.Param("uid")
|
||||
u.InstanceID = c.Param("id")
|
||||
if err := s.Svc.UpdateUser(u); err != nil {
|
||||
s.Svc.AuditForAPI(c, "update_user", u.Username, err.Error(), "failed")
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "update_user", u.Username, "", "ok")
|
||||
c.JSON(200, u)
|
||||
}
|
||||
|
||||
func (s *Server) revokeUser(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
if err := s.Svc.RevokeUser(uid); err != nil {
|
||||
|
||||
@@ -2,40 +2,54 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AccessMode 控制客户端的访问范围。
|
||||
// - "open": 不限制,客户端可访问所有可达网段(默认)
|
||||
// - "whitelist":白名单模式,仅允许访问 Instance 与 User 合并后的 allow_networks
|
||||
// 列表中的网段,其他内网流量在服务端 FORWARD 链被丢弃
|
||||
type AccessMode string
|
||||
|
||||
const (
|
||||
AccessOpen AccessMode = "open"
|
||||
AccessWhitelist AccessMode = "whitelist"
|
||||
)
|
||||
|
||||
// Instance 一个 OpenVPN 服务端实例。
|
||||
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
|
||||
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // 唯一名,作为目录名
|
||||
Port int `json:"port"` // openvpn 监听端口
|
||||
Proto string `json:"proto"` // udp / tcp
|
||||
Dev string `json:"dev"` // tun / tap
|
||||
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
|
||||
Cipher string `json:"cipher"` // 加密算法
|
||||
AuthDigest string `json:"auth_digest"` // 摘要算法
|
||||
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
|
||||
PushRoutes string `json:"push_routes"` // 多行
|
||||
Extra string `json:"extra"` // 用户追加配置
|
||||
Status string `json:"status"` // running/stopped/error
|
||||
PID int `json:"pid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // 唯一名,作为目录名
|
||||
Port int `json:"port"` // openvpn 监听端口
|
||||
Proto string `json:"proto"` // udp / tcp
|
||||
Dev string `json:"dev"` // tun / tap
|
||||
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
|
||||
Cipher string `json:"cipher"` // 加密算法
|
||||
AuthDigest string `json:"auth_digest"` // 摘要算法
|
||||
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
|
||||
PushRoutes string `json:"push_routes"` // 多行
|
||||
Extra string `json:"extra"` // 用户追加配置
|
||||
AccessMode AccessMode `json:"access_mode"` // open | whitelist
|
||||
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
|
||||
Status string `json:"status"` // running/stopped/error
|
||||
PID int `json:"pid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VPNUser 一个 OpenVPN 客户端用户。
|
||||
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
|
||||
type VPNUser struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Username string `json:"username"` // 证书 CN
|
||||
RealName string `json:"real_name"` // 备注
|
||||
Email string `json:"email"`
|
||||
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
|
||||
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Username string `json:"username"` // 证书 CN
|
||||
RealName string `json:"real_name"` // 备注
|
||||
Email string `json:"email"`
|
||||
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
|
||||
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
|
||||
AllowNetworks []string `json:"allow_networks"` // 用户级白名单(在实例基础上叠加)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuditLog 操作审计日志
|
||||
|
||||
@@ -88,6 +88,15 @@ func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
|
||||
if in.Subnet == "" {
|
||||
in.Subnet = "10.8.0.0/24"
|
||||
}
|
||||
if in.AccessMode == "" {
|
||||
in.AccessMode = model.AccessOpen
|
||||
}
|
||||
// 校验白名单 CIDR
|
||||
for _, c := range in.AllowNetworks {
|
||||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||||
return nil, fmt.Errorf("instance allow_networks: %w", err)
|
||||
}
|
||||
}
|
||||
// 名称查重
|
||||
if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
|
||||
return nil, fmt.Errorf("instance %s already exists", in.Name)
|
||||
@@ -111,11 +120,16 @@ func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
|
||||
if err := s.Ovm.IssueServerCert(in.Name); err != nil {
|
||||
return nil, fmt.Errorf("issue server cert: %w", err)
|
||||
}
|
||||
// 写 server.conf
|
||||
// 写 server.conf + 白名单脚本
|
||||
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||||
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||||
return nil, fmt.Errorf("write conf: %w", err)
|
||||
}
|
||||
if in.AccessMode == model.AccessWhitelist {
|
||||
if _, err := s.Ovm.AllowNetworksScript(in.Name, in.AllowNetworks); err != nil {
|
||||
return nil, fmt.Errorf("gen whitelist scripts: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.Store.UpsertInstance(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -128,6 +142,26 @@ func (s *Service) UpdateInstance(in model.Instance) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.AccessMode == "" {
|
||||
in.AccessMode = old.AccessMode
|
||||
}
|
||||
if in.Cipher == "" {
|
||||
in.Cipher = old.Cipher
|
||||
}
|
||||
if in.AuthDigest == "" {
|
||||
in.AuthDigest = old.AuthDigest
|
||||
}
|
||||
if in.Dev == "" {
|
||||
in.Dev = old.Dev
|
||||
}
|
||||
if in.Subnet == "" {
|
||||
in.Subnet = old.Subnet
|
||||
}
|
||||
for _, c := range in.AllowNetworks {
|
||||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||||
return fmt.Errorf("instance allow_networks: %w", err)
|
||||
}
|
||||
}
|
||||
in.CreatedAt = old.CreatedAt
|
||||
in.UpdatedAt = time.Now()
|
||||
in.Status = old.Status
|
||||
@@ -136,7 +170,24 @@ func (s *Service) UpdateInstance(in model.Instance) error {
|
||||
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.UpsertInstance(in)
|
||||
if in.AccessMode == model.AccessWhitelist {
|
||||
if _, err := s.Ovm.AllowNetworksScript(in.Name, in.AllowNetworks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.Store.UpsertInstance(in); err != nil {
|
||||
return err
|
||||
}
|
||||
// 实例级 allow_networks 变更后,刷新所有用户的 ccd
|
||||
inst := in
|
||||
for _, u := range s.Store.ListUsers(inst.ID) {
|
||||
u := u
|
||||
merged := openvpn.MergeAllowNetworks(inst.AllowNetworks, u.AllowNetworks)
|
||||
if err := s.writeUserCCD(&inst, &u, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInstance 移除实例及其 PKI/配置。
|
||||
@@ -238,6 +289,12 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
||||
if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
|
||||
return nil, fmt.Errorf("user %s already exists", u.Username)
|
||||
}
|
||||
// 校验用户级 CIDR
|
||||
for _, c := range u.AllowNetworks {
|
||||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||||
return nil, fmt.Errorf("user allow_networks: %w", err)
|
||||
}
|
||||
}
|
||||
u.ID = uuid.NewString()
|
||||
u.Enabled = true
|
||||
u.CreatedAt = time.Now()
|
||||
@@ -245,12 +302,10 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
||||
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CCD
|
||||
if u.StaticIP != "" {
|
||||
ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n"
|
||||
if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CCD: 合并实例级与用户级白名单(白名单模式才生效)
|
||||
merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
|
||||
if err := s.writeUserCCD(in, &u, merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
|
||||
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil {
|
||||
@@ -312,6 +367,47 @@ func (s *Service) DeleteUser(userID string) error {
|
||||
return s.Store.DeleteUser(userID)
|
||||
}
|
||||
|
||||
// UpdateUser 修改用户(主要用于改 allow_networks / static_ip 等)。
|
||||
func (s *Service) UpdateUser(u model.VPNUser) error {
|
||||
old, err := s.Store.GetUser(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range u.AllowNetworks {
|
||||
if err := openvpn.ValidateCIDR(c); err != nil {
|
||||
return fmt.Errorf("user allow_networks: %w", err)
|
||||
}
|
||||
}
|
||||
u.CreatedAt = old.CreatedAt
|
||||
u.Enabled = old.Enabled
|
||||
u.RevokedAt = old.RevokedAt
|
||||
in, err := s.Store.GetInstance(u.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
|
||||
if err := s.writeUserCCD(in, &u, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.UpsertUser(u)
|
||||
}
|
||||
|
||||
// writeUserCCD 把允许网段写进 ccd/<cn>,client-connect 脚本读取后推送 route。
|
||||
// 文件内容:
|
||||
// - 第 1 行 ifconfig-push (固定 IP)
|
||||
// - 之后每行一个 CIDR (允许的网段)
|
||||
// 这样设计既兼容现有 static_ip 场景,又能让 client-connect.sh 简单 grep。
|
||||
func (s *Service) writeUserCCD(in *model.Instance, u *model.VPNUser, allowNets []string) error {
|
||||
var b strings.Builder
|
||||
if u.StaticIP != "" {
|
||||
b.WriteString("ifconfig-push " + u.StaticIP + " 255.255.255.0\n")
|
||||
}
|
||||
for _, c := range allowNets {
|
||||
b.WriteString(c + "\n")
|
||||
}
|
||||
return s.Ovm.WriteCCD(in.Name, u.Username, b.String())
|
||||
}
|
||||
|
||||
// CertInfos 汇总所有用户证书的过期时间。
|
||||
func (s *Service) CertInfos() ([]model.CertInfo, error) {
|
||||
var out []model.CertInfo
|
||||
|
||||
@@ -127,6 +127,17 @@ func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
|
||||
if extraDir != "" {
|
||||
conf.WriteString("client-config-dir " + extraDir + "\n")
|
||||
}
|
||||
if in.AccessMode == model.AccessWhitelist {
|
||||
// 白名单模式:openVPN 在每次客户端连接时调用脚本应用 iptables
|
||||
// 该脚本由 manager.AllowNetworksScript() 生成,负责:
|
||||
// 1. 写入合并后的白名单到 ccd/<cn>(push route)
|
||||
// 2. 在 FORWARD 链中按白名单过滤 tun->eth 的流量
|
||||
conf.WriteString("client-connect " + filepath.Join(m.InstanceDir(in.Name), "client-connect.sh") + "\n")
|
||||
conf.WriteString("client-disconnect " + filepath.Join(m.InstanceDir(in.Name), "client-disconnect.sh") + "\n")
|
||||
// 强制客户端把流量全部走 VPN(否则白名单的 push route 没意义)
|
||||
// 仍允许 bypass-dhcp 保留局域网 DNS
|
||||
conf.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
|
||||
}
|
||||
if in.Extra != "" {
|
||||
conf.WriteString("\n# --- custom ---\n")
|
||||
conf.WriteString(in.Extra + "\n")
|
||||
@@ -265,6 +276,181 @@ func (m *Manager) WriteCCD(instanceName, username, body string) error {
|
||||
return os.WriteFile(filepath.Join(dir, username), []byte(body+"\n"), 0o644)
|
||||
}
|
||||
|
||||
// ValidateCIDR 校验 CIDR 是否合法(IP + /prefix 长度)。
|
||||
func ValidateCIDR(s string) error {
|
||||
ip, ipnet, err := net.ParseCIDR(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid CIDR %q: %w", s, err)
|
||||
}
|
||||
if ip.To4() == nil && ip.To16() == nil {
|
||||
return fmt.Errorf("invalid IP in CIDR %q", s)
|
||||
}
|
||||
_ = ipnet
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeAllowNetworks 合并实例级与用户级白名单,去重去空。
|
||||
func MergeAllowNetworks(a, b []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, s := range append(append([]string{}, a...), b...) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CIDRToRoute 将 CIDR 转为 OpenVPN push route 用的字符串(IP + Netmask)。
|
||||
// 仅支持 IPv4。IPv6 用 /128 全 0 掩码(占位,本项目主要面向 IPv4)。
|
||||
func CIDRToRoute(s string) (string, error) {
|
||||
if err := ValidateCIDR(s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, ipnet, _ := net.ParseCIDR(strings.TrimSpace(s))
|
||||
mask := ipnet.Mask
|
||||
ones, _ := ipnet.Mask.Size()
|
||||
// 转 4 字节点分十进制
|
||||
if len(mask) == 4 {
|
||||
return fmt.Sprintf("%s %d.%d.%d.%d", ipnet.IP.String(), mask[0], mask[1], mask[2], mask[3]), nil
|
||||
}
|
||||
// IPv6: 暂用 /128 占位,openVPN 走 route-ipv6 单独处理
|
||||
_ = ones
|
||||
return "", fmt.Errorf("IPv6 not yet supported: %s", s)
|
||||
}
|
||||
|
||||
// AllowNetworksScript 生成 client-connect 脚本:
|
||||
// - 输入: $1 = 连接上下文, $common_name=<CN>
|
||||
// - 作用:读 ccd/<cn>,根据其白名单在 FORWARD 链插入 ACCEPT
|
||||
//
|
||||
// client-connect 脚本的 stdin 是 OpenVPN 提供的环境变量,我们打印
|
||||
// "push route ..." 行让 openvpn 推给客户端。
|
||||
func (m *Manager) AllowNetworksScript(instanceName string, nets []string) (string, error) {
|
||||
dir := filepath.Join(m.dataDir, "instances", instanceName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
script := filepath.Join(dir, "client-connect.sh")
|
||||
var b strings.Builder
|
||||
b.WriteString("#!/bin/bash\n")
|
||||
b.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
|
||||
b.WriteString("set -e\n\n")
|
||||
b.WriteString("# OpenVPN 客户端连接脚本: 接受 ccd 之外额外的 push 指令\n")
|
||||
b.WriteString("# 我们使用 stdin 提供的环境变量(由 OpenVPN 注入),按白名单允许的网段\n")
|
||||
b.WriteString("# 写入 FORWARD 链,然后通过 echo 'push \"route ...\"' 回传给 OpenVPN。\n\n")
|
||||
b.WriteString("CN=\"${common_name:-}\"\n")
|
||||
b.WriteString("VPN_IP=\"${ifconfig_pool_remote_ip:-${trusted_ip:-}}\"\n\n")
|
||||
b.WriteString("# 仅处理已启用的用户:从 ccd/<cn> 读取 allow_networks(若文件不存在则拒绝推送任何路由)\n")
|
||||
b.WriteString("CCD_FILE=\"$(dirname \"$0\")/ccd/${CN}\"\n")
|
||||
b.WriteString("if [[ ! -f \"$CCD_FILE\" ]]; then\n")
|
||||
b.WriteString(" echo \"# ccd file missing for $CN, denying\" >&2\n")
|
||||
b.WriteString(" exit 1\n")
|
||||
b.WriteString("fi\n\n")
|
||||
b.WriteString("# 解析 allow_networks(grep + awk)\n")
|
||||
b.WriteString("split_cidr() {\n")
|
||||
b.WriteString(" local cidr=\"$1\"\n")
|
||||
b.WriteString(" local ip mask\n")
|
||||
b.WriteString(" ip=\"${cidr%/*}\"\n")
|
||||
b.WriteString(" local prefix=\"${cidr##*/}\"\n")
|
||||
b.WriteString(" case \"$prefix\" in\n")
|
||||
b.WriteString(" 32) mask=\"255.255.255.255\" ;;\n")
|
||||
b.WriteString(" 31) mask=\"255.255.255.254\" ;;\n")
|
||||
b.WriteString(" 30) mask=\"255.255.255.252\" ;;\n")
|
||||
b.WriteString(" 29) mask=\"255.255.255.248\" ;;\n")
|
||||
b.WriteString(" 28) mask=\"255.255.255.240\" ;;\n")
|
||||
b.WriteString(" 27) mask=\"255.255.255.224\" ;;\n")
|
||||
b.WriteString(" 26) mask=\"255.255.255.192\" ;;\n")
|
||||
b.WriteString(" 25) mask=\"255.255.255.128\" ;;\n")
|
||||
b.WriteString(" 24) mask=\"255.255.255.0\" ;;\n")
|
||||
b.WriteString(" 23) mask=\"255.255.254.0\" ;;\n")
|
||||
b.WriteString(" 22) mask=\"255.255.252.0\" ;;\n")
|
||||
b.WriteString(" 21) mask=\"255.255.248.0\" ;;\n")
|
||||
b.WriteString(" 20) mask=\"255.255.240.0\" ;;\n")
|
||||
b.WriteString(" 19) mask=\"255.255.224.0\" ;;\n")
|
||||
b.WriteString(" 18) mask=\"255.255.192.0\" ;;\n")
|
||||
b.WriteString(" 17) mask=\"255.255.128.0\" ;;\n")
|
||||
b.WriteString(" 16) mask=\"255.255.0.0\" ;;\n")
|
||||
b.WriteString(" 15) mask=\"255.254.0.0\" ;;\n")
|
||||
b.WriteString(" 14) mask=\"255.252.0.0\" ;;\n")
|
||||
b.WriteString(" 13) mask=\"255.248.0.0\" ;;\n")
|
||||
b.WriteString(" 12) mask=\"255.240.0.0\" ;;\n")
|
||||
b.WriteString(" 11) mask=\"255.224.0.0\" ;;\n")
|
||||
b.WriteString(" 10) mask=\"255.192.0.0\" ;;\n")
|
||||
b.WriteString(" 9) mask=\"255.128.0.0\" ;;\n")
|
||||
b.WriteString(" 8) mask=\"255.0.0.0\" ;;\n")
|
||||
b.WriteString(" *) echo \"unsupported prefix /$prefix for $cidr\" >&2; return 1 ;;\n")
|
||||
b.WriteString(" esac\n")
|
||||
b.WriteString(" echo \"$ip $mask\"\n")
|
||||
b.WriteString("}\n\n")
|
||||
b.WriteString("while IFS= read -r net; do\n")
|
||||
b.WriteString(" [[ -z \"$net\" || \"$net\" =~ ^# ]] && continue\n")
|
||||
b.WriteString(" pair=\"$(split_cidr \"$net\")\" || continue\n")
|
||||
b.WriteString(" echo \"push \\\"route ${pair}\\\"\"\n")
|
||||
b.WriteString("done < <(grep -E '^[0-9]' \"$CCD_FILE\" || true)\n\n")
|
||||
b.WriteString("# iptables:放行此客户端 VPN_IP -> 白名单网段的 FORWARD\n")
|
||||
b.WriteString("if [[ -n \"$VPN_IP\" ]]; then\n")
|
||||
b.WriteString(" while IFS= read -r net; do\n")
|
||||
b.WriteString(" [[ -z \"$net\" || \"$net\" =~ ^# ]] && continue\n")
|
||||
b.WriteString(" /sbin/iptables -I FORWARD -s \"$VPN_IP\" -d \"$net\" -j ACCEPT 2>/dev/null || true\n")
|
||||
b.WriteString(" /sbin/iptables -I FORWARD -d \"$VPN_IP\" -s \"$net\" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true\n")
|
||||
b.WriteString(" done < <(grep -E '^[0-9]' \"$CCD_FILE\" || true)\n")
|
||||
b.WriteString("fi\n\n")
|
||||
b.WriteString("exit 0\n")
|
||||
if err := os.WriteFile(script, []byte(b.String()), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 同样的逻辑写到 disconnect 脚本:清理 iptables 规则
|
||||
dis := filepath.Join(dir, "client-disconnect.sh")
|
||||
var d strings.Builder
|
||||
d.WriteString("#!/bin/bash\n")
|
||||
d.WriteString("# openvpn-manager generated\n")
|
||||
d.WriteString("VPN_IP=\"${ifconfig_pool_remote_ip:-${trusted_ip:-}}\"\n")
|
||||
d.WriteString("CN=\"${common_name:-}\"\n")
|
||||
d.WriteString("if [[ -n \"$VPN_IP\" ]]; then\n")
|
||||
d.WriteString(" /sbin/iptables -D FORWARD -s \"$VPN_IP\" -j ACCEPT 2>/dev/null || true\n")
|
||||
d.WriteString(" /sbin/iptables -D FORWARD -d \"$VPN_IP\" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true\n")
|
||||
d.WriteString("fi\n")
|
||||
d.WriteString("exit 0\n")
|
||||
if err := os.WriteFile(dis, []byte(d.String()), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
// InstanceDir 返回实例的根目录(供脚本路径使用)。
|
||||
func (m *Manager) InstanceDir(name string) string {
|
||||
return filepath.Join(m.dataDir, "instances", name)
|
||||
}
|
||||
|
||||
// SetIptablesForInstance 应用一个实例的默认 FORWARD 策略。
|
||||
// 在白名单模式下,我们希望"默认拒绝 tun->eth 的转发",再由 client-connect 脚本按需放行。
|
||||
// 这个函数在实例启动时调用一次。
|
||||
//
|
||||
// 注意: iptables 修改宿主机的防火墙,需要 root 权限。脚本会以 noop 模式尝试运行,
|
||||
// 失败仅记录日志,不会阻塞实例启动(允许开发环境无 iptables)。
|
||||
func (m *Manager) SetIptablesForInstance(in *model.Instance) error {
|
||||
if in.AccessMode != model.AccessWhitelist {
|
||||
return nil
|
||||
}
|
||||
tunName := in.Dev // tun0 / tun1 ...
|
||||
if in.Dev == "tap" {
|
||||
tunName = "tap0"
|
||||
}
|
||||
// 默认策略:FORWARD 链中,凡是从 tun 设备出去的,如果未命中 ACCEPT 一律 DROP
|
||||
// 这条规则加到 FORWARD 链末尾,优先级低于 client-connect 插入的 ACCEPT
|
||||
cmd := exec.Command("/sbin/iptables", "-A", "FORWARD", "-i", tunName, "-j", "REJECT",
|
||||
"--reject-with", "icmp-net-unreachable")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// 不致命:开发机可能没装 iptables 或没权限
|
||||
return fmt.Errorf("iptables: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCCD 移除 ccd 文件。
|
||||
func (m *Manager) DeleteCCD(instanceName, username string) error {
|
||||
p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username)
|
||||
|
||||
Reference in New Issue
Block a user