0d84b07f68
CI / docker (push) Successful in 1m58s
Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor <cursoragent@cursor.com>
922 lines
27 KiB
Go
922 lines
27 KiB
Go
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})
|
|
}
|