Add Android client support and unify cross-platform builds

This commit is contained in:
2026-03-22 21:25:09 +08:00
parent 6558d1acdb
commit 4210ab7675
44 changed files with 2241 additions and 328 deletions

View File

@@ -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")
}

View 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.'
}
`