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:
cnbugs
2026-08-09 20:47:21 +08:00
parent 77f8b59290
commit 4c8b7b5188
11 changed files with 697 additions and 64 deletions
+113 -7
View File
@@ -26,7 +26,8 @@
- [创建第一个实例](#创建第一个实例) - [创建第一个实例](#创建第一个实例)
- [创建客户端用户并下载配置](#创建客户端用户并下载配置) - [创建客户端用户并下载配置](#创建客户端用户并下载配置)
- [防火墙与公网暴露](#防火墙与公网暴露) - [防火墙与公网暴露](#防火墙与公网暴露)
- [三、客户端使用](#三客户端使用) - [三、访问控制(白名单模式)](#三访问控制白名单模式)
- [四、客户端使用](#四客户端使用)
- [配置文件说明](#配置文件说明) - [配置文件说明](#配置文件说明)
- [Windows](#windows) - [Windows](#windows)
- [macOS](#macos) - [macOS](#macos)
@@ -34,9 +35,9 @@
- [Android](#android) - [Android](#android)
- [iOS](#ios) - [iOS](#ios)
- [验证连接](#验证连接) - [验证连接](#验证连接)
- [、API 参考](#api-参考) - [、API 参考](#api-参考)
- [、常见问题](#常见问题) - [、常见问题](#常见问题)
- [、开发](#开发) - [、开发](#开发)
- [许可证](#许可证) - [许可证](#许可证)
--- ---
@@ -48,6 +49,7 @@
| 多实例管理 | 同一台机器上跑多个 OpenVPN 实例,每个独立端口/协议/子网/PKI | | 多实例管理 | 同一台机器上跑多个 OpenVPN 实例,每个独立端口/协议/子网/PKI |
| 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 | | 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 |
| 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP | | 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP |
| **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 |
| 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 | | 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 |
| 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 | | 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 |
| 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 | | 证书到期提醒 | 仪表盘统计 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 <vpn-ip> -d <allowed-net> -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 <token>` 所有 `/api` 路径(除 `/login``/health`)都需要 `Authorization: Bearer <token>`
@@ -559,7 +665,7 @@ curl -X POST http://localhost:8089/api/instances \
--- ---
# 、常见问题 # 、常见问题
**Q: 服务启动后访问 8089 提示"无法连接"?** **Q: 服务启动后访问 8089 提示"无法连接"?**
@@ -621,7 +727,7 @@ server {
--- ---
# 、开发 # 、开发
```bash ```bash
# 后端 # 后端
+40 -8
View File
@@ -57,6 +57,7 @@ func (s *Server) Router(distDir string) *gin.Engine {
auth.GET("/instances/:id/users", s.listUsers) auth.GET("/instances/:id/users", s.listUsers)
auth.POST("/instances/:id/users", s.createUser) 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.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
auth.DELETE("/instances/:id/users/:uid", s.deleteUser) auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN) 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) cl, _ := s.Svc.ListOnline(in.ID)
online += len(cl) 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{ c.JSON(200, gin.H{
"instances": len(instances), "instances": len(instances),
"running": countByStatus(instances, "running"), "running": countByStatus(instances, "running"),
"users": len(users), "whitelist_instances": whitelistN,
"active_users": countEnabled(users), "users": len(users),
"online": online, "active_users": countEnabled(users),
"expiring_certs": expiring, "online": online,
"recent_audits": s.Svc.Store.ListAudits(20), "expiring_certs": expiring,
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20), "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) 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) { func (s *Server) revokeUser(c *gin.Context) {
uid := c.Param("uid") uid := c.Param("uid")
if err := s.Svc.RevokeUser(uid); err != nil { if err := s.Svc.RevokeUser(uid); err != nil {
+39 -25
View File
@@ -2,40 +2,54 @@ package model
import "time" import "time"
// AccessMode 控制客户端的访问范围。
// - "open": 不限制,客户端可访问所有可达网段(默认)
// - "whitelist":白名单模式,仅允许访问 Instance 与 User 合并后的 allow_networks
// 列表中的网段,其他内网流量在服务端 FORWARD 链被丢弃
type AccessMode string
const (
AccessOpen AccessMode = "open"
AccessWhitelist AccessMode = "whitelist"
)
// Instance 一个 OpenVPN 服务端实例。 // Instance 一个 OpenVPN 服务端实例。
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下, // 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。 // 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
type Instance struct { type Instance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` // 唯一名,作为目录名 Name string `json:"name"` // 唯一名,作为目录名
Port int `json:"port"` // openvpn 监听端口 Port int `json:"port"` // openvpn 监听端口
Proto string `json:"proto"` // udp / tcp Proto string `json:"proto"` // udp / tcp
Dev string `json:"dev"` // tun / tap Dev string `json:"dev"` // tun / tap
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24 Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
Cipher string `json:"cipher"` // 加密算法 Cipher string `json:"cipher"` // 加密算法
AuthDigest string `json:"auth_digest"` // 摘要算法 AuthDigest string `json:"auth_digest"` // 摘要算法
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x" PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
PushRoutes string `json:"push_routes"` // 多行 PushRoutes string `json:"push_routes"` // 多行
Extra string `json:"extra"` // 用户追加配置 Extra string `json:"extra"` // 用户追加配置
Status string `json:"status"` // running/stopped/error AccessMode AccessMode `json:"access_mode"` // open | whitelist
PID int `json:"pid"` AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
CreatedAt time.Time `json:"created_at"` Status string `json:"status"` // running/stopped/error
UpdatedAt time.Time `json:"updated_at"` PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// VPNUser 一个 OpenVPN 客户端用户。 // VPNUser 一个 OpenVPN 客户端用户。
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。 // 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
type VPNUser struct { type VPNUser struct {
ID string `json:"id"` ID string `json:"id"`
InstanceID string `json:"instance_id"` InstanceID string `json:"instance_id"`
Username string `json:"username"` // 证书 CN Username string `json:"username"` // 证书 CN
RealName string `json:"real_name"` // 备注 RealName string `json:"real_name"` // 备注
Email string `json:"email"` Email string `json:"email"`
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用 Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态 StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
CreatedAt time.Time `json:"created_at"` AllowNetworks []string `json:"allow_networks"` // 用户级白名单(在实例基础上叠加)
ExpiresAt *time.Time `json:"expires_at,omitempty"` CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
} }
// AuditLog 操作审计日志 // AuditLog 操作审计日志
+104 -8
View File
@@ -88,6 +88,15 @@ func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
if in.Subnet == "" { if in.Subnet == "" {
in.Subnet = "10.8.0.0/24" 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 { if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
return nil, fmt.Errorf("instance %s already exists", in.Name) 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 { if err := s.Ovm.IssueServerCert(in.Name); err != nil {
return nil, fmt.Errorf("issue server cert: %w", err) return nil, fmt.Errorf("issue server cert: %w", err)
} }
// 写 server.conf // 写 server.conf + 白名单脚本
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd") ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil { if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
return nil, fmt.Errorf("write conf: %w", err) 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 { if err := s.Store.UpsertInstance(in); err != nil {
return nil, err return nil, err
} }
@@ -128,6 +142,26 @@ func (s *Service) UpdateInstance(in model.Instance) error {
if err != nil { if err != nil {
return err 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.CreatedAt = old.CreatedAt
in.UpdatedAt = time.Now() in.UpdatedAt = time.Now()
in.Status = old.Status 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 { if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
return err 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/配置。 // 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 { if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
return nil, fmt.Errorf("user %s already exists", u.Username) 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.ID = uuid.NewString()
u.Enabled = true u.Enabled = true
u.CreatedAt = time.Now() 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 { if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
return nil, err return nil, err
} }
// CCD // CCD: 合并实例级与用户级白名单(白名单模式才生效)
if u.StaticIP != "" { merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n" if err := s.writeUserCCD(in, &u, merged); err != nil {
if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil { return nil, err
return nil, err
}
} }
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载) // 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil { 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) 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 汇总所有用户证书的过期时间。 // CertInfos 汇总所有用户证书的过期时间。
func (s *Service) CertInfos() ([]model.CertInfo, error) { func (s *Service) CertInfos() ([]model.CertInfo, error) {
var out []model.CertInfo var out []model.CertInfo
+186
View File
@@ -127,6 +127,17 @@ func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
if extraDir != "" { if extraDir != "" {
conf.WriteString("client-config-dir " + extraDir + "\n") 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 != "" { if in.Extra != "" {
conf.WriteString("\n# --- custom ---\n") conf.WriteString("\n# --- custom ---\n")
conf.WriteString(in.Extra + "\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) 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 文件。 // DeleteCCD 移除 ccd 文件。
func (m *Manager) DeleteCCD(instanceName, username string) error { func (m *Manager) DeleteCCD(instanceName, username string) error {
p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username) p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username)
+25 -2
View File
@@ -109,11 +109,18 @@ POST /api/instances
"auth_digest": "SHA256", // 可选 "auth_digest": "SHA256", // 可选
"push_dns": "dhcp-option DNS 8.8.8.8\ndhcp-option DNS 1.1.1.1", "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", "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 → 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 PUT /api/instances/:id
@@ -121,6 +128,8 @@ PUT /api/instances/:id
→ 200 Instance → 200 Instance
``` ```
> 修改实例级 `allow_networks` 后,所有用户的 ccd 文件会被自动重写。
### 删除(级联删除该实例下所有用户/证书) ### 删除(级联删除该实例下所有用户/证书)
``` ```
DELETE /api/instances/:id DELETE /api/instances/:id
@@ -171,11 +180,22 @@ POST /api/instances/:id/users
"username": "alice", "username": "alice",
"real_name": "Alice", "real_name": "Alice",
"email": "alice@example.com", "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 → 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 POST /api/instances/:id/users/:uid/revoke
@@ -311,6 +331,8 @@ GET /api/audits
"push_dns": "...", "push_dns": "...",
"push_routes": "...", "push_routes": "...",
"extra": "", "extra": "",
"access_mode": "open | whitelist",
"allow_networks": ["192.168.1.0/24", "10.0.0.0/8"],
"status": "running | stopped | error", "status": "running | stopped | error",
"pid": 12345, "pid": 12345,
"created_at": "...", "created_at": "...",
@@ -328,6 +350,7 @@ GET /api/audits
"email": "alice@example.com", "email": "alice@example.com",
"enabled": true, "enabled": true,
"static_ip": "10.8.0.10", "static_ip": "10.8.0.10",
"allow_networks": ["172.16.0.0/16"],
"created_at": "...", "created_at": "...",
"revoked_at": null "revoked_at": null
} }
+1
View File
@@ -44,6 +44,7 @@ export const Inst = {
online: id => api.get(`/instances/${id}/online`).then(r => r.data), online: id => api.get(`/instances/${id}/online`).then(r => r.data),
listUsers: id => api.get(`/instances/${id}/users`).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), 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), 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), 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||'')}`, ovpnUrl: (id, uid, host) => `/api/instances/${id}/users/${uid}/ovpn?host=${encodeURIComponent(host||'')}`,
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="netlist">
<el-tag
v-for="(item, idx) in list"
:key="idx"
:type="valid(item) ? 'success' : 'danger'"
closable
style="margin: 0 6px 6px 0"
@close="remove(idx)"
>
{{ item }}
</el-tag>
<el-input
v-if="adding"
ref="inputRef"
v-model="draft"
size="small"
placeholder="如 192.168.1.0/24,回车确认"
style="width: 200px"
@keyup.enter="commit"
@blur="commit"
/>
<el-button v-else size="small" type="primary" plain @click="startAdd">
<el-icon><Plus /></el-icon>添加
</el-button>
<div v-if="invalidMsg" class="invalid">{{ invalidMsg }}</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { Plus } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])
const list = ref([...(props.modelValue || [])])
const adding = ref(false)
const draft = ref('')
const invalidMsg = ref('')
const inputRef = ref(null)
function valid(s) {
if (!s) return false
const cidr = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(s.trim())
return cidr
}
function sync() {
emit('update:modelValue', [...list.value])
}
function remove(i) {
list.value.splice(i, 1)
sync()
}
async function startAdd() {
adding.value = true
draft.value = ''
invalidMsg.value = ''
await nextTick()
inputRef.value?.focus?.()
}
function commit() {
const v = draft.value.trim()
if (!v) { adding.value = false; return }
if (!valid(v)) {
invalidMsg.value = `非法 CIDR: ${v} (格式 192.168.1.0/24)`
draft.value = ''
return
}
if (list.value.includes(v)) {
invalidMsg.value = `已存在: ${v}`
draft.value = ''
return
}
list.value.push(v)
sync()
draft.value = ''
invalidMsg.value = ''
adding.value = false
}
</script>
<style scoped>
.netlist { display: flex; flex-wrap: wrap; align-items: center; }
.invalid { color: #f56c6c; font-size: 12px; margin-top: 4px; width: 100%; }
</style>
+24 -1
View File
@@ -2,11 +2,34 @@
<div> <div>
<h2 style="margin-top:0">仪表盘</h2> <h2 style="margin-top:0">仪表盘</h2>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6"><div class="card-stat"><div class="label">实例数</div><div class="num">{{ data.instances || 0 }}</div><div class="muted">运行中 {{ data.running || 0 }}</div></div></el-col> <el-col :span="6"><div class="card-stat"><div class="label">实例数</div><div class="num">{{ data.instances || 0 }}</div><div class="muted">运行中 {{ data.running || 0 }} · 白名单 {{ data.whitelist_instances || 0 }}</div></div></el-col>
<el-col :span="6"><div class="card-stat"><div class="label">用户数</div><div class="num">{{ data.users || 0 }}</div><div class="muted">启用 {{ data.active_users || 0 }}</div></div></el-col> <el-col :span="6"><div class="card-stat"><div class="label">用户数</div><div class="num">{{ data.users || 0 }}</div><div class="muted">启用 {{ data.active_users || 0 }}</div></div></el-col>
<el-col :span="6"><div class="card-stat"><div class="label">当前在线</div><div class="num">{{ data.online || 0 }}</div><div class="muted">来自 status.log</div></div></el-col> <el-col :span="6"><div class="card-stat"><div class="label">当前在线</div><div class="num">{{ data.online || 0 }}</div><div class="muted">来自 status.log</div></div></el-col>
<el-col :span="6"><div class="card-stat"><div class="label">即将到期证书(30)</div><div class="num" :style="{color:(data.expiring_certs>0)?'#f56c6c':'#1f2937'}">{{ data.expiring_certs || 0 }}</div><div class="muted">需及时续签</div></div></el-col> <el-col :span="6"><div class="card-stat"><div class="label">即将到期证书(30)</div><div class="num" :style="{color:(data.expiring_certs>0)?'#f56c6c':'#1f2937'}">{{ data.expiring_certs || 0 }}</div><div class="muted">需及时续签</div></div></el-col>
</el-row> </el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="12">
<div class="chart-card">
<h4 style="margin:0 0 8px">访问控制概览</h4>
<el-table :data="data.access_overview || []" size="small" max-height="240">
<el-table-column prop="name" label="实例" />
<el-table-column label="模式" width="100">
<template #default="{row}">
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
{{ row.access_mode==='whitelist' ? '白名单' : '开放' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="allow_networks" label="允许网段" min-width="240">
<template #default="{row}">
<span v-if="!row.allow_networks || !row.allow_networks.length" class="muted">(全拒绝)</span>
<el-tag v-for="n in row.allow_networks" :key="n" size="small" style="margin:0 4px 4px 0">{{ n }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px"> <el-row :gutter="16" style="margin-top:16px">
<el-col :span="12"> <el-col :span="12">
+32 -2
View File
@@ -12,6 +12,16 @@
<el-table-column prop="proto" label="协议" width="80" /> <el-table-column prop="proto" label="协议" width="80" />
<el-table-column prop="dev" label="设备" width="80" /> <el-table-column prop="dev" label="设备" width="80" />
<el-table-column prop="subnet" label="子网" /> <el-table-column prop="subnet" label="子网" />
<el-table-column label="访问控制" width="110">
<template #default="{row}">
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
{{ row.access_mode==='whitelist' ? '白名单' : '开放' }}
</el-tag>
<div class="muted" v-if="row.allow_networks && row.allow_networks.length">
{{ row.allow_networks.length }} 个网段
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="100">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="row.status==='running'?'success':'info'" size="small">{{ row.status }}</el-tag> <el-tag :type="row.status==='running'?'success':'info'" size="small">{{ row.status }}</el-tag>
@@ -51,6 +61,25 @@
</el-form-item> </el-form-item>
<el-form-item label="推送 DNS"><el-input v-model="form.push_dns" type="textarea" :rows="3" placeholder="每行一条,例如 dhcp-option DNS 8.8.8.8"/></el-form-item> <el-form-item label="推送 DNS"><el-input v-model="form.push_dns" type="textarea" :rows="3" placeholder="每行一条,例如 dhcp-option DNS 8.8.8.8"/></el-form-item>
<el-form-item label="推送路由"><el-input v-model="form.push_routes" type="textarea" :rows="3" placeholder="每行一条 CIDR"/></el-form-item> <el-form-item label="推送路由"><el-input v-model="form.push_routes" type="textarea" :rows="3" placeholder="每行一条 CIDR"/></el-form-item>
<el-form-item label="访问控制">
<el-select v-model="form.access_mode" style="width:200px">
<el-option label="开放(open)" value="open"/>
<el-option label="白名单(whitelist)" value="whitelist"/>
</el-select>
<div class="muted" style="margin-top:4px">
<span v-if="form.access_mode==='whitelist'">
客户端只能访问下方列出的内网网段;其他流量在服务端被丢弃需要 root + iptables
</span>
<span v-else>不限制,客户端可访问所有可达网段</span>
</div>
</el-form-item>
<el-form-item label="允许网段">
<NetworkList v-model="form.allow_networks" />
<div class="muted" style="margin-top:4px">
每行一个 CIDR( <code>192.168.1.0/24</code>),仅在"白名单"模式下生效
留空则所有内网都被拒绝(最严格)
</div>
</el-form-item>
<el-form-item label="自定义"><el-input v-model="form.extra" type="textarea" :rows="3" placeholder="追加到 server.conf 末尾"/></el-form-item> <el-form-item label="自定义"><el-input v-model="form.extra" type="textarea" :rows="3" placeholder="追加到 server.conf 末尾"/></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -65,6 +94,7 @@ import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Inst } from '@/api' import { Inst } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import NetworkList from '@/components/NetworkList.vue'
const list = ref([]) const list = ref([])
const dlg = ref(false) const dlg = ref(false)
@@ -73,11 +103,11 @@ const router = useRouter()
async function load() { list.value = await Inst.list() } async function load() { list.value = await Inst.list() }
function openCreate() { function openCreate() {
Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', id:'' }) Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', access_mode:'open', allow_networks:[], id:'' })
dlg.value = true dlg.value = true
} }
function openEdit(row) { function openEdit(row) {
Object.assign(form, row) Object.assign(form, row, { allow_networks: row.allow_networks || [] })
dlg.value = true dlg.value = true
} }
async function submit() { async function submit() {
+41 -11
View File
@@ -10,7 +10,7 @@
<el-col :span="6"><el-input v-model="host" placeholder="客户端连接的远端域名/IP" /></el-col> <el-col :span="6"><el-input v-model="host" placeholder="客户端连接的远端域名/IP" /></el-col>
<el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col> <el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col>
<el-col :span="6" style="text-align:right"> <el-col :span="6" style="text-align:right">
<el-button type="primary" :disabled="!instanceId" @click="dlg=true"><el-icon><Plus /></el-icon>新建用户</el-button> <el-button type="primary" :disabled="!instanceId" @click="openCreate"><el-icon><Plus /></el-icon>新建用户</el-button>
</el-col> </el-col>
</el-row> </el-row>
@@ -19,6 +19,12 @@
<el-table-column prop="real_name" label="备注" /> <el-table-column prop="real_name" label="备注" />
<el-table-column prop="email" label="邮箱" /> <el-table-column prop="email" label="邮箱" />
<el-table-column prop="static_ip" label="固定 IP" width="140" /> <el-table-column prop="static_ip" label="固定 IP" width="140" />
<el-table-column label="允许网段" width="180">
<template #default="{row}">
<span v-if="!row.allow_networks || !row.allow_networks.length" class="muted">继承实例</span>
<el-tag v-for="n in row.allow_networks" :key="n" size="small" style="margin:0 4px 4px 0">{{ n }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="enabled" label="状态" width="100"> <el-table-column prop="enabled" label="状态" width="100">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="row.enabled?'success':'danger'" size="small">{{ row.enabled?'启用':'已吊销' }}</el-tag> <el-tag :type="row.enabled?'success':'danger'" size="small">{{ row.enabled?'启用':'已吊销' }}</el-tag>
@@ -27,9 +33,10 @@
<el-table-column prop="created_at" label="创建时间" width="170"> <el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template> <template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="260"> <el-table-column label="操作" width="300">
<template #default="{row}"> <template #default="{row}">
<el-button size="small" @click="download(row)">下载 .ovpn</el-button> <el-button size="small" @click="download(row)">下载 .ovpn</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.enabled" size="small" type="warning" @click="revoke(row)">吊销</el-button> <el-button v-if="row.enabled" size="small" type="warning" @click="revoke(row)">吊销</el-button>
<el-popconfirm title="确定删除此用户及其证书?" @confirm="del(row)"> <el-popconfirm title="确定删除此用户及其证书?" @confirm="del(row)">
<template #reference><el-button size="small" type="danger">删除</el-button></template> <template #reference><el-button size="small" type="danger">删除</el-button></template>
@@ -38,16 +45,24 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-dialog v-model="dlg" title="新建用户" width="500px"> <el-dialog v-model="dlg" :title="form.id?'编辑用户':'新建用户'" width="560px">
<el-form :model="form" label-width="100px"> <el-form :model="form" label-width="100px">
<el-form-item label="用户名(CN)"><el-input v-model="form.username" placeholder="字母数字下划线"/></el-form-item> <el-form-item label="用户名(CN)">
<el-input v-model="form.username" :disabled="!!form.id" placeholder="字母数字下划线"/>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.real_name" /></el-form-item> <el-form-item label="备注"><el-input v-model="form.real_name" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item> <el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="固定 IP"><el-input v-model="form.static_ip" placeholder="可选, 如 10.8.0.10" /></el-form-item> <el-form-item label="固定 IP"><el-input v-model="form.static_ip" placeholder="可选, 如 10.8.0.10" /></el-form-item>
<el-form-item label="额外允许网段">
<NetworkList v-model="form.allow_networks" />
<div class="muted" style="margin-top:4px">
这些网段会在实例级基础上<b>额外</b>开放给该用户留空则只继承实例级
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dlg=false">取消</el-button> <el-button @click="dlg=false">取消</el-button>
<el-button type="primary" @click="submit">生成并签发</el-button> <el-button type="primary" @click="submit">{{ form.id ? '保存' : '生成并签发' }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -57,6 +72,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Inst } from '@/api' import { Inst } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import NetworkList from '@/components/NetworkList.vue'
const route = useRoute() const route = useRoute()
const instances = ref([]) const instances = ref([])
@@ -64,7 +80,7 @@ const users = ref([])
const instanceId = ref('') const instanceId = ref('')
const host = ref(window.location.hostname) const host = ref(window.location.hostname)
const dlg = ref(false) const dlg = ref(false)
const form = reactive({ username:'', real_name:'', email:'', static_ip:'' }) const form = reactive({ id:'', username:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
async function loadInstances() { async function loadInstances() {
instances.value = await Inst.list() instances.value = await Inst.list()
@@ -79,13 +95,27 @@ async function loadUsers() {
} }
watch(() => route.query.instance, v => { if (v) { instanceId.value = v; loadUsers() } }) watch(() => route.query.instance, v => { if (v) { instanceId.value = v; loadUsers() } })
function openCreate() {
Object.assign(form, { id:'', username:'', real_name:'', email:'', static_ip:'', allow_networks:[] })
dlg.value = true
}
function openEdit(row) {
Object.assign(form, row, { allow_networks: row.allow_networks || [] })
dlg.value = true
}
async function submit() { async function submit() {
if (!form.username) return ElMessage.warning('请输入用户名') if (!form.username) return ElMessage.warning('请输入用户名')
await Inst.createUser(instanceId.value, { ...form }) try {
ElMessage.success('已创建并签发证书') if (form.id) {
dlg.value = false await Inst.updateUser(instanceId.value, form.id, form)
Object.assign(form, { username:'', real_name:'', email:'', static_ip:'' }) ElMessage.success('已保存')
loadUsers() } else {
await Inst.createUser(instanceId.value, { ...form })
ElMessage.success('已创建并签发证书')
}
dlg.value = false
loadUsers()
} catch(e) {}
} }
async function revoke(row) { async function revoke(row) {
await Inst.revokeUser(instanceId.value, row.id) await Inst.revokeUser(instanceId.value, row.id)