184 lines
4.6 KiB
Go
184 lines
4.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gotunnel/internal/server/db"
|
|
)
|
|
|
|
type InstallHandler struct {
|
|
app AppInterface
|
|
}
|
|
|
|
const (
|
|
installTokenHeader = "X-GoTunnel-Install-Token"
|
|
installTokenTTL = 3600
|
|
)
|
|
|
|
func NewInstallHandler(app AppInterface) *InstallHandler {
|
|
return &InstallHandler{app: app}
|
|
}
|
|
|
|
type InstallCommandResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
TunnelPort int `json:"tunnel_port"`
|
|
}
|
|
|
|
// GenerateInstallCommand creates a one-time install token and returns
|
|
// the tunnel port so the frontend can build a host-aware command.
|
|
//
|
|
// @Summary Generate install command payload
|
|
// @Tags install
|
|
// @Produce json
|
|
// @Success 200 {object} InstallCommandResponse
|
|
// @Router /api/install/generate [post]
|
|
func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
|
return
|
|
}
|
|
|
|
token := hex.EncodeToString(tokenBytes)
|
|
now := time.Now().Unix()
|
|
|
|
installToken := &db.InstallToken{
|
|
Token: token,
|
|
CreatedAt: now,
|
|
Used: false,
|
|
}
|
|
|
|
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "install token store is not supported"})
|
|
return
|
|
}
|
|
|
|
if err := store.CreateInstallToken(installToken); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, InstallCommandResponse{
|
|
Token: token,
|
|
ExpiresAt: now + installTokenTTL,
|
|
TunnelPort: h.app.GetServer().GetBindPort(),
|
|
})
|
|
}
|
|
|
|
func (h *InstallHandler) ServeShellScript(c *gin.Context) {
|
|
if !h.validateInstallToken(c) {
|
|
return
|
|
}
|
|
|
|
applyInstallSecurityHeaders(c)
|
|
c.Header("Content-Type", "text/x-shellscript; charset=utf-8")
|
|
c.String(http.StatusOK, shellInstallScript)
|
|
}
|
|
|
|
func (h *InstallHandler) ServePowerShellScript(c *gin.Context) {
|
|
if !h.validateInstallToken(c) {
|
|
return
|
|
}
|
|
|
|
applyInstallSecurityHeaders(c)
|
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
|
c.String(http.StatusOK, powerShellInstallScript)
|
|
}
|
|
|
|
func (h *InstallHandler) DownloadClient(c *gin.Context) {
|
|
if !h.validateInstallToken(c) {
|
|
return
|
|
}
|
|
|
|
osName := c.Query("os")
|
|
arch := c.Query("arch")
|
|
|
|
updateInfo, err := checkClientUpdateForPlatform(osName, arch)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve client package"})
|
|
return
|
|
}
|
|
if updateInfo.DownloadURL == "" {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no client package found for this platform"})
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, updateInfo.DownloadURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create download request"})
|
|
return
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download client package"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("upstream returned %s", resp.Status)})
|
|
return
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
applyInstallSecurityHeaders(c)
|
|
c.Header("Content-Type", contentType)
|
|
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
|
c.Header("Content-Length", contentLength)
|
|
}
|
|
if updateInfo.AssetName != "" {
|
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, updateInfo.AssetName))
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
_, _ = io.Copy(c.Writer, resp.Body)
|
|
}
|
|
|
|
func (h *InstallHandler) validateInstallToken(c *gin.Context) bool {
|
|
token := strings.TrimSpace(c.GetHeader(installTokenHeader))
|
|
if token == "" {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return false
|
|
}
|
|
|
|
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
|
|
if !ok {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return false
|
|
}
|
|
|
|
installToken, err := store.GetInstallToken(token)
|
|
if err != nil {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return false
|
|
}
|
|
|
|
if installToken.Used || time.Now().Unix()-installToken.CreatedAt >= installTokenTTL {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func applyInstallSecurityHeaders(c *gin.Context) {
|
|
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
|
c.Header("Pragma", "no-cache")
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("X-Robots-Tag", "noindex, nofollow, noarchive")
|
|
}
|