From 4c8b7b518819407a11a2ac574e54a41489c01117 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Sun, 9 Aug 2026 20:47:21 +0800 Subject: [PATCH] Add per-instance/user access whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ to extract CIDRs * Pushes "route " 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/ 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 四 --- README.md | 120 ++++++++++++++- backend/internal/api/router.go | 48 +++++- backend/internal/model/model.go | 64 ++++---- backend/internal/service/service.go | 112 +++++++++++++- backend/pkg/openvpn/manager.go | 186 ++++++++++++++++++++++++ docs/API.md | 27 +++- frontend/src/api/index.js | 1 + frontend/src/components/NetworkList.vue | 92 ++++++++++++ frontend/src/views/Dashboard.vue | 25 +++- frontend/src/views/Instances.vue | 34 ++++- frontend/src/views/Users.vue | 52 +++++-- 11 files changed, 697 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/NetworkList.vue diff --git a/README.md b/README.md index c9b0ad5..e1a1ec3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ - [创建第一个实例](#创建第一个实例) - [创建客户端用户并下载配置](#创建客户端用户并下载配置) - [防火墙与公网暴露](#防火墙与公网暴露) -- [三、客户端使用](#三客户端使用) +- [三、访问控制(白名单模式)](#三访问控制白名单模式) +- [四、客户端使用](#四客户端使用) - [配置文件说明](#配置文件说明) - [Windows](#windows) - [macOS](#macos) @@ -34,9 +35,9 @@ - [Android](#android) - [iOS](#ios) - [验证连接](#验证连接) -- [四、API 参考](#四api-参考) -- [五、常见问题](#五常见问题) -- [六、开发](#六开发) +- [五、API 参考](#五api-参考) +- [六、常见问题](#六常见问题) +- [七、开发](#七开发) - [许可证](#许可证) --- @@ -48,6 +49,7 @@ | 多实例管理 | 同一台机器上跑多个 OpenVPN 实例,每个独立端口/协议/子网/PKI | | 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 | | 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP | +| **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 | | 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 | | 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 | | 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 | @@ -359,6 +361,110 @@ firewall-cmd --reload --- +# 三、访问控制(白名单模式) + +**核心需求**:VPN 用户登录后,只能访问你明确允许的内网网段,默认与所有内网隔离。 + +## 工作原理 + +每个实例有一个 `access_mode` 字段: + +- `open`(默认):不限制,客户端可访问所有可达网段 +- `whitelist`:仅允许访问白名单中列出的内网网段 + +当 `access_mode=whitelist` 时: + +1. **实例配置 `allow_networks`**:该实例下所有用户共享的允许网段 +2. **用户配置 `allow_networks`**:单个用户的额外允许网段(在实例基础上叠加) +3. **实际生效** = 实例白名单 ∪ 用户白名单(去重) +4. **服务端强制**: + - OpenVPN 自动生成 `client-connect.sh` / `client-disconnect.sh` 脚本 + - 每个用户连接时,服务端的 FORWARD 链插入 iptables ACCEPT 规则,只放行到白名单网段的流量 + - 其他内网流量在服务端被 REJECT(客户端看到的是"无法连接",而非"超时") +5. **客户端推送**: + - 自动 `push "redirect-gateway def1 bypass-dhcp"` 让客户端把所有流量都走 VPN(否则白名单没意义) + - 自动按白名单 `push route ...` 让客户端知道这些网段在 VPN 后 + +> 双重防护:即使客户端操作系统被绕过,iptables 仍会拒绝非法流量。 + +## 使用方法 + +### Web 端 + +1. **实例管理 → 新建/编辑实例** + - "访问控制"下拉选 **"白名单(whitelist)"** + - "允许网段"标签输入:每行一个 CIDR,如 `192.168.1.0/24`、`10.0.0.0/8` + - 保存 + +2. **用户管理 → 编辑用户**(可选) + - "额外允许网段":这个用户特有的、实例未列出的网段 + - 保存 + +3. **启动实例** —— 实例启动时,会执行一次 `iptables -A FORWARD -i tunX -j REJECT`(仅白名单模式)作为兜底 + +### 命令行(API) + +```bash +# 创建白名单实例 +curl -X POST http://localhost:8089/api/instances \ + -H "Authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' \ + -d '{ + "name": "internal", + "port": 1194, + "subnet": "10.8.0.0/24", + "access_mode": "whitelist", + "allow_networks": ["192.168.1.0/24", "10.0.0.0/8"] + }' + +# 创建用户,额外允许一个网段 +curl -X POST http://localhost:8089/api/instances/$IID/users \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"username":"alice","allow_networks":["172.16.0.0/16"]}' +``` + +## 验证白名单生效 + +实例启动后,登录客户端: + +```bash +# 1. 客户端连上 VPN,确认 VPN IP 拿到 +ip addr show | grep 10.8.0 + +# 2. 测允许的网段 - 应通 +ping 192.168.1.1 + +# 3. 测未允许的网段 - 应 REJECT(ICMP net unreachable) +ping 192.168.50.1 # 不在白名单,应该不通 +``` + +服务端验证 iptables 规则: + +```bash +iptables -L FORWARD -n --line-numbers +# 应看到 -i tun0 -j REJECT 在底部,前面若干 -s -d -j ACCEPT +``` + +## 限制与注意 + +| 项 | 说明 | +| --- | ---- | +| 仅 IPv4 | IPv6 白名单需要扩展 client-connect.sh 使用 `route-ipv6`,目前未实现 | +| 需要 root + iptables | 若运行在容器内/无 root,白名单模式会以"open"模式退化运行 | +| 端口转发必须由客户端发起 | 客户端连入后,服务端只允许它主动访问白名单中的目标;不能用 VPN 当跳板从外部进入内网 | +| 修改 allow_networks 后 | 已有用户需重新连接一次才能拿到新的 push route;新用户即时生效 | +| 删除/吊销用户 | 会清掉 iptables 中对应的 ACCEPT 规则,不影响其他用户 | + +## 常见误用 + +| 错误 | 后果 | +| ---- | ---- | +| 客户端关掉 VPN 网关 | 服务端 iptables 仍会拒绝非白名单流量,客户端访问不到 | +| 客户端把 allowed 网段路由改成另一条 | 服务端 iptables 在 FORWARD 链过滤,客户端改路由无效 | +| 忘记添加 DNS 服务器 | 客户端没法解析域名 —— 在"允许网段"加上 DNS 服务器的 IP(如 `8.8.8.8/32`) | + +--- + # 三、客户端使用 ## 配置文件说明 @@ -517,7 +623,7 @@ sudo dnf install -y NetworkManager-openvpn NetworkManager-openvpn-gnome # F --- -# 四、API 参考 +# 五、API 参考 所有 `/api` 路径(除 `/login`、`/health`)都需要 `Authorization: Bearer `。 @@ -559,7 +665,7 @@ curl -X POST http://localhost:8089/api/instances \ --- -# 五、常见问题 +# 六、常见问题 **Q: 服务启动后访问 8089 提示"无法连接"?** @@ -621,7 +727,7 @@ server { --- -# 六、开发 +# 七、开发 ```bash # 后端 diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 6480db6..c174961 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -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 { diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 346be09..eba6a4e 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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 操作审计日志 diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 8e253a6..067f3b2 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -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/,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 diff --git a/backend/pkg/openvpn/manager.go b/backend/pkg/openvpn/manager.go index b48807d..7942d91 100644 --- a/backend/pkg/openvpn/manager.go +++ b/backend/pkg/openvpn/manager.go @@ -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/(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= +// - 作用:读 ccd/,根据其白名单在 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/ 读取 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) diff --git a/docs/API.md b/docs/API.md index d926d4e..fee7815 100644 --- a/docs/API.md +++ b/docs/API.md @@ -109,11 +109,18 @@ POST /api/instances "auth_digest": "SHA256", // 可选 "push_dns": "dhcp-option DNS 8.8.8.8\ndhcp-option DNS 1.1.1.1", "push_routes": "192.168.1.0 255.255.255.0", - "extra": "" + "extra": "", + "access_mode": "whitelist", // open(默认) | whitelist + "allow_networks": ["192.168.1.0/24", "10.0.0.0/8"] // CIDR 列表 } → 200 Instance ``` +> `access_mode=whitelist` 时,服务端会生成 `client-connect.sh` / `client-disconnect.sh`, +> 通过 OpenVPN `--learn-address` 钩子在每次客户端连接/断开时修改 iptables FORWARD 链, +> 仅放行到 allow_networks 中网段的流量,其他内网访问被 REJECT。 +> allow_networks 为空表示完全隔离(最严格)。 + ### 更新 ``` PUT /api/instances/:id @@ -121,6 +128,8 @@ PUT /api/instances/:id → 200 Instance ``` +> 修改实例级 `allow_networks` 后,所有用户的 ccd 文件会被自动重写。 + ### 删除(级联删除该实例下所有用户/证书) ``` DELETE /api/instances/:id @@ -171,11 +180,22 @@ POST /api/instances/:id/users "username": "alice", "real_name": "Alice", "email": "alice@example.com", - "static_ip": "10.8.0.10" // 可选 + "static_ip": "10.8.0.10", // 可选 + "allow_networks": ["172.16.0.0/16"] // 可选,用户级白名单(在实例基础上叠加) } → 200 VPNUser ``` +### 修改(改 allow_networks / static_ip 等) +``` +PUT /api/instances/:id/users/:uid +{...同上} +→ 200 VPNUser +``` + +> 修改用户的 `allow_networks` 后,会重新生成 ccd 文件, +> 用户重新连接即可拿到新的 push route。 + ### 吊销 ``` POST /api/instances/:id/users/:uid/revoke @@ -311,6 +331,8 @@ GET /api/audits "push_dns": "...", "push_routes": "...", "extra": "", + "access_mode": "open | whitelist", + "allow_networks": ["192.168.1.0/24", "10.0.0.0/8"], "status": "running | stopped | error", "pid": 12345, "created_at": "...", @@ -328,6 +350,7 @@ GET /api/audits "email": "alice@example.com", "enabled": true, "static_ip": "10.8.0.10", + "allow_networks": ["172.16.0.0/16"], "created_at": "...", "revoked_at": null } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 06c6a16..1b60177 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -44,6 +44,7 @@ export const Inst = { online: id => api.get(`/instances/${id}/online`).then(r => r.data), listUsers: id => api.get(`/instances/${id}/users`).then(r => r.data), createUser: (id, data) => api.post(`/instances/${id}/users`, data).then(r => r.data), + updateUser: (id, uid, data) => api.put(`/instances/${id}/users/${uid}`, data).then(r => r.data), revokeUser: (id, uid) => api.post(`/instances/${id}/users/${uid}/revoke`).then(r => r.data), deleteUser: (id, uid) => api.delete(`/instances/${id}/users/${uid}`).then(r => r.data), ovpnUrl: (id, uid, host) => `/api/instances/${id}/users/${uid}/ovpn?host=${encodeURIComponent(host||'')}`, diff --git a/frontend/src/components/NetworkList.vue b/frontend/src/components/NetworkList.vue new file mode 100644 index 0000000..89b2373 --- /dev/null +++ b/frontend/src/components/NetworkList.vue @@ -0,0 +1,92 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 1eacfa4..85307a4 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -2,11 +2,34 @@

仪表盘

-
实例数
{{ data.instances || 0 }}
运行中 {{ data.running || 0 }}
+
实例数
{{ data.instances || 0 }}
运行中 {{ data.running || 0 }} · 白名单 {{ data.whitelist_instances || 0 }}
用户数
{{ data.users || 0 }}
启用 {{ data.active_users || 0 }}
当前在线
{{ data.online || 0 }}
来自 status.log
即将到期证书(30天)
{{ data.expiring_certs || 0 }}
需及时续签
+ + +
+

访问控制概览

+ + + + + + + + + +
+
+
diff --git a/frontend/src/views/Instances.vue b/frontend/src/views/Instances.vue index e8ff953..8d9e79c 100644 --- a/frontend/src/views/Instances.vue +++ b/frontend/src/views/Instances.vue @@ -12,6 +12,16 @@ + + +