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>
349 lines
9.0 KiB
Go
349 lines
9.0 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/wormhole/wormhole/internal/auth"
|
|
"github.com/wormhole/wormhole/internal/store"
|
|
)
|
|
|
|
type importResult struct {
|
|
Created int `json:"created"`
|
|
Updated int `json:"updated"`
|
|
Skipped int `json:"skipped"`
|
|
Failed int `json:"failed"`
|
|
Errors []string `json:"errors"`
|
|
}
|
|
|
|
type tunnelImportRequest struct {
|
|
Mode string `json:"mode"` // create, upsert, skip
|
|
Items []store.TunnelExportItem `json:"items"`
|
|
}
|
|
|
|
type hostImportRequest struct {
|
|
Mode string `json:"mode"`
|
|
Items []store.HostExportItem `json:"items"`
|
|
}
|
|
|
|
func (s *Server) exportTunnels(c *gin.Context) {
|
|
claims := getClaims(c)
|
|
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
|
|
tunnels, err := s.store.ListTunnels(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
clientNames := tunnelClientNames(s.store, tunnels)
|
|
items := make([]store.TunnelExportItem, 0, len(tunnels))
|
|
for _, t := range tunnels {
|
|
items = append(items, store.TunnelToExportItem(t, clientNames[t.ClientID]))
|
|
}
|
|
doc := store.TunnelExportDoc{
|
|
Version: store.ExportVersion,
|
|
ExportedAt: time.Now().UTC(),
|
|
Items: items,
|
|
}
|
|
c.Header("Content-Disposition", `attachment; filename="wormhole-tunnels.json"`)
|
|
c.JSON(http.StatusOK, doc)
|
|
}
|
|
|
|
func (s *Server) importTunnels(c *gin.Context) {
|
|
claims := getClaims(c)
|
|
var req tunnelImportRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
|
return
|
|
}
|
|
if len(req.Items) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
|
|
return
|
|
}
|
|
mode := req.Mode
|
|
if mode == "" {
|
|
mode = "upsert"
|
|
}
|
|
result := importResult{}
|
|
for i, item := range req.Items {
|
|
clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
|
|
if err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
|
continue
|
|
}
|
|
t := tunnelFromImportItem(item, clientID)
|
|
if err := normalizeTunnel(&t); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
|
continue
|
|
}
|
|
existing, err := s.store.FindTunnelByPort(t.ListenPort)
|
|
switch mode {
|
|
case "create":
|
|
if err == nil {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
if err := s.store.CreateTunnel(&t); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertTunnel(&t)
|
|
if t.Status {
|
|
_ = s.proxy.StartTunnel(t.ID)
|
|
}
|
|
result.Created++
|
|
case "skip":
|
|
if err == nil {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
if err := s.store.CreateTunnel(&t); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertTunnel(&t)
|
|
if t.Status {
|
|
_ = s.proxy.StartTunnel(t.ID)
|
|
}
|
|
result.Created++
|
|
default: // upsert
|
|
if err != nil {
|
|
if err := s.store.CreateTunnel(&t); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertTunnel(&t)
|
|
if t.Status {
|
|
_ = s.proxy.StartTunnel(t.ID)
|
|
}
|
|
result.Created++
|
|
continue
|
|
}
|
|
wasRunning := existing.RunStatus
|
|
if wasRunning {
|
|
_ = s.proxy.StopTunnel(existing.ID)
|
|
}
|
|
t.ID = existing.ID
|
|
t.ClientID = existing.ClientID
|
|
t.RunStatus = false
|
|
if err := s.store.UpdateTunnel(&t); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("tunnel", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertTunnel(&t)
|
|
if t.Status {
|
|
_ = s.proxy.StartTunnel(t.ID)
|
|
}
|
|
result.Updated++
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) exportHosts(c *gin.Context) {
|
|
claims := getClaims(c)
|
|
clientID, _ := strconv.ParseUint(c.Query("client_id"), 10, 64)
|
|
hosts, err := s.store.ListHosts(uint(clientID), claims.UserID, auth.IsAdmin(claims.Role))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
clientNames := hostClientNames(s.store, hosts)
|
|
items := make([]store.HostExportItem, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
items = append(items, store.HostToExportItem(h, clientNames[h.ClientID]))
|
|
}
|
|
doc := store.HostExportDoc{
|
|
Version: store.ExportVersion,
|
|
ExportedAt: time.Now().UTC(),
|
|
Items: items,
|
|
}
|
|
c.Header("Content-Disposition", `attachment; filename="wormhole-hosts.json"`)
|
|
c.JSON(http.StatusOK, doc)
|
|
}
|
|
|
|
func (s *Server) importHosts(c *gin.Context) {
|
|
claims := getClaims(c)
|
|
var req hostImportRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
|
return
|
|
}
|
|
if len(req.Items) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "items is empty"})
|
|
return
|
|
}
|
|
mode := req.Mode
|
|
if mode == "" {
|
|
mode = "upsert"
|
|
}
|
|
result := importResult{}
|
|
for i, item := range req.Items {
|
|
clientID, err := store.ResolveImportClientID(s.store, item.ClientID, item.ClientName, claims.UserID, auth.IsAdmin(claims.Role))
|
|
if err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
|
continue
|
|
}
|
|
h := hostFromImportItem(item, clientID)
|
|
if err := normalizeHost(&h); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
|
continue
|
|
}
|
|
existing, err := s.store.FindHostByRouteGlobal(h.Host, h.Location)
|
|
switch mode {
|
|
case "create":
|
|
if err == nil {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
if err := s.store.CreateHost(&h); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertHost(&h)
|
|
result.Created++
|
|
case "skip":
|
|
if err == nil {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
if err := s.store.CreateHost(&h); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertHost(&h)
|
|
result.Created++
|
|
default:
|
|
if err != nil {
|
|
if err := s.store.CreateHost(&h); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertHost(&h)
|
|
result.Created++
|
|
continue
|
|
}
|
|
h.ID = existing.ID
|
|
h.ClientID = existing.ClientID
|
|
if err := s.store.UpdateHost(&h); err != nil {
|
|
result.Failed++
|
|
result.Errors = append(result.Errors, formatImportError("host", i+1, err))
|
|
continue
|
|
}
|
|
s.cache.UpsertHost(&h)
|
|
result.Updated++
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func tunnelFromImportItem(item store.TunnelExportItem, clientID uint) store.Tunnel {
|
|
return store.Tunnel{
|
|
ClientID: clientID,
|
|
Name: item.Name,
|
|
Mode: item.Mode,
|
|
ListenIP: item.ListenIP,
|
|
ListenPort: item.ListenPort,
|
|
TargetAddr: item.TargetAddr,
|
|
IPForwardMode: item.IPForwardMode,
|
|
CustomHeaders: item.CustomHeaders,
|
|
Status: item.Status,
|
|
}
|
|
}
|
|
|
|
func hostFromImportItem(item store.HostExportItem, clientID uint) store.Host {
|
|
return store.Host{
|
|
ClientID: clientID,
|
|
Host: item.Host,
|
|
Location: item.Location,
|
|
Scheme: item.Scheme,
|
|
TargetAddr: item.TargetAddr,
|
|
AutoHTTPS: item.AutoHTTPS,
|
|
IPForwardMode: item.IPForwardMode,
|
|
CustomHeaders: item.CustomHeaders,
|
|
Status: item.Status,
|
|
}
|
|
}
|
|
|
|
func normalizeTunnel(t *store.Tunnel) error {
|
|
if t.ListenIP == "" {
|
|
t.ListenIP = "0.0.0.0"
|
|
}
|
|
if t.Mode == "" {
|
|
t.Mode = "tcp"
|
|
}
|
|
if t.ListenPort <= 0 || t.TargetAddr == "" {
|
|
return errImportField("listen_port and target_addr required")
|
|
}
|
|
if t.IPForwardMode == "" {
|
|
t.IPForwardMode = "replace"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeHost(h *store.Host) error {
|
|
if h.Host == "" || h.TargetAddr == "" {
|
|
return errImportField("host and target_addr required")
|
|
}
|
|
if h.Location == "" {
|
|
h.Location = "/"
|
|
}
|
|
if h.Scheme == "" {
|
|
h.Scheme = "all"
|
|
}
|
|
if h.IPForwardMode == "" {
|
|
h.IPForwardMode = "replace"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type importFieldError string
|
|
|
|
func errImportField(msg string) error { return importFieldError(msg) }
|
|
|
|
func (e importFieldError) Error() string { return string(e) }
|
|
|
|
func formatImportError(kind string, index int, err error) string {
|
|
return kind + " #" + strconv.Itoa(index) + ": " + err.Error()
|
|
}
|
|
|
|
func tunnelClientNames(st *store.Store, tunnels []store.Tunnel) map[uint]string {
|
|
ids := make(map[uint]struct{})
|
|
for _, t := range tunnels {
|
|
ids[t.ClientID] = struct{}{}
|
|
}
|
|
names := make(map[uint]string, len(ids))
|
|
for id := range ids {
|
|
if c, err := st.GetClientByID(id); err == nil {
|
|
names[id] = c.Name
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func hostClientNames(st *store.Store, hosts []store.Host) map[uint]string {
|
|
ids := make(map[uint]struct{})
|
|
for _, h := range hosts {
|
|
ids[h.ClientID] = struct{}{}
|
|
}
|
|
names := make(map[uint]string, len(ids))
|
|
for id := range ids {
|
|
if c, err := st.GetClientByID(id); err == nil {
|
|
names[id] = c.Name
|
|
}
|
|
}
|
|
return names
|
|
}
|