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:
@@ -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
|
||||||
# 后端
|
# 后端
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
"whitelist_instances": whitelistN,
|
||||||
"users": len(users),
|
"users": len(users),
|
||||||
"active_users": countEnabled(users),
|
"active_users": countEnabled(users),
|
||||||
"online": online,
|
"online": online,
|
||||||
"expiring_certs": expiring,
|
"expiring_certs": expiring,
|
||||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 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 {
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ 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 接口与之通信。
|
||||||
@@ -17,6 +28,8 @@ type Instance struct {
|
|||||||
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"` // 用户追加配置
|
||||||
|
AccessMode AccessMode `json:"access_mode"` // open | whitelist
|
||||||
|
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
|
||||||
Status string `json:"status"` // running/stopped/error
|
Status string `json:"status"` // running/stopped/error
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -33,6 +46,7 @@ type VPNUser struct {
|
|||||||
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 固定地址, 空表示动态
|
||||||
|
AllowNetworks []string `json:"allow_networks"` // 用户级白名单(在实例基础上叠加)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||||
|
|||||||
@@ -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,13 +302,11 @@ 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 {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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||'')}`,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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('请输入用户名')
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
await Inst.updateUser(instanceId.value, form.id, form)
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} else {
|
||||||
await Inst.createUser(instanceId.value, { ...form })
|
await Inst.createUser(instanceId.value, { ...form })
|
||||||
ElMessage.success('已创建并签发证书')
|
ElMessage.success('已创建并签发证书')
|
||||||
|
}
|
||||||
dlg.value = false
|
dlg.value = false
|
||||||
Object.assign(form, { username:'', real_name:'', email:'', static_ip:'' })
|
|
||||||
loadUsers()
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user