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
+40 -8
View File
@@ -57,6 +57,7 @@ func (s *Server) Router(distDir string) *gin.Engine {
auth.GET("/instances/:id/users", s.listUsers)
auth.POST("/instances/:id/users", s.createUser)
auth.PUT("/instances/:id/users/:uid", s.updateUser)
auth.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN)
@@ -140,15 +141,29 @@ func (s *Server) dashboard(c *gin.Context) {
cl, _ := s.Svc.ListOnline(in.ID)
online += len(cl)
}
whitelistN := 0
overview := []map[string]any{}
for _, in := range instances {
if in.AccessMode == model.AccessWhitelist {
whitelistN++
}
overview = append(overview, map[string]any{
"name": in.Name,
"access_mode": in.AccessMode,
"allow_networks": in.AllowNetworks,
})
}
c.JSON(200, gin.H{
"instances": len(instances),
"running": countByStatus(instances, "running"),
"users": len(users),
"active_users": countEnabled(users),
"online": online,
"expiring_certs": expiring,
"recent_audits": s.Svc.Store.ListAudits(20),
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
"instances": len(instances),
"running": countByStatus(instances, "running"),
"whitelist_instances": whitelistN,
"users": len(users),
"active_users": countEnabled(users),
"online": online,
"expiring_certs": expiring,
"recent_audits": s.Svc.Store.ListAudits(20),
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
"access_overview": overview,
})
}
@@ -261,6 +276,23 @@ func (s *Server) createUser(c *gin.Context) {
c.JSON(200, out)
}
func (s *Server) updateUser(c *gin.Context) {
var u model.VPNUser
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
u.ID = c.Param("uid")
u.InstanceID = c.Param("id")
if err := s.Svc.UpdateUser(u); err != nil {
s.Svc.AuditForAPI(c, "update_user", u.Username, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "update_user", u.Username, "", "ok")
c.JSON(200, u)
}
func (s *Server) revokeUser(c *gin.Context) {
uid := c.Param("uid")
if err := s.Svc.RevokeUser(uid); err != nil {
+39 -25
View File
@@ -2,40 +2,54 @@ package model
import "time"
// AccessMode 控制客户端的访问范围。
// - "open": 不限制,客户端可访问所有可达网段(默认)
// - "whitelist":白名单模式,仅允许访问 Instance 与 User 合并后的 allow_networks
// 列表中的网段,其他内网流量在服务端 FORWARD 链被丢弃
type AccessMode string
const (
AccessOpen AccessMode = "open"
AccessWhitelist AccessMode = "whitelist"
)
// Instance 一个 OpenVPN 服务端实例。
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
type Instance struct {
ID string `json:"id"`
Name string `json:"name"` // 唯一名,作为目录名
Port int `json:"port"` // openvpn 监听端口
Proto string `json:"proto"` // udp / tcp
Dev string `json:"dev"` // tun / tap
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
Cipher string `json:"cipher"` // 加密算法
AuthDigest string `json:"auth_digest"` // 摘要算法
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
PushRoutes string `json:"push_routes"` // 多行
Extra string `json:"extra"` // 用户追加配置
Status string `json:"status"` // running/stopped/error
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"` // 唯一名,作为目录名
Port int `json:"port"` // openvpn 监听端口
Proto string `json:"proto"` // udp / tcp
Dev string `json:"dev"` // tun / tap
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
Cipher string `json:"cipher"` // 加密算法
AuthDigest string `json:"auth_digest"` // 摘要算法
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
PushRoutes string `json:"push_routes"` // 多行
Extra string `json:"extra"` // 用户追加配置
AccessMode AccessMode `json:"access_mode"` // open | whitelist
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
Status string `json:"status"` // running/stopped/error
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// VPNUser 一个 OpenVPN 客户端用户。
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
type VPNUser struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Username string `json:"username"` // 证书 CN
RealName string `json:"real_name"` // 备注
Email string `json:"email"`
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Username string `json:"username"` // 证书 CN
RealName string `json:"real_name"` // 备注
Email string `json:"email"`
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
AllowNetworks []string `json:"allow_networks"` // 用户级白名单(在实例基础上叠加)
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// AuditLog 操作审计日志
+104 -8
View File
@@ -88,6 +88,15 @@ func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
if in.Subnet == "" {
in.Subnet = "10.8.0.0/24"
}
if in.AccessMode == "" {
in.AccessMode = model.AccessOpen
}
// 校验白名单 CIDR
for _, c := range in.AllowNetworks {
if err := openvpn.ValidateCIDR(c); err != nil {
return nil, fmt.Errorf("instance allow_networks: %w", err)
}
}
// 名称查重
if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
return nil, fmt.Errorf("instance %s already exists", in.Name)
@@ -111,11 +120,16 @@ func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
if err := s.Ovm.IssueServerCert(in.Name); err != nil {
return nil, fmt.Errorf("issue server cert: %w", err)
}
// 写 server.conf
// 写 server.conf + 白名单脚本
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
return nil, fmt.Errorf("write conf: %w", err)
}
if in.AccessMode == model.AccessWhitelist {
if _, err := s.Ovm.AllowNetworksScript(in.Name, in.AllowNetworks); err != nil {
return nil, fmt.Errorf("gen whitelist scripts: %w", err)
}
}
if err := s.Store.UpsertInstance(in); err != nil {
return nil, err
}
@@ -128,6 +142,26 @@ func (s *Service) UpdateInstance(in model.Instance) error {
if err != nil {
return err
}
if in.AccessMode == "" {
in.AccessMode = old.AccessMode
}
if in.Cipher == "" {
in.Cipher = old.Cipher
}
if in.AuthDigest == "" {
in.AuthDigest = old.AuthDigest
}
if in.Dev == "" {
in.Dev = old.Dev
}
if in.Subnet == "" {
in.Subnet = old.Subnet
}
for _, c := range in.AllowNetworks {
if err := openvpn.ValidateCIDR(c); err != nil {
return fmt.Errorf("instance allow_networks: %w", err)
}
}
in.CreatedAt = old.CreatedAt
in.UpdatedAt = time.Now()
in.Status = old.Status
@@ -136,7 +170,24 @@ func (s *Service) UpdateInstance(in model.Instance) error {
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
return err
}
return s.Store.UpsertInstance(in)
if in.AccessMode == model.AccessWhitelist {
if _, err := s.Ovm.AllowNetworksScript(in.Name, in.AllowNetworks); err != nil {
return err
}
}
if err := s.Store.UpsertInstance(in); err != nil {
return err
}
// 实例级 allow_networks 变更后,刷新所有用户的 ccd
inst := in
for _, u := range s.Store.ListUsers(inst.ID) {
u := u
merged := openvpn.MergeAllowNetworks(inst.AllowNetworks, u.AllowNetworks)
if err := s.writeUserCCD(&inst, &u, merged); err != nil {
return err
}
}
return nil
}
// DeleteInstance 移除实例及其 PKI/配置。
@@ -238,6 +289,12 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
return nil, fmt.Errorf("user %s already exists", u.Username)
}
// 校验用户级 CIDR
for _, c := range u.AllowNetworks {
if err := openvpn.ValidateCIDR(c); err != nil {
return nil, fmt.Errorf("user allow_networks: %w", err)
}
}
u.ID = uuid.NewString()
u.Enabled = true
u.CreatedAt = time.Now()
@@ -245,12 +302,10 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
return nil, err
}
// CCD
if u.StaticIP != "" {
ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n"
if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil {
return nil, err
}
// CCD: 合并实例级与用户级白名单(白名单模式才生效)
merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
if err := s.writeUserCCD(in, &u, merged); err != nil {
return nil, err
}
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil {
@@ -312,6 +367,47 @@ func (s *Service) DeleteUser(userID string) error {
return s.Store.DeleteUser(userID)
}
// UpdateUser 修改用户(主要用于改 allow_networks / static_ip 等)。
func (s *Service) UpdateUser(u model.VPNUser) error {
old, err := s.Store.GetUser(u.ID)
if err != nil {
return err
}
for _, c := range u.AllowNetworks {
if err := openvpn.ValidateCIDR(c); err != nil {
return fmt.Errorf("user allow_networks: %w", err)
}
}
u.CreatedAt = old.CreatedAt
u.Enabled = old.Enabled
u.RevokedAt = old.RevokedAt
in, err := s.Store.GetInstance(u.InstanceID)
if err != nil {
return err
}
merged := openvpn.MergeAllowNetworks(in.AllowNetworks, u.AllowNetworks)
if err := s.writeUserCCD(in, &u, merged); err != nil {
return err
}
return s.Store.UpsertUser(u)
}
// writeUserCCD 把允许网段写进 ccd/<cn>,client-connect 脚本读取后推送 route。
// 文件内容:
// - 第 1 行 ifconfig-push (固定 IP)
// - 之后每行一个 CIDR (允许的网段)
// 这样设计既兼容现有 static_ip 场景,又能让 client-connect.sh 简单 grep。
func (s *Service) writeUserCCD(in *model.Instance, u *model.VPNUser, allowNets []string) error {
var b strings.Builder
if u.StaticIP != "" {
b.WriteString("ifconfig-push " + u.StaticIP + " 255.255.255.0\n")
}
for _, c := range allowNets {
b.WriteString(c + "\n")
}
return s.Ovm.WriteCCD(in.Name, u.Username, b.String())
}
// CertInfos 汇总所有用户证书的过期时间。
func (s *Service) CertInfos() ([]model.CertInfo, error) {
var out []model.CertInfo