Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,921 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wormhole/wormhole/internal/auth"
|
||||
"github.com/wormhole/wormhole/internal/bridge"
|
||||
"github.com/wormhole/wormhole/internal/cache"
|
||||
"github.com/wormhole/wormhole/internal/config"
|
||||
"github.com/wormhole/wormhole/internal/metrics"
|
||||
"github.com/wormhole/wormhole/internal/proxy"
|
||||
"github.com/wormhole/wormhole/internal/security"
|
||||
"github.com/wormhole/wormhole/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
cache *cache.Manager
|
||||
auth *auth.Service
|
||||
resAuth *auth.ResourceAuth
|
||||
security *security.Service
|
||||
bridge *bridge.Bridge
|
||||
proxy *proxy.Manager
|
||||
metrics *metrics.Collector
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
cfg *config.Config,
|
||||
st *store.Store,
|
||||
c *cache.Manager,
|
||||
authSvc *auth.Service,
|
||||
resAuth *auth.ResourceAuth,
|
||||
sec *security.Service,
|
||||
b *bridge.Bridge,
|
||||
pm *proxy.Manager,
|
||||
mc *metrics.Collector,
|
||||
) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
cache: c,
|
||||
auth: authSvc,
|
||||
resAuth: resAuth,
|
||||
security: sec,
|
||||
bridge: b,
|
||||
proxy: pm,
|
||||
metrics: mc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Router() *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), gin.Logger())
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/openapi.json", s.openapiDoc)
|
||||
api.POST("/auth/login", s.login)
|
||||
|
||||
authGroup := api.Group("")
|
||||
authGroup.Use(s.authMiddleware())
|
||||
{
|
||||
authGroup.GET("/auth/me", s.me)
|
||||
authGroup.PUT("/auth/me/password", s.changeMyPassword)
|
||||
authGroup.GET("/dashboard", s.dashboard)
|
||||
authGroup.GET("/dashboard/rankings", s.dashboardRankings)
|
||||
|
||||
authGroup.GET("/api-keys", s.listAPIKeys)
|
||||
authGroup.POST("/api-keys", s.createAPIKey)
|
||||
authGroup.DELETE("/api-keys/:id", s.deleteAPIKey)
|
||||
|
||||
authGroup.GET("/clients", s.listClients)
|
||||
authGroup.POST("/clients", s.createClient)
|
||||
authGroup.PUT("/clients/:id", s.updateClient)
|
||||
authGroup.DELETE("/clients/:id", s.deleteClient)
|
||||
|
||||
authGroup.GET("/tunnels", s.listTunnels)
|
||||
authGroup.POST("/tunnels", s.createTunnel)
|
||||
authGroup.PUT("/tunnels/:id", s.updateTunnel)
|
||||
authGroup.DELETE("/tunnels/:id", s.deleteTunnel)
|
||||
authGroup.POST("/tunnels/:id/start", s.startTunnel)
|
||||
authGroup.POST("/tunnels/:id/stop", s.stopTunnel)
|
||||
authGroup.POST("/tunnels/:id/pause", s.pauseTunnel)
|
||||
authGroup.POST("/tunnels/:id/resume", s.resumeTunnel)
|
||||
authGroup.GET("/tunnels/export", s.exportTunnels)
|
||||
authGroup.POST("/tunnels/import", s.importTunnels)
|
||||
|
||||
authGroup.GET("/hosts", s.listHosts)
|
||||
authGroup.POST("/hosts", s.createHost)
|
||||
authGroup.PUT("/hosts/:id", s.updateHost)
|
||||
authGroup.DELETE("/hosts/:id", s.deleteHost)
|
||||
authGroup.POST("/hosts/:id/pause", s.pauseHost)
|
||||
authGroup.POST("/hosts/:id/resume", s.resumeHost)
|
||||
authGroup.GET("/hosts/export", s.exportHosts)
|
||||
authGroup.POST("/hosts/import", s.importHosts)
|
||||
|
||||
authGroup.GET("/security/ip-rules", s.listIPRules)
|
||||
authGroup.POST("/security/ip-rules", s.createIPRule)
|
||||
authGroup.DELETE("/security/ip-rules/:id", s.deleteIPRule)
|
||||
authGroup.GET("/security/request-ips", s.listRequestIPs)
|
||||
authGroup.POST("/security/block-ip", s.blockIP)
|
||||
|
||||
authGroup.GET("/auth-policies", s.listAuthPolicies)
|
||||
authGroup.POST("/auth-policies", s.createAuthPolicy)
|
||||
authGroup.DELETE("/auth-policies/:id", s.deleteAuthPolicy)
|
||||
|
||||
authGroup.GET("/settings", s.getSettings)
|
||||
authGroup.PUT("/settings", s.updateSettings)
|
||||
|
||||
authGroup.GET("/users", s.listUsers)
|
||||
authGroup.GET("/users/:id/access-logs", s.listVisitorAccessLogs)
|
||||
authGroup.POST("/users", s.createUser)
|
||||
authGroup.PUT("/users/:id", s.updateUser)
|
||||
authGroup.DELETE("/users/:id", s.deleteUser)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func getClaims(c *gin.Context) *auth.Claims {
|
||||
v, _ := c.Get("claims")
|
||||
claims, _ := v.(*auth.Claims)
|
||||
return claims
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
ip := auth.DirectRemoteIP(c.Request)
|
||||
token, user, err := s.auth.Login(s.store, ip, req.Username, req.Password)
|
||||
if err != nil {
|
||||
var blocked *auth.LoginBlockedError
|
||||
if errors.As(err, &blocked) {
|
||||
secs := int(blocked.RetryAfter.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
c.Header("Retry-After", strconv.Itoa(secs))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "登录尝试次数过多,请稍后再试",
|
||||
"retry_after": secs,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) me(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) changeMyPassword(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
if claims.Role == "visitor" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "访客账号无法修改管理密码"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入当前密码和新密码"})
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码至少 4 位"})
|
||||
return
|
||||
}
|
||||
user, err := s.store.GetUserByID(claims.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if !store.CheckPassword(user.PasswordHash, req.CurrentPassword) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "当前密码不正确"})
|
||||
return
|
||||
}
|
||||
hash, err := store.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = hash
|
||||
if err := s.store.UpdateUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(c *gin.Context) {
|
||||
data, err := s.metrics.Dashboard()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tops, ok := data["top_ips"].([]store.RequestIP); ok {
|
||||
data["top_ips"] = requestIPViews(s.store, tops)
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (s *Server) listClients(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
clients, err := s.store.ListClients(claims.UserID, auth.IsAdmin(claims.Role))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type clientView struct {
|
||||
store.Client
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
out := make([]clientView, 0, len(clients))
|
||||
for _, cl := range clients {
|
||||
out = append(out, clientView{
|
||||
Client: cl,
|
||||
Online: s.bridge.IsOnline(cl.ID),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) createClient(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
var req store.Client
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
req.OwnerUserID = claims.UserID
|
||||
if auth.IsAdmin(claims.Role) && req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = claims.UserID
|
||||
}
|
||||
if err := s.store.CreateClient(&req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cache.UpsertClient(&req)
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (s *Server) updateClient(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
if err := store.ClientOwnerCheck(s.store, uint(id), claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req store.Client
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
existing, err := s.store.GetClientByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
req.ID = existing.ID
|
||||
req.VerifyKey = existing.VerifyKey
|
||||
req.OwnerUserID = existing.OwnerUserID
|
||||
if err := s.store.UpdateClient(&req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cache.UpsertClient(&req)
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (s *Server) deleteClient(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
if err := store.ClientOwnerCheck(s.store, uint(id), claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteClient(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cache.DeleteClient(uint(id))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listTunnels(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
|
||||
p := pageParams(c)
|
||||
tunnels, total, err := s.store.ListTunnelsPaged(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role), p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]tunnelView, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
view, err := s.enrichTunnelView(t)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items = append(items, view)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) createTunnel(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
var req tunnelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, req.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = "tcp"
|
||||
}
|
||||
req.ListenIP = "0.0.0.0"
|
||||
if req.VisitorBindMode == "" {
|
||||
req.VisitorBindMode = "all"
|
||||
}
|
||||
if req.VisitorTTLSeconds <= 0 {
|
||||
req.VisitorTTLSeconds = 7200
|
||||
}
|
||||
if req.VisitorAuthEnabled && req.Mode == "udp" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "UDP 隧道不支持访客登录"})
|
||||
return
|
||||
}
|
||||
inUse, err := s.store.TunnelPortInUse(req.ListenPort, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if inUse {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "监听端口已被占用"})
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateTunnel(&req.Tunnel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.saveResourceVisitors("tunnel", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
|
||||
_ = s.store.DeleteTunnel(req.ID)
|
||||
writeVisitorSaveError(c, err)
|
||||
return
|
||||
}
|
||||
s.cache.UpsertTunnel(&req.Tunnel)
|
||||
if req.Status {
|
||||
_ = s.proxy.StartTunnel(req.ID)
|
||||
}
|
||||
view, _ := s.enrichTunnelView(req.Tunnel)
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) updateTunnel(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
existing, err := s.store.GetTunnelByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req tunnelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
req.ID = existing.ID
|
||||
req.ClientID = existing.ClientID
|
||||
req.ListenIP = "0.0.0.0"
|
||||
if req.VisitorBindMode == "" {
|
||||
req.VisitorBindMode = "all"
|
||||
}
|
||||
if req.VisitorTTLSeconds <= 0 {
|
||||
req.VisitorTTLSeconds = 7200
|
||||
}
|
||||
if req.VisitorAuthEnabled && req.Mode == "udp" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "UDP 隧道不支持访客登录"})
|
||||
return
|
||||
}
|
||||
inUse, err := s.store.TunnelPortInUse(req.ListenPort, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if inUse {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "监听端口已被占用"})
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateTunnel(&req.Tunnel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.saveResourceVisitors("tunnel", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
|
||||
writeVisitorSaveError(c, err)
|
||||
return
|
||||
}
|
||||
wasRunning := existing.RunStatus
|
||||
s.cache.UpsertTunnel(&req.Tunnel)
|
||||
if wasRunning || (req.Status && req.VisitorAuthEnabled != existing.VisitorAuthEnabled) {
|
||||
_ = s.proxy.StopTunnel(uint(id))
|
||||
if req.Status {
|
||||
_ = s.proxy.StartTunnel(uint(id))
|
||||
}
|
||||
}
|
||||
view, _ := s.enrichTunnelView(req.Tunnel)
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) deleteTunnel(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
existing, err := s.store.GetTunnelByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.proxy.StopTunnel(uint(id))
|
||||
if err := s.store.DeleteTunnel(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cache.DeleteTunnel(uint(id))
|
||||
s.cache.DeleteResourceVisitors("tunnel", uint(id))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) startTunnel(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
existing, err := s.store.GetTunnelByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.proxy.StartTunnel(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) stopTunnel(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
existing, err := s.store.GetTunnelByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.proxy.StopTunnel(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listHosts(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
|
||||
p := pageParams(c)
|
||||
hosts, total, err := s.store.ListHostsPaged(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role), p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]hostView, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
view, err := s.enrichHostView(h)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items = append(items, view)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) createHost(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
var req hostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, req.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Location == "" {
|
||||
req.Location = "/"
|
||||
}
|
||||
if req.Scheme == "" {
|
||||
req.Scheme = "all"
|
||||
}
|
||||
if req.VisitorBindMode == "" {
|
||||
req.VisitorBindMode = "all"
|
||||
}
|
||||
if req.VisitorTTLSeconds <= 0 {
|
||||
req.VisitorTTLSeconds = 7200
|
||||
}
|
||||
if err := s.store.HostRouteConflict(req.Host.Host, req.Location, 0); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateHost(&req.Host); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.saveResourceVisitors("host", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
|
||||
_ = s.store.DeleteHost(req.ID)
|
||||
writeVisitorSaveError(c, err)
|
||||
return
|
||||
}
|
||||
s.cache.UpsertHost(&req.Host)
|
||||
view, _ := s.enrichHostView(req.Host)
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) updateHost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
existing, err := s.store.GetHostByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req hostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
req.ID = existing.ID
|
||||
req.ClientID = existing.ClientID
|
||||
if req.VisitorBindMode == "" {
|
||||
req.VisitorBindMode = "all"
|
||||
}
|
||||
if req.VisitorTTLSeconds <= 0 {
|
||||
req.VisitorTTLSeconds = 7200
|
||||
}
|
||||
if err := s.store.HostRouteConflict(req.Host.Host, req.Location, uint(id)); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateHost(&req.Host); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.saveResourceVisitors("host", req.ID, req.VisitorAuthEnabled, req.VisitorBindMode, req.VisitorIDs); err != nil {
|
||||
writeVisitorSaveError(c, err)
|
||||
return
|
||||
}
|
||||
s.cache.UpsertHost(&req.Host)
|
||||
view, _ := s.enrichHostView(req.Host)
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) deleteHost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
claims := getClaims(c)
|
||||
existing, err := s.store.GetHostByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err := store.ClientOwnerCheck(s.store, existing.ClientID, claims.UserID, auth.IsAdmin(claims.Role)); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteHost(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cache.DeleteHost(uint(id))
|
||||
s.cache.DeleteResourceVisitors("host", uint(id))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listIPRules(c *gin.Context) {
|
||||
scope := c.Query("scope")
|
||||
resourceID, _ := strconv.ParseUint(c.Query("resource_id"), 10, 64)
|
||||
rules, err := s.store.ListIPRules(scope, uint(resourceID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rules)
|
||||
}
|
||||
|
||||
func (s *Server) createIPRule(c *gin.Context) {
|
||||
var req store.IPRule
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if req.PatternKind == "" {
|
||||
req.PatternKind = security.DetectPatternKind(req.Pattern)
|
||||
}
|
||||
if err := s.store.CreateIPRule(&req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.cache.ReloadIPRules(s.store)
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (s *Server) deleteIPRule(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := s.store.DeleteIPRule(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.cache.ReloadIPRules(s.store)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listRequestIPs(c *gin.Context) {
|
||||
resourceType := c.Query("resource_type")
|
||||
resourceID, _ := strconv.ParseUint(c.Query("resource_id"), 10, 64)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
q := c.Query("q")
|
||||
if resourceType == "" && resourceID == 0 {
|
||||
items, total, err := s.store.ListAllRequestIPs(limit, offset, q)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": requestIPViews(s.store, items), "total": total})
|
||||
return
|
||||
}
|
||||
items, total, err := s.store.ListRequestIPs(resourceType, uint(resourceID), limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": requestIPViews(s.store, items), "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) blockIP(c *gin.Context) {
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
Scope string `json:"scope"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
scope = req.ResourceType
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "global"
|
||||
}
|
||||
if err := s.security.BlockIP(req.IP, scope, req.ResourceID, req.Remark); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listAuthPolicies(c *gin.Context) {
|
||||
resourceType := c.Query("resource_type")
|
||||
resourceID, _ := strconv.ParseUint(c.Query("resource_id"), 10, 64)
|
||||
policies, err := s.store.ListAuthPolicies(resourceType, uint(resourceID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, policies)
|
||||
}
|
||||
|
||||
func (s *Server) createAuthPolicy(c *gin.Context) {
|
||||
var req store.AuthPolicy
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if err := s.resAuth.CreatePolicy(s.store, &req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.cache.ReloadAll(s.store)
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (s *Server) deleteAuthPolicy(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := s.store.DeleteAuthPolicy(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.cache.ReloadAll(s.store)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
if !auth.IsAdmin(claims.Role) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListVisitors()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *Server) listVisitorAccessLogs(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
if !auth.IsAdmin(claims.Role) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
user, err := s.store.GetUserByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if user.Role != "visitor" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可查看访客访问记录"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
|
||||
logs, err := s.store.ListVisitorAccessLogs(uint(id), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
items = append(items, gin.H{
|
||||
"id": log.ID,
|
||||
"user_id": log.UserID,
|
||||
"resource_type": log.ResourceType,
|
||||
"resource_id": log.ResourceID,
|
||||
"resource_label": visitorResourceLabel(s.store, log.ResourceType, log.ResourceID),
|
||||
"action": log.Action,
|
||||
"method": log.Method,
|
||||
"path": log.Path,
|
||||
"ip": log.IP,
|
||||
"user_agent": log.UserAgent,
|
||||
"created_at": log.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func (s *Server) createUser(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
if !auth.IsAdmin(claims.Role) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
hash, err := store.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = "visitor"
|
||||
}
|
||||
if req.Role != "visitor" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持创建访客用户"})
|
||||
return
|
||||
}
|
||||
user := store.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hash,
|
||||
Role: req.Role,
|
||||
Status: true,
|
||||
}
|
||||
if err := s.store.CreateUser(&user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
if !auth.IsAdmin(claims.Role) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
user, err := s.store.GetUserByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if user.Role != "visitor" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可编辑访客用户"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Status *bool `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if req.Password != "" {
|
||||
hash, err := store.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = hash
|
||||
}
|
||||
if req.Role != "" {
|
||||
if req.Role != "visitor" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持访客用户"})
|
||||
return
|
||||
}
|
||||
user.Role = req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
user.Status = *req.Status
|
||||
}
|
||||
if err := s.store.UpdateUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(c *gin.Context) {
|
||||
claims := getClaims(c)
|
||||
if !auth.IsAdmin(claims.Role) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
user, err := s.store.GetUserByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if user.Role != "visitor" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅可删除访客用户"})
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteUser(uint(id), claims.UserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
Reference in New Issue
Block a user