Files
openvpn-manager/backend/internal/api/router.go
T
cnbugs 4c8b7b5188 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 四
2026-08-09 20:47:21 +08:00

434 lines
10 KiB
Go

package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"openvpn-manager/internal/config"
"openvpn-manager/internal/middleware"
"openvpn-manager/internal/model"
"openvpn-manager/internal/service"
)
type Server struct {
Cfg *config.Config
Svc *service.Service
}
func NewServer(cfg *config.Config, svc *service.Service) *Server {
return &Server{Cfg: cfg, Svc: svc}
}
func (s *Server) Router(distDir string) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 12 * time.Hour,
}))
// 公共
r.POST("/api/login", s.login)
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
auth := r.Group("/api", middleware.JWTAuth(s.Cfg.JWTSecret))
{
auth.GET("/me", s.me)
auth.POST("/logout", s.logout)
auth.GET("/dashboard", s.dashboard)
auth.GET("/instances", s.listInstances)
auth.POST("/instances", s.createInstance)
auth.GET("/instances/:id", s.getInstance)
auth.PUT("/instances/:id", s.updateInstance)
auth.DELETE("/instances/:id", s.deleteInstance)
auth.POST("/instances/:id/start", s.startInstance)
auth.POST("/instances/:id/stop", s.stopInstance)
auth.GET("/instances/:id/online", s.onlineClients)
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)
auth.GET("/certs", s.listCerts)
auth.GET("/connlogs", s.listConnLogs)
auth.GET("/backups", s.listBackups)
auth.POST("/backups", s.createBackup)
auth.POST("/backups/:id/restore", s.restoreBackup)
auth.DELETE("/backups/:id", s.deleteBackup)
auth.GET("/audits", s.listAudits)
}
// 静态前端
if distDir != "" {
if _, err := os.Stat(distDir); err == nil {
r.NoRoute(func(c *gin.Context) {
path := filepath.Join(distDir, c.Request.URL.Path)
if !fileExists(path) || strings.HasSuffix(c.Request.URL.Path, "/") {
c.File(filepath.Join(distDir, "index.html"))
return
}
c.File(path)
})
}
}
return r
}
func fileExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && !fi.IsDir()
}
// ---------- handlers ----------
func (s *Server) login(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if req.Username != s.Cfg.AdminUser || req.Password != s.Cfg.AdminPass {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
tok, err := middleware.IssueToken(s.Cfg.JWTSecret, req.Username, "admin", 12*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
return
}
c.JSON(200, gin.H{"token": tok, "username": req.Username})
}
func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
func (s *Server) me(c *gin.Context) {
u, _ := c.Get("user")
c.JSON(200, gin.H{"username": u})
}
// dashboard 汇总统计
func (s *Server) dashboard(c *gin.Context) {
instances := s.Svc.Store.ListInstances()
users := s.Svc.Store.ListUsers("")
certs, _ := s.Svc.CertInfos()
expiring := 0
for _, ct := range certs {
if ct.DaysLeft <= 30 {
expiring++
}
}
online := 0
for _, in := range instances {
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"),
"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,
})
}
// ---- instances ----
func (s *Server) listInstances(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListInstances())
}
func (s *Server) getInstance(c *gin.Context) {
in, err := s.Svc.Store.GetInstance(c.Param("id"))
if err != nil {
c.JSON(404, gin.H{"error": err.Error()})
return
}
c.JSON(200, in)
}
func (s *Server) createInstance(c *gin.Context) {
var in model.Instance
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
out, err := s.Svc.CreateInstance(in)
if err != nil {
s.Svc.AuditForAPI(c, "create_instance", in.Name, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "create_instance", in.Name, "port="+itoa(out.Port), "ok")
c.JSON(200, out)
}
func (s *Server) updateInstance(c *gin.Context) {
var in model.Instance
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
in.ID = c.Param("id")
if err := s.Svc.UpdateInstance(in); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "update_instance", in.Name, "", "ok")
c.JSON(200, in)
}
func (s *Server) deleteInstance(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.DeleteInstance(id); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "delete_instance", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) startInstance(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.StartInstance(id); err != nil {
s.Svc.AuditForAPI(c, "start_instance", id, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "start_instance", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) stopInstance(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.StopInstance(id); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "stop_instance", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) onlineClients(c *gin.Context) {
cl, err := s.Svc.ListOnline(c.Param("id"))
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, cl)
}
// ---- users ----
func (s *Server) listUsers(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListUsers(c.Param("id")))
}
func (s *Server) createUser(c *gin.Context) {
var u model.VPNUser
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
u.InstanceID = c.Param("id")
out, err := s.Svc.CreateUser(u)
if err != nil {
s.Svc.AuditForAPI(c, "create_user", u.Username, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "create_user", u.Username, "instance="+u.InstanceID, "ok")
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 {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "revoke_user", uid, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) deleteUser(c *gin.Context) {
uid := c.Param("uid")
if err := s.Svc.DeleteUser(uid); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "delete_user", uid, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) downloadOVPN(c *gin.Context) {
host := c.Query("host")
if host == "" {
host = c.Request.Host
}
p, err := s.Svc.GenerateOVPN(c.Param("uid"), host)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(p))
c.File(p)
}
// ---- certs ----
func (s *Server) listCerts(c *gin.Context) {
certs, err := s.Svc.CertInfos()
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, certs)
}
// ---- conn logs ----
func (s *Server) listConnLogs(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListConnLogs(c.Query("instance"), 200))
}
// ---- backups ----
func (s *Server) listBackups(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListBackups())
}
func (s *Server) createBackup(c *gin.Context) {
var req struct{ Note string `json:"note"` }
_ = c.ShouldBindJSON(&req)
b, err := s.Svc.Backup(req.Note)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "create_backup", b.ID, "", "ok")
c.JSON(200, b)
}
func (s *Server) restoreBackup(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.Restore(id); err != nil {
s.Svc.AuditForAPI(c, "restore_backup", id, err.Error(), "failed")
c.JSON(400, gin.H{"error": err.Error()})
return
}
s.Svc.AuditForAPI(c, "restore_backup", id, "", "ok")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) deleteBackup(c *gin.Context) {
id := c.Param("id")
if err := s.Svc.DeleteBackup(id); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ---- audits ----
func (s *Server) listAudits(c *gin.Context) {
c.JSON(200, s.Svc.Store.ListAudits(500))
}
// ---- helpers ----
func countByStatus(in []model.Instance, status string) int {
n := 0
for _, x := range in {
if x.Status == status {
n++
}
}
return n
}
func countEnabled(u []model.VPNUser) int {
n := 0
for _, x := range u {
if x.Enabled {
n++
}
}
return n
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}