Add Android client support and unify cross-platform builds
This commit is contained in:
@@ -3,7 +3,10 @@ package handler
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,6 +17,11 @@ type InstallHandler struct {
|
||||
app AppInterface
|
||||
}
|
||||
|
||||
const (
|
||||
installTokenHeader = "X-GoTunnel-Install-Token"
|
||||
installTokenTTL = 3600
|
||||
)
|
||||
|
||||
func NewInstallHandler(app AppInterface) *InstallHandler {
|
||||
return &InstallHandler{app: app}
|
||||
}
|
||||
@@ -61,7 +69,115 @@ func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, InstallCommandResponse{
|
||||
Token: token,
|
||||
ExpiresAt: now + 3600,
|
||||
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")
|
||||
}
|
||||
|
||||
185
internal/server/router/handler/install_scripts.go
Normal file
185
internal/server/router/handler/install_scripts.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
const shellInstallScript = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash install.sh -s <server:port> -t <token> -b <web-base-url>
|
||||
|
||||
Options:
|
||||
-s Tunnel server address, for example 10.0.0.2:7000
|
||||
-t One-time install token generated by the server
|
||||
-b Web console base URL, for example https://example.com:7500
|
||||
EOF
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux) echo "linux" ;;
|
||||
Darwin) echo "darwin" ;;
|
||||
*)
|
||||
echo "unsupported operating system: $(uname -s)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64" ;;
|
||||
aarch64|arm64) echo "arm64" ;;
|
||||
i386|i686) echo "386" ;;
|
||||
armv7l|armv6l|arm) echo "arm" ;;
|
||||
*)
|
||||
echo "unsupported architecture: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
SERVER_ADDR=""
|
||||
INSTALL_TOKEN=""
|
||||
BASE_URL=""
|
||||
|
||||
while getopts ":s:t:b:h" opt; do
|
||||
case "$opt" in
|
||||
s) SERVER_ADDR="$OPTARG" ;;
|
||||
t) INSTALL_TOKEN="$OPTARG" ;;
|
||||
b) BASE_URL="$OPTARG" ;;
|
||||
h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
:)
|
||||
echo "option -$OPTARG requires a value" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
\?)
|
||||
echo "unknown option: -$OPTARG" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SERVER_ADDR" || -z "$INSTALL_TOKEN" || -z "$BASE_URL" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_cmd curl
|
||||
require_cmd tar
|
||||
require_cmd mktemp
|
||||
|
||||
OS_NAME="$(detect_os)"
|
||||
ARCH_NAME="$(detect_arch)"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
INSTALL_ROOT="${HOME:-$(pwd)}/.gotunnel"
|
||||
BIN_DIR="$INSTALL_ROOT/bin"
|
||||
TARGET_BIN="$BIN_DIR/gotunnel-client"
|
||||
LOG_FILE="$INSTALL_ROOT/client.log"
|
||||
PID_FILE="$INSTALL_ROOT/client.pid"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
ARCHIVE_PATH="$TMP_DIR/gotunnel-client.tar.gz"
|
||||
DOWNLOAD_URL="$BASE_URL/install/client?os=$OS_NAME&arch=$ARCH_NAME"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
echo "Downloading GoTunnel client from $DOWNLOAD_URL"
|
||||
curl -fsSL -H "X-GoTunnel-Install-Token: $INSTALL_TOKEN" "$DOWNLOAD_URL" -o "$ARCHIVE_PATH"
|
||||
tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
|
||||
|
||||
EXTRACTED_BIN="$(find "$TMP_DIR" -type f -name 'gotunnel-client*' ! -name '*.tar.gz' ! -name '*.zip' | head -n 1)"
|
||||
if [[ -z "$EXTRACTED_BIN" ]]; then
|
||||
echo "failed to find extracted client binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$EXTRACTED_BIN" "$TARGET_BIN"
|
||||
chmod 0755 "$TARGET_BIN"
|
||||
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||
if [[ -n "$OLD_PID" ]]; then
|
||||
kill "$OLD_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
nohup "$TARGET_BIN" -s "$SERVER_ADDR" -t "$INSTALL_TOKEN" >>"$LOG_FILE" 2>&1 &
|
||||
NEW_PID=$!
|
||||
echo "$NEW_PID" >"$PID_FILE"
|
||||
|
||||
echo "GoTunnel client installed to $TARGET_BIN"
|
||||
echo "Client started in background with PID $NEW_PID"
|
||||
echo "Logs: $LOG_FILE"
|
||||
`
|
||||
|
||||
const powerShellInstallScript = `function Get-GoTunnelArch {
|
||||
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) {
|
||||
'x64' { return 'amd64' }
|
||||
'arm64' { return 'arm64' }
|
||||
'x86' { return '386' }
|
||||
default { throw "Unsupported architecture: $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" }
|
||||
}
|
||||
}
|
||||
|
||||
function Install-GoTunnel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Server,
|
||||
[Parameter(Mandatory = $true)][string]$Token,
|
||||
[Parameter(Mandatory = $true)][string]$BaseUrl
|
||||
)
|
||||
|
||||
$BaseUrl = $BaseUrl.TrimEnd('/')
|
||||
$Arch = Get-GoTunnelArch
|
||||
$InstallRoot = Join-Path $env:LOCALAPPDATA 'GoTunnel'
|
||||
$ExtractDir = Join-Path $InstallRoot 'extract'
|
||||
$ArchivePath = Join-Path $InstallRoot 'gotunnel-client.zip'
|
||||
$TargetPath = Join-Path $InstallRoot 'gotunnel-client.exe'
|
||||
$DownloadUrl = "$BaseUrl/install/client?os=windows&arch=$Arch"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
|
||||
|
||||
Write-Host "Downloading GoTunnel client from $DownloadUrl"
|
||||
$Headers = @{ 'X-GoTunnel-Install-Token' = $Token }
|
||||
Invoke-WebRequest -Uri $DownloadUrl -Headers $Headers -OutFile $ArchivePath -MaximumRedirection 5
|
||||
|
||||
if (Test-Path $ExtractDir) {
|
||||
Remove-Item -Path $ExtractDir -Recurse -Force
|
||||
}
|
||||
Expand-Archive -Path $ArchivePath -DestinationPath $ExtractDir -Force
|
||||
|
||||
$Binary = Get-ChildItem -Path $ExtractDir -Recurse -File |
|
||||
Where-Object { $_.Name -eq 'gotunnel-client.exe' } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $Binary) {
|
||||
throw 'Failed to find extracted client binary.'
|
||||
}
|
||||
|
||||
Copy-Item -Path $Binary.FullName -Destination $TargetPath -Force
|
||||
|
||||
Get-Process |
|
||||
Where-Object { $_.Path -eq $TargetPath } |
|
||||
Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Process -FilePath $TargetPath -ArgumentList @('-s', $Server, '-t', $Token) -WindowStyle Hidden
|
||||
|
||||
Write-Host "GoTunnel client installed to $TargetPath"
|
||||
Write-Host 'Client started in background.'
|
||||
}
|
||||
`
|
||||
@@ -48,6 +48,11 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
|
||||
engine.POST("/api/auth/login", authHandler.Login)
|
||||
engine.GET("/api/auth/check", authHandler.Check)
|
||||
|
||||
installHandler := handler.NewInstallHandler(app)
|
||||
engine.GET("/install.sh", installHandler.ServeShellScript)
|
||||
engine.GET("/install.ps1", installHandler.ServePowerShellScript)
|
||||
engine.GET("/install/client", installHandler.DownloadClient)
|
||||
|
||||
// API 路由 (需要 JWT)
|
||||
api := engine.Group("/api")
|
||||
api.Use(middleware.JWTAuth(jwtAuth))
|
||||
@@ -94,7 +99,6 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
|
||||
api.GET("/traffic/hourly", trafficHandler.GetHourly)
|
||||
|
||||
// 安装命令生成
|
||||
installHandler := handler.NewInstallHandler(app)
|
||||
api.POST("/install/generate", installHandler.GenerateInstallCommand)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user