diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index beffdbb..4ccde3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,38 @@ jobs: path: web/dist retention-days: 1 + android-apk: + name: Build Android debug APK + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.7' + + - name: Build debug APK + working-directory: android + run: gradle --no-daemon assembleDebug + + - name: Upload Android APK artifact + uses: actions/upload-artifact@v4 + with: + name: gotunnel-android-debug-apk + path: android/app/build/outputs/apk/debug/app-debug.apk + retention-days: 7 + build: name: Build ${{ matrix.goos }}/${{ matrix.goarch }} needs: frontend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0215321..f7ca6f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,60 @@ jobs: path: web/dist retention-days: 1 + android-apk: + name: Package Android release APK + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve release metadata + id: meta + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.7' + + - name: Build release APK + working-directory: android + run: gradle --no-daemon assembleRelease + + - name: Package Android release asset + id: package + shell: bash + run: | + mkdir -p dist/out + ARCHIVE="gotunnel-android-${{ steps.meta.outputs.tag }}-release-unsigned.apk" + cp android/app/build/outputs/apk/release/app-release-unsigned.apk "dist/out/${ARCHIVE}" + echo "archive=dist/out/${ARCHIVE}" >> "$GITHUB_OUTPUT" + + - name: Upload Android release artifact + uses: actions/upload-artifact@v4 + with: + name: release-android-apk + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + build-assets: name: Package ${{ matrix.component }} ${{ matrix.goos }}/${{ matrix.goarch }} needs: frontend @@ -176,7 +230,9 @@ jobs: publish: name: Publish release - needs: build-assets + needs: + - build-assets + - android-apk runs-on: ubuntu-latest steps: - name: Download packaged assets diff --git a/Makefile b/Makefile index 9a43196..2162371 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,13 @@ # GoTunnel Makefile -.PHONY: all build-frontend sync-frontend build-server build-client clean help +.PHONY: all build-frontend sync-frontend sync-only build-server build-client build-all-platforms build-current-platform build-android clean help -# 默认目标 -all: build-frontend sync-frontend build-server build-client +all: build-frontend sync-frontend build-current-platform -# 构建前端 build-frontend: @echo "Building frontend..." cd web && npm ci && npm run build -# 同步前端到 embed 目录 sync-frontend: @echo "Syncing frontend to embed directory..." ifeq ($(OS),Windows_NT) @@ -21,7 +18,6 @@ else cp -r web/dist internal/server/app/dist endif -# 仅同步(不重新构建前端) sync-only: @echo "Syncing existing frontend build..." ifeq ($(OS),Windows_NT) @@ -32,33 +28,38 @@ else cp -r web/dist internal/server/app/dist endif -# 构建服务端(当前平台) build-server: - @echo "Building server..." - go build -ldflags="-s -w" -o gotunnel-server ./cmd/server + @echo "Building server for current platform..." + go build -buildvcs=false -trimpath -ldflags="-s -w" -o gotunnel-server ./cmd/server -# 构建客户端(当前平台) build-client: - @echo "Building client..." - go build -ldflags="-s -w" -o gotunnel-client ./cmd/client + @echo "Building client for current platform..." + go build -buildvcs=false -trimpath -ldflags="-s -w" -o gotunnel-client ./cmd/client -# 构建 Linux ARM64 服务端 -build-server-linux-arm64: sync-only - @echo "Building server for Linux ARM64..." - GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-arm64 ./cmd/server +build-current-platform: + @echo "Building current platform binaries..." +ifeq ($(OS),Windows_NT) + powershell -ExecutionPolicy Bypass -File scripts/build.ps1 current +else + ./scripts/build.sh current +endif -# 构建 Linux AMD64 服务端 -build-server-linux-amd64: sync-only - @echo "Building server for Linux AMD64..." - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-amd64 ./cmd/server +build-all-platforms: + @echo "Building all desktop platform binaries..." +ifeq ($(OS),Windows_NT) + powershell -ExecutionPolicy Bypass -File scripts/build.ps1 all -NoUPX +else + ./scripts/build.sh all +endif -# 完整构建(包含前端) -full-build: build-frontend sync-frontend build-server build-client +build-android: + @echo "Android build placeholder..." +ifeq ($(OS),Windows_NT) + powershell -ExecutionPolicy Bypass -File scripts/build.ps1 android +else + ./scripts/build.sh android +endif -# 开发模式:快速构建(假设前端已构建) -dev-build: sync-only build-server - -# 清理构建产物 clean: @echo "Cleaning..." ifeq ($(OS),Windows_NT) @@ -68,21 +69,21 @@ ifeq ($(OS),Windows_NT) if exist gotunnel-client.exe del gotunnel-client.exe if exist gotunnel-server-* del gotunnel-server-* if exist gotunnel-client-* del gotunnel-client-* + if exist build rmdir /s /q build else rm -f gotunnel-server gotunnel-client gotunnel-server-* gotunnel-client-* + rm -rf build endif -# 帮助 help: @echo "Available targets:" - @echo " all - Build frontend, sync, and build binaries" + @echo " all - Build frontend, sync, and current platform binaries" @echo " build-frontend - Build frontend (npm)" @echo " sync-frontend - Sync web/dist to internal/server/app/dist" @echo " sync-only - Sync without rebuilding frontend" @echo " build-server - Build server for current platform" @echo " build-client - Build client for current platform" - @echo " build-server-linux-arm64 - Cross-compile server for Linux ARM64" - @echo " build-server-linux-amd64 - Cross-compile server for Linux AMD64" - @echo " full-build - Complete build with frontend" - @echo " dev-build - Quick build (assumes frontend exists)" + @echo " build-current-platform - Build server/client into build/_/" + @echo " build-all-platforms - Build Windows/Linux/macOS server/client binaries" + @echo " build-android - Android build placeholder" @echo " clean - Remove build artifacts" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..8b44201 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,5 @@ +/.gradle +/build +/app/build +/local.properties +/captures diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..8d46fb5 --- /dev/null +++ b/android/README.md @@ -0,0 +1,27 @@ +# GoTunnel Android Host + +This directory contains a minimal Android Studio / Gradle project skeleton for the GoTunnel Android host app. + +## What is included + +- Foreground service shell for keeping the tunnel process alive +- Boot receiver for auto-start on device reboot +- Network recovery helper for reconnect/restart triggers +- Basic configuration screen for server address and token +- Notification channel and ongoing service notification +- A stub bridge layer that can later be replaced with a gomobile/native Go core binding + +## Current status + +The Go tunnel core is not wired into Android yet. `GoTunnelBridge` returns a stub controller so the app structure can be developed independently from the Go runtime integration. + +## Open in Android Studio + +Open the `android/` folder as a Gradle project. Android Studio can sync it directly and generate a wrapper if you want to build from the command line later. + +## Notes + +- The foreground service is marked as `dataSync` and starts in sticky mode. +- Auto-start is controlled by the saved configuration. +- Network restoration currently triggers a restart hook in the stub controller. +- Replace the stub bridge with a native binding when the Go client core is exported for Android. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..81b228b --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.gotunnel.android" + compileSdk = 34 + + defaultConfig { + applicationId = "com.gotunnel.android" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-ktx:1.9.2") + implementation("com.google.android.material:material:1.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..4efd082 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Placeholder rules for the Android host shell. +# Add Go bridge / native binding rules here when the core integration is introduced. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..36f7651 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/gotunnel/android/GoTunnelApp.kt b/android/app/src/main/java/com/gotunnel/android/GoTunnelApp.kt new file mode 100644 index 0000000..58e6656 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/GoTunnelApp.kt @@ -0,0 +1,11 @@ +package com.gotunnel.android + +import android.app.Application +import com.gotunnel.android.service.NotificationHelper + +class GoTunnelApp : Application() { + override fun onCreate() { + super.onCreate() + NotificationHelper.ensureChannel(this) + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/MainActivity.kt b/android/app/src/main/java/com/gotunnel/android/MainActivity.kt new file mode 100644 index 0000000..572fa0c --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/MainActivity.kt @@ -0,0 +1,153 @@ +package com.gotunnel.android + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.gotunnel.android.bridge.TunnelStatus +import com.gotunnel.android.config.AppConfig +import com.gotunnel.android.config.ConfigStore +import com.gotunnel.android.config.ServiceStateStore +import com.gotunnel.android.databinding.ActivityMainBinding +import com.gotunnel.android.service.TunnelService +import java.text.DateFormat +import java.util.Date + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var configStore: ConfigStore + private lateinit var stateStore: ServiceStateStore + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (!granted) { + Toast.makeText(this, R.string.notification_permission_denied, Toast.LENGTH_SHORT).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestNotificationPermissionIfNeeded() + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + configStore = ConfigStore(this) + stateStore = ServiceStateStore(this) + + populateForm(configStore.load()) + renderState() + + binding.saveButton.setOnClickListener { + val config = readForm() + configStore.save(config) + renderState() + Toast.makeText(this, R.string.config_saved, Toast.LENGTH_SHORT).show() + } + + binding.startButton.setOnClickListener { + val config = readForm() + configStore.save(config) + renderState() + ContextCompat.startForegroundService( + this, + TunnelService.createStartIntent(this, "manual-start"), + ) + Toast.makeText(this, R.string.service_start_requested, Toast.LENGTH_SHORT).show() + } + + binding.stopButton.setOnClickListener { + ContextCompat.startForegroundService( + this, + TunnelService.createStopIntent(this, "manual-stop"), + ) + Toast.makeText(this, R.string.service_stop_requested, Toast.LENGTH_SHORT).show() + } + + binding.batteryButton.setOnClickListener { + openBatteryOptimizationSettings() + } + } + + override fun onResume() { + super.onResume() + renderState() + } + + private fun populateForm(config: AppConfig) { + binding.serverAddressInput.setText(config.serverAddress) + binding.tokenInput.setText(config.token) + binding.autoStartSwitch.isChecked = config.autoStart + binding.autoReconnectSwitch.isChecked = config.autoReconnect + binding.useTlsSwitch.isChecked = config.useTls + } + + private fun readForm(): AppConfig { + return AppConfig( + serverAddress = binding.serverAddressInput.text?.toString().orEmpty().trim(), + token = binding.tokenInput.text?.toString().orEmpty().trim(), + autoStart = binding.autoStartSwitch.isChecked, + autoReconnect = binding.autoReconnectSwitch.isChecked, + useTls = binding.useTlsSwitch.isChecked, + ) + } + + private fun renderState() { + val state = stateStore.load() + val timestamp = if (state.updatedAt > 0L) { + DateFormat.getDateTimeInstance().format(Date(state.updatedAt)) + } else { + getString(R.string.state_never_updated) + } + + binding.stateValue.text = getString( + R.string.state_format, + state.status.name, + state.detail.ifBlank { getString(R.string.state_no_detail) }, + ) + binding.stateMeta.text = getString(R.string.state_meta_format, timestamp) + + val hint = when (state.status) { + TunnelStatus.RUNNING -> R.string.state_hint_running + TunnelStatus.STARTING -> R.string.state_hint_starting + TunnelStatus.RECONNECTING -> R.string.state_hint_reconnecting + TunnelStatus.ERROR -> R.string.state_hint_error + TunnelStatus.STOPPED -> R.string.state_hint_stopped + } + binding.stateHint.text = getString(hint) + } + + private fun requestNotificationPermissionIfNeeded() { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + return + } + + val granted = ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + private fun openBatteryOptimizationSettings() { + val powerManager = getSystemService(PowerManager::class.java) + if (powerManager != null && powerManager.isIgnoringBatteryOptimizations(packageName)) { + Toast.makeText(this, R.string.battery_optimization_already_disabled, Toast.LENGTH_SHORT).show() + return + } + + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + startActivity(intent) + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/bridge/GoTunnelBridge.kt b/android/app/src/main/java/com/gotunnel/android/bridge/GoTunnelBridge.kt new file mode 100644 index 0000000..52834b4 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/bridge/GoTunnelBridge.kt @@ -0,0 +1,10 @@ +package com.gotunnel.android.bridge + +import android.content.Context + +object GoTunnelBridge { + fun create(context: Context): TunnelController { + // Stub bridge for the Android shell. Replace with a native Go binding later. + return StubTunnelController(context.applicationContext) + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/bridge/StubTunnelController.kt b/android/app/src/main/java/com/gotunnel/android/bridge/StubTunnelController.kt new file mode 100644 index 0000000..6e0a71b --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/bridge/StubTunnelController.kt @@ -0,0 +1,64 @@ +package com.gotunnel.android.bridge + +import android.content.Context +import com.gotunnel.android.config.AppConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class StubTunnelController( + @Suppress("unused") private val context: Context, +) : TunnelController { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var listener: TunnelController.Listener? = null + private var config: AppConfig = AppConfig() + private var job: Job? = null + + override val isRunning: Boolean + get() = job?.isActive == true + + override fun setListener(listener: TunnelController.Listener?) { + this.listener = listener + } + + override fun updateConfig(config: AppConfig) { + this.config = config + } + + override fun start(config: AppConfig) { + updateConfig(config) + if (isRunning) { + listener?.onLog("Stub tunnel already running") + return + } + + job = scope.launch { + listener?.onStatusChanged(TunnelStatus.STARTING, "Preparing tunnel session") + delay(400) + listener?.onLog("Stub tunnel prepared for ${config.serverAddress}") + listener?.onStatusChanged(TunnelStatus.RUNNING, "Waiting for native Go core") + + while (isActive) { + delay(30_000) + listener?.onLog("Stub keepalive tick for ${this@StubTunnelController.config.serverAddress}") + } + } + } + + override fun stop(reason: String) { + listener?.onLog("Stub tunnel stop requested: $reason") + job?.cancel() + job = null + listener?.onStatusChanged(TunnelStatus.STOPPED, reason) + } + + override fun restart(reason: String) { + listener?.onStatusChanged(TunnelStatus.RECONNECTING, reason) + stop(reason) + start(config) + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/bridge/TunnelController.kt b/android/app/src/main/java/com/gotunnel/android/bridge/TunnelController.kt new file mode 100644 index 0000000..6d3a695 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/bridge/TunnelController.kt @@ -0,0 +1,26 @@ +package com.gotunnel.android.bridge + +import com.gotunnel.android.config.AppConfig + +enum class TunnelStatus { + STOPPED, + STARTING, + RUNNING, + RECONNECTING, + ERROR, +} + +interface TunnelController { + interface Listener { + fun onStatusChanged(status: TunnelStatus, detail: String = "") + fun onLog(message: String) + } + + val isRunning: Boolean + + fun setListener(listener: Listener?) + fun updateConfig(config: AppConfig) + fun start(config: AppConfig) + fun stop(reason: String = "manual") + fun restart(reason: String = "manual") +} diff --git a/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt b/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt new file mode 100644 index 0000000..8c1a613 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt @@ -0,0 +1,9 @@ +package com.gotunnel.android.config + +data class AppConfig( + val serverAddress: String = "", + val token: String = "", + val autoStart: Boolean = true, + val autoReconnect: Boolean = true, + val useTls: Boolean = true, +) diff --git a/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt b/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt new file mode 100644 index 0000000..7a5d65d --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt @@ -0,0 +1,36 @@ +package com.gotunnel.android.config + +import android.content.Context + +class ConfigStore(context: Context) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): AppConfig { + return AppConfig( + serverAddress = prefs.getString(KEY_SERVER_ADDRESS, "") ?: "", + token = prefs.getString(KEY_TOKEN, "") ?: "", + autoStart = prefs.getBoolean(KEY_AUTO_START, true), + autoReconnect = prefs.getBoolean(KEY_AUTO_RECONNECT, true), + useTls = prefs.getBoolean(KEY_USE_TLS, true), + ) + } + + fun save(config: AppConfig) { + prefs.edit() + .putString(KEY_SERVER_ADDRESS, config.serverAddress) + .putString(KEY_TOKEN, config.token) + .putBoolean(KEY_AUTO_START, config.autoStart) + .putBoolean(KEY_AUTO_RECONNECT, config.autoReconnect) + .putBoolean(KEY_USE_TLS, config.useTls) + .apply() + } + + companion object { + private const val PREFS_NAME = "gotunnel_config" + private const val KEY_SERVER_ADDRESS = "server_address" + private const val KEY_TOKEN = "token" + private const val KEY_AUTO_START = "auto_start" + private const val KEY_AUTO_RECONNECT = "auto_reconnect" + private const val KEY_USE_TLS = "use_tls" + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/config/ServiceStateStore.kt b/android/app/src/main/java/com/gotunnel/android/config/ServiceStateStore.kt new file mode 100644 index 0000000..a71ccba --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/config/ServiceStateStore.kt @@ -0,0 +1,40 @@ +package com.gotunnel.android.config + +import android.content.Context +import com.gotunnel.android.bridge.TunnelStatus + +data class ServiceState( + val status: TunnelStatus = TunnelStatus.STOPPED, + val detail: String = "", + val updatedAt: Long = 0L, +) + +class ServiceStateStore(context: Context) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): ServiceState { + val statusName = prefs.getString(KEY_STATUS, TunnelStatus.STOPPED.name) ?: TunnelStatus.STOPPED.name + val status = runCatching { TunnelStatus.valueOf(statusName) }.getOrDefault(TunnelStatus.STOPPED) + + return ServiceState( + status = status, + detail = prefs.getString(KEY_DETAIL, "") ?: "", + updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L), + ) + } + + fun save(status: TunnelStatus, detail: String) { + prefs.edit() + .putString(KEY_STATUS, status.name) + .putString(KEY_DETAIL, detail) + .putLong(KEY_UPDATED_AT, System.currentTimeMillis()) + .apply() + } + + companion object { + private const val PREFS_NAME = "gotunnel_state" + private const val KEY_STATUS = "status" + private const val KEY_DETAIL = "detail" + private const val KEY_UPDATED_AT = "updated_at" + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/service/BootReceiver.kt b/android/app/src/main/java/com/gotunnel/android/service/BootReceiver.kt new file mode 100644 index 0000000..14ba680 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/service/BootReceiver.kt @@ -0,0 +1,26 @@ +package com.gotunnel.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import com.gotunnel.android.config.ConfigStore + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action ?: return + if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) { + return + } + + val config = ConfigStore(context).load() + if (!config.autoStart) { + return + } + + ContextCompat.startForegroundService( + context, + TunnelService.createStartIntent(context, action.lowercase()), + ) + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/service/NetworkMonitor.kt b/android/app/src/main/java/com/gotunnel/android/service/NetworkMonitor.kt new file mode 100644 index 0000000..3ae5af4 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/service/NetworkMonitor.kt @@ -0,0 +1,52 @@ +package com.gotunnel.android.service + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities + +class NetworkMonitor( + context: Context, + private val onAvailable: () -> Unit, + private val onLost: () -> Unit = {}, +) { + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private var registered = false + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + onAvailable() + } + + override fun onLost(network: Network) { + onLost() + } + } + + fun start() { + if (registered) { + return + } + connectivityManager.registerDefaultNetworkCallback(callback) + registered = true + } + + fun isConnected(): Boolean { + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + fun stop() { + if (!registered) { + return + } + runCatching { + connectivityManager.unregisterNetworkCallback(callback) + } + registered = false + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt b/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt new file mode 100644 index 0000000..ec10576 --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt @@ -0,0 +1,96 @@ +package com.gotunnel.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.gotunnel.android.MainActivity +import com.gotunnel.android.R +import com.gotunnel.android.bridge.TunnelStatus +import com.gotunnel.android.config.AppConfig + +object NotificationHelper { + const val CHANNEL_ID = "gotunnel_tunnel" + const val NOTIFICATION_ID = 2001 + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) != null) { + return + } + + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.notification_channel_description) + } + manager.createNotificationChannel(channel) + } + + fun build( + context: Context, + status: TunnelStatus, + detail: String, + config: AppConfig, + ): Notification { + val baseText = when { + detail.isNotBlank() -> detail + config.serverAddress.isNotBlank() -> context.getString( + R.string.notification_text_configured, + config.serverAddress, + ) + else -> context.getString(R.string.notification_text_unconfigured) + } + + val contentIntent = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + pendingIntentFlags(), + ) + + val stopIntent = PendingIntent.getService( + context, + 1, + TunnelService.createStopIntent(context, "notification-stop"), + pendingIntentFlags(), + ) + + val restartIntent = PendingIntent.getService( + context, + 2, + TunnelService.createRestartIntent(context, "notification-restart"), + pendingIntentFlags(), + ) + + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_gotunnel_notification) + .setContentTitle(context.getString(R.string.notification_title, status.name)) + .setContentText(baseText) + .setStyle(NotificationCompat.BigTextStyle().bigText(baseText)) + .setOngoing(status != TunnelStatus.STOPPED) + .setOnlyAlertOnce(true) + .setContentIntent(contentIntent) + .addAction(android.R.drawable.ic_popup_sync, context.getString(R.string.notification_action_restart), restartIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, context.getString(R.string.notification_action_stop), stopIntent) + .build() + } + + private fun pendingIntentFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + } +} diff --git a/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt b/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt new file mode 100644 index 0000000..a54a80a --- /dev/null +++ b/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt @@ -0,0 +1,177 @@ +package com.gotunnel.android.service + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationManagerCompat +import com.gotunnel.android.bridge.GoTunnelBridge +import com.gotunnel.android.bridge.TunnelController +import com.gotunnel.android.bridge.TunnelStatus +import com.gotunnel.android.config.AppConfig +import com.gotunnel.android.config.ConfigStore +import com.gotunnel.android.config.ServiceStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +class TunnelService : Service() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var configStore: ConfigStore + private lateinit var stateStore: ServiceStateStore + private lateinit var controller: TunnelController + private lateinit var networkMonitor: NetworkMonitor + private var currentConfig: AppConfig = AppConfig() + private var networkMonitorPrimed = false + + override fun onCreate() { + super.onCreate() + configStore = ConfigStore(this) + stateStore = ServiceStateStore(this) + controller = GoTunnelBridge.create(applicationContext) + controller.setListener(object : TunnelController.Listener { + override fun onStatusChanged(status: TunnelStatus, detail: String) { + stateStore.save(status, detail) + updateNotification(status, detail) + } + + override fun onLog(message: String) { + val current = stateStore.load() + stateStore.save(current.status, message) + updateNotification(current.status, message) + } + }) + networkMonitor = NetworkMonitor( + this, + onAvailable = { + if (networkMonitorPrimed) { + networkMonitorPrimed = false + } else { + val config = configStore.load() + if (config.autoReconnect && controller.isRunning) { + controller.restart("network-restored") + } + } + }, + onLost = { + val detail = getString(com.gotunnel.android.R.string.network_lost) + stateStore.save(TunnelStatus.RECONNECTING, detail) + updateNotification(TunnelStatus.RECONNECTING, detail) + }, + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + ensureForeground() + + when (intent?.action) { + ACTION_STOP -> { + stopServiceInternal(intent.getStringExtra(EXTRA_REASON) ?: "stop") + return START_NOT_STICKY + } + + ACTION_RESTART -> { + controller.restart(intent.getStringExtra(EXTRA_REASON) ?: "restart") + } + + else -> { + startOrRefreshTunnel(intent?.getStringExtra(EXTRA_REASON) ?: "start") + } + } + + return START_STICKY + } + + override fun onDestroy() { + runCatching { networkMonitor.stop() } + runCatching { controller.stop("service-destroyed") } + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun ensureForeground() { + val state = stateStore.load() + val config = configStore.load() + NotificationHelper.ensureChannel(this) + startForeground( + NotificationHelper.NOTIFICATION_ID, + NotificationHelper.build( + this, + state.status, + state.detail, + config, + ), + ) + } + + private fun startOrRefreshTunnel(reason: String) { + currentConfig = configStore.load() + controller.updateConfig(currentConfig) + stateStore.save(TunnelStatus.STARTING, reason) + updateNotification(TunnelStatus.STARTING, reason) + + if (!isConfigReady(currentConfig)) { + val detail = getString(com.gotunnel.android.R.string.config_missing) + stateStore.save(TunnelStatus.STOPPED, detail) + updateNotification(TunnelStatus.STOPPED, detail) + return + } + + networkMonitorPrimed = networkMonitor.isConnected() + controller.start(currentConfig) + runCatching { networkMonitor.start() } + } + + private fun stopServiceInternal(reason: String) { + runCatching { networkMonitor.stop() } + networkMonitorPrimed = false + controller.stop(reason) + stateStore.save(TunnelStatus.STOPPED, reason) + updateNotification(TunnelStatus.STOPPED, reason) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun updateNotification(status: TunnelStatus, detail: String) { + val config = currentConfig.takeIf { it.serverAddress.isNotBlank() } ?: configStore.load() + NotificationManagerCompat.from(this).notify( + NotificationHelper.NOTIFICATION_ID, + NotificationHelper.build(this, status, detail, config), + ) + } + + private fun isConfigReady(config: AppConfig): Boolean { + return config.serverAddress.isNotBlank() && config.token.isNotBlank() + } + + companion object { + const val ACTION_START = "com.gotunnel.android.service.action.START" + const val ACTION_STOP = "com.gotunnel.android.service.action.STOP" + const val ACTION_RESTART = "com.gotunnel.android.service.action.RESTART" + const val EXTRA_REASON = "extra_reason" + + fun createStartIntent(context: Context, reason: String): Intent { + return Intent(context, TunnelService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_REASON, reason) + } + } + + fun createStopIntent(context: Context, reason: String): Intent { + return Intent(context, TunnelService::class.java).apply { + action = ACTION_STOP + putExtra(EXTRA_REASON, reason) + } + } + + fun createRestartIntent(context: Context, reason: String): Intent { + return Intent(context, TunnelService::class.java).apply { + action = ACTION_RESTART + putExtra(EXTRA_REASON, reason) + } + } + } +} diff --git a/android/app/src/main/res/drawable/ic_gotunnel_app.xml b/android/app/src/main/res/drawable/ic_gotunnel_app.xml new file mode 100644 index 0000000..9005080 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_gotunnel_app.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_gotunnel_notification.xml b/android/app/src/main/res/drawable/ic_gotunnel_notification.xml new file mode 100644 index 0000000..aa5368b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_gotunnel_notification.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3f9e1b3 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d2a477d --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + #0F172A + #111827 + #0EA5A8 + #38BDF8 + #E5E7EB + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6651507 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ + + GoTunnel + Android host shell for the GoTunnel client core. + Server address, for example 10.0.2.2:7000 + Authentication token + Start on boot + Restart on network restore + Use TLS + Save + Start + Stop + Battery optimization + Configuration saved + Service start requested + Service stop requested + Battery optimization is already disabled for GoTunnel. + Notification permission was denied. Foreground service notifications may be limited. + Never updated + No detail + State: %1$s\nDetail: %2$s + Updated: %1$s + The foreground service is idle until a configuration is saved and started. + The host shell is preparing a tunnel session. + The host shell is ready. The native Go tunnel core can be attached here later. + The host shell is waiting for connectivity to return. + The last session reported an error. Check the configuration and service notification. + GoTunnel service + Keeps the Android host shell running in the foreground + GoTunnel - %1$s + Configured for %1$s + No server configured yet + Restart + Stop + Server address and token are required + Network lost + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..55d741d --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..017d909 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..9ba4441 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..1a7ea3d --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "GoTunnelAndroid" +include(":app") diff --git a/cmd/client/main.go b/cmd/client/main.go index 7d77a5c..b89a20c 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "log" + "time" "github.com/gotunnel/internal/client/config" "github.com/gotunnel/internal/client/tunnel" @@ -10,7 +11,7 @@ import ( "github.com/gotunnel/pkg/version" ) -// 版本信息(通过 ldflags 注入) +// Version information injected by ldflags. var Version string var BuildTime string var GitCommit string @@ -24,9 +25,13 @@ func main() { server := flag.String("s", "", "server address (ip:port)") token := flag.String("t", "", "auth token") configPath := flag.String("c", "", "config file path") + dataDir := flag.String("data-dir", "", "client data directory") + clientName := flag.String("name", "", "client display name") + clientID := flag.String("id", "", "client id") + reconnectMin := flag.Int("reconnect-min", 0, "minimum reconnect delay in seconds") + reconnectMax := flag.Int("reconnect-max", 0, "maximum reconnect delay in seconds") flag.Parse() - // 优先加载配置文件 var cfg *config.ClientConfig if *configPath != "" { var err error @@ -38,26 +43,53 @@ func main() { cfg = &config.ClientConfig{} } - // 命令行参数覆盖配置文件 if *server != "" { cfg.Server = *server } if *token != "" { cfg.Token = *token } + if *dataDir != "" { + cfg.DataDir = *dataDir + } + if *clientName != "" { + cfg.Name = *clientName + } + if *clientID != "" { + cfg.ClientID = *clientID + } + if *reconnectMin > 0 { + cfg.ReconnectMinSec = *reconnectMin + } + if *reconnectMax > 0 { + cfg.ReconnectMaxSec = *reconnectMax + } if cfg.Server == "" || cfg.Token == "" { log.Fatal("Usage: client [-c config.yaml] | [-s -t ]") } - client := tunnel.NewClient(cfg.Server, cfg.Token) + opts := tunnel.ClientOptions{ + DataDir: cfg.DataDir, + ClientID: cfg.ClientID, + ClientName: cfg.Name, + } + if cfg.ReconnectMinSec > 0 { + opts.ReconnectDelay = time.Duration(cfg.ReconnectMinSec) * time.Second + } + if cfg.ReconnectMaxSec > 0 { + opts.ReconnectMaxDelay = time.Duration(cfg.ReconnectMaxSec) * time.Second + } + + client := tunnel.NewClientWithOptions(cfg.Server, cfg.Token, opts) - // TLS 默认启用,默认跳过证书验证(类似 frp) if !cfg.NoTLS { client.TLSEnabled = true client.TLSConfig = crypto.ClientTLSConfig() log.Printf("[Client] TLS enabled") } - client.Run() + if err := client.Run(); err != nil { + log.Fatalf("Client stopped: %v", err) + } } diff --git a/internal/client/config/config.go b/internal/client/config/config.go index 157f816..fe423df 100644 --- a/internal/client/config/config.go +++ b/internal/client/config/config.go @@ -6,14 +6,19 @@ import ( "gopkg.in/yaml.v3" ) -// ClientConfig 客户端配置 +// ClientConfig defines client runtime configuration. type ClientConfig struct { - Server string `yaml:"server"` // 服务器地址 - Token string `yaml:"token"` // 认证 Token - NoTLS bool `yaml:"no_tls"` // 禁用 TLS + Server string `yaml:"server"` + Token string `yaml:"token"` + NoTLS bool `yaml:"no_tls"` + DataDir string `yaml:"data_dir"` + Name string `yaml:"name"` + ClientID string `yaml:"client_id"` + ReconnectMinSec int `yaml:"reconnect_min_sec"` + ReconnectMaxSec int `yaml:"reconnect_max_sec"` } -// LoadClientConfig 加载客户端配置 +// LoadClientConfig loads client configuration from YAML. func LoadClientConfig(path string) (*ClientConfig, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/client/tunnel/client.go b/internal/client/tunnel/client.go index ceffcb6..e857738 100644 --- a/internal/client/tunnel/client.go +++ b/internal/client/tunnel/client.go @@ -22,82 +22,90 @@ import ( "github.com/hashicorp/yamux" ) -// 客户端常量 const ( - dialTimeout = 10 * time.Second - localDialTimeout = 5 * time.Second - udpTimeout = 10 * time.Second - reconnectDelay = 5 * time.Second - disconnectDelay = 3 * time.Second - udpBufferSize = 65535 + dialTimeout = 10 * time.Second + localDialTimeout = 5 * time.Second + udpTimeout = 10 * time.Second + reconnectDelay = 5 * time.Second + maxReconnectDelay = 30 * time.Second + disconnectDelay = 3 * time.Second + tcpKeepAlive = 30 * time.Second + udpBufferSize = 65535 ) -// Client 隧道客户端 +// Client is the tunnel client runtime. type Client struct { ServerAddr string Token string ID string - Name string // 客户端名称(主机名) + Name string TLSEnabled bool TLSConfig *tls.Config - DataDir string // 数据目录 - session *yamux.Session - rules []protocol.ProxyRule - mu sync.RWMutex - logger *Logger // 日志收集器 + DataDir string + + features PlatformFeatures + reconnectDelay time.Duration + reconnectMaxDelay time.Duration + + session *yamux.Session + rules []protocol.ProxyRule + mu sync.RWMutex + logger *Logger } -// NewClient 创建客户端 +// NewClient creates a client with default desktop options. func NewClient(serverAddr, token string) *Client { - // 默认数据目录:优先使用用户主目录,失败时回退到当前工作目录 - var dataDir string - if home, err := os.UserHomeDir(); err == nil && home != "" { - dataDir = filepath.Join(home, ".gotunnel") - } else { - // UserHomeDir 失败(如 Android adb shell 环境),使用当前工作目录 - if cwd, err := os.Getwd(); err == nil { - dataDir = filepath.Join(cwd, ".gotunnel") - log.Printf("[Client] UserHomeDir unavailable, using current directory: %s", dataDir) - } else { - // 最后回退到相对路径 - dataDir = ".gotunnel" - log.Printf("[Client] Warning: using relative path for data directory") - } - } + return NewClientWithOptions(serverAddr, token, ClientOptions{}) +} - // 确保数据目录存在 +// NewClientWithOptions creates a client with explicit runtime options. +func NewClientWithOptions(serverAddr, token string, opts ClientOptions) *Client { + dataDir := resolveDataDir(opts.DataDir) if err := os.MkdirAll(dataDir, 0755); err != nil { log.Printf("Failed to create data dir: %v", err) } - // ID 优先级:命令行参数 > 机器ID - id := getMachineID() - - // 获取主机名作为客户端名称 - hostname, _ := os.Hostname() - - // 初始化日志收集器 logger, err := NewLogger(dataDir) if err != nil { log.Printf("Failed to initialize logger: %v", err) } + features := DefaultPlatformFeatures() + if opts.Features != nil { + features = *opts.Features + } + + delay := opts.ReconnectDelay + if delay <= 0 { + delay = reconnectDelay + } + + maxDelay := opts.ReconnectMaxDelay + if maxDelay <= 0 { + maxDelay = maxReconnectDelay + } + if maxDelay < delay { + maxDelay = delay + } + return &Client{ - ServerAddr: serverAddr, - Token: token, - ID: id, - Name: hostname, - DataDir: dataDir, - logger: logger, + ServerAddr: serverAddr, + Token: token, + ID: resolveClientID(dataDir, opts.ClientID), + Name: resolveClientName(opts.ClientName), + DataDir: dataDir, + features: features, + reconnectDelay: delay, + reconnectMaxDelay: maxDelay, + logger: logger, } } -// InitVersionStore 初始化版本存储 +// InitVersionStore is kept for compatibility with older callers. func (c *Client) InitVersionStore() error { return nil } -// logf 安全地记录日志(同时输出到标准日志和日志收集器) func (c *Client) logf(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) log.Print(msg) @@ -106,7 +114,6 @@ func (c *Client) logf(format string, args ...interface{}) { } } -// logErrorf 安全地记录错误日志 func (c *Client) logErrorf(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) log.Print(msg) @@ -115,7 +122,6 @@ func (c *Client) logErrorf(format string, args ...interface{}) { } } -// logWarnf 安全地记录警告日志 func (c *Client) logWarnf(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) log.Print(msg) @@ -124,32 +130,82 @@ func (c *Client) logWarnf(format string, args ...interface{}) { } } -// Run 启动客户端(带断线重连) +// Run starts the reconnect loop until the process exits. func (c *Client) Run() error { + return c.RunContext(context.Background()) +} + +// RunContext starts the reconnect loop and exits when ctx is cancelled. +func (c *Client) RunContext(ctx context.Context) error { + backoff := c.reconnectDelay + for { - if err := c.connect(); err != nil { + if ctx.Err() != nil { + return nil + } + + if err := c.connect(ctx); err != nil { + if ctx.Err() != nil { + return nil + } c.logErrorf("Connect error: %v", err) - c.logf("Reconnecting in %v...", reconnectDelay) - time.Sleep(reconnectDelay) + c.logf("Reconnecting in %v...", backoff) + if !sleepWithContext(ctx, backoff) { + return nil + } + backoff *= 2 + if backoff > c.reconnectMaxDelay { + backoff = c.reconnectMaxDelay + } continue } - c.handleSession() + backoff = c.reconnectDelay + c.handleSession(ctx) + if ctx.Err() != nil { + return nil + } c.logWarnf("Disconnected, reconnecting...") - time.Sleep(disconnectDelay) + if !sleepWithContext(ctx, disconnectDelay) { + return nil + } } } -// connect 连接到服务端并认证 -func (c *Client) connect() error { +func sleepWithContext(ctx context.Context, wait time.Duration) bool { + timer := time.NewTimer(wait) + defer timer.Stop() + + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func (c *Client) connect(ctx context.Context) error { var conn net.Conn var err error + dialer := &net.Dialer{ + Timeout: dialTimeout, + KeepAlive: tcpKeepAlive, + } + if c.TLSEnabled && c.TLSConfig != nil { - dialer := &net.Dialer{Timeout: dialTimeout} - conn, err = tls.DialWithDialer(dialer, "tcp", c.ServerAddr, c.TLSConfig) + rawConn, dialErr := dialer.DialContext(ctx, "tcp", c.ServerAddr) + if dialErr != nil { + return dialErr + } + tlsConn := tls.Client(rawConn, c.TLSConfig) + if handshakeErr := tlsConn.HandshakeContext(ctx); handshakeErr != nil { + rawConn.Close() + return handshakeErr + } + conn = tlsConn } else { - conn, err = net.DialTimeout("tcp", c.ServerAddr, dialTimeout) + conn, err = dialer.DialContext(ctx, "tcp", c.ServerAddr) } if err != nil { return err @@ -184,8 +240,6 @@ func (c *Client) connect() error { conn.Close() return fmt.Errorf("auth failed: %s", authResp.Message) } - - // 如果服务端分配了新 ID,则更新 if authResp.ClientID != "" && authResp.ClientID != c.ID { conn.Close() return fmt.Errorf("server returned unexpected client id: %s", authResp.ClientID) @@ -206,12 +260,31 @@ func (c *Client) connect() error { return nil } -// handleSession 处理会话 -func (c *Client) handleSession() { - defer c.session.Close() +func (c *Client) currentSession() *yamux.Session { + c.mu.RLock() + defer c.mu.RUnlock() + return c.session +} + +func (c *Client) handleSession(ctx context.Context) { + session := c.currentSession() + if session == nil { + return + } + + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + session.Close() + case <-done: + } + }() + defer close(done) + defer session.Close() for { - stream, err := c.session.Accept() + stream, err := session.Accept() if err != nil { return } @@ -219,7 +292,6 @@ func (c *Client) handleSession() { } } -// handleStream 处理流 func (c *Client) handleStream(stream net.Conn) { msg, err := protocol.ReadMessage(stream) if err != nil { @@ -254,10 +326,11 @@ func (c *Client) handleStream(stream net.Conn) { c.handleScreenshotRequest(stream, msg) case protocol.MsgTypeShellExecuteRequest: c.handleShellExecuteRequest(stream, msg) + default: + stream.Close() } } -// handleProxyConfig 处理代理配置 func (c *Client) handleProxyConfig(stream net.Conn, msg *protocol.Message) { defer stream.Close() @@ -276,12 +349,10 @@ func (c *Client) handleProxyConfig(stream net.Conn, msg *protocol.Message) { c.logf(" %s: %s:%d", r.Name, r.LocalIP, r.LocalPort) } - // 发送配置确认 ack := &protocol.Message{Type: protocol.MsgTypeProxyReady} protocol.WriteMessage(stream, ack) } -// handleNewProxy 处理新代理请求 func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) { var req protocol.NewProxyRequest if err := msg.ParsePayload(&req); err != nil { @@ -291,9 +362,9 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) { var rule *protocol.ProxyRule c.mu.RLock() - for _, r := range c.rules { - if r.RemotePort == req.RemotePort { - rule = &r + for i := range c.rules { + if c.rules[i].RemotePort == req.RemotePort { + rule = &c.rules[i] break } } @@ -314,13 +385,11 @@ func (c *Client) handleNewProxy(stream net.Conn, msg *protocol.Message) { relay.Relay(stream, localConn) } -// handleHeartbeat 处理心跳 func (c *Client) handleHeartbeat(stream net.Conn) { msg := &protocol.Message{Type: protocol.MsgTypeHeartbeatAck} protocol.WriteMessage(stream, msg) } -// handleProxyConnect 处理代理连接请求 (SOCKS5/HTTP) func (c *Client) handleProxyConnect(stream net.Conn, msg *protocol.Message) { defer stream.Close() @@ -330,7 +399,6 @@ func (c *Client) handleProxyConnect(stream net.Conn, msg *protocol.Message) { return } - // 连接目标地址 targetConn, err := net.DialTimeout("tcp", req.Target, dialTimeout) if err != nil { c.sendProxyResult(stream, false, err.Error()) @@ -338,23 +406,19 @@ func (c *Client) handleProxyConnect(stream net.Conn, msg *protocol.Message) { } defer targetConn.Close() - // 发送成功响应 if err := c.sendProxyResult(stream, true, ""); err != nil { return } - // 双向转发数据 relay.Relay(stream, targetConn) } -// sendProxyResult 发送代理连接结果 func (c *Client) sendProxyResult(stream net.Conn, success bool, message string) error { result := protocol.ProxyConnectResult{Success: success, Message: message} msg, _ := protocol.NewMessage(protocol.MsgTypeProxyResult, result) return protocol.WriteMessage(stream, msg) } -// handleUDPData 处理 UDP 数据 func (c *Client) handleUDPData(stream net.Conn, msg *protocol.Message) { defer stream.Close() @@ -363,13 +427,11 @@ func (c *Client) handleUDPData(stream net.Conn, msg *protocol.Message) { return } - // 查找对应的规则 rule := c.findRuleByPort(packet.RemotePort) if rule == nil { return } - // 连接本地 UDP 服务 target := fmt.Sprintf("%s:%d", rule.LocalIP, rule.LocalPort) conn, err := net.DialTimeout("udp", target, localDialTimeout) if err != nil { @@ -377,20 +439,17 @@ func (c *Client) handleUDPData(stream net.Conn, msg *protocol.Message) { } defer conn.Close() - // 发送数据到本地服务 conn.SetDeadline(time.Now().Add(udpTimeout)) if _, err := conn.Write(packet.Data); err != nil { return } - // 读取响应 buf := make([]byte, udpBufferSize) n, err := conn.Read(buf) if err != nil { return } - // 发送响应回服务端 respPacket := protocol.UDPPacket{ RemotePort: packet.RemotePort, ClientAddr: packet.ClientAddr, @@ -400,7 +459,6 @@ func (c *Client) handleUDPData(stream net.Conn, msg *protocol.Message) { protocol.WriteMessage(stream, respMsg) } -// findRuleByPort 根据端口查找规则 func (c *Client) findRuleByPort(port int) *protocol.ProxyRule { c.mu.RLock() defer c.mu.RUnlock() @@ -413,7 +471,6 @@ func (c *Client) findRuleByPort(port int) *protocol.ProxyRule { return nil } -// handleClientRestart 处理客户端重启请求 func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) { defer stream.Close() @@ -422,7 +479,6 @@ func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) { c.logf("Restart requested: %s", req.Reason) - // 发送响应 resp := protocol.ClientRestartResponse{ Success: true, Message: "restarting", @@ -430,17 +486,19 @@ func (c *Client) handleClientRestart(stream net.Conn, msg *protocol.Message) { respMsg, _ := protocol.NewMessage(protocol.MsgTypeClientRestart, resp) protocol.WriteMessage(stream, respMsg) - // 停止所有运行中的插件 - // 关闭会话(会触发重连) - if c.session != nil { - c.session.Close() + if session := c.currentSession(); session != nil { + session.Close() } } -// handleUpdateDownload 处理更新下载请求 func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) { defer stream.Close() + if !c.features.AllowSelfUpdate { + c.sendUpdateResult(stream, false, "self-update not supported on this platform") + return + } + var req protocol.UpdateDownloadRequest if err := msg.ParsePayload(&req); err != nil { c.logErrorf("Parse update request error: %v", err) @@ -450,7 +508,6 @@ func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) { c.logf("Update download requested: %s", req.DownloadURL) - // 异步执行更新 go func() { if err := c.performSelfUpdate(req.DownloadURL); err != nil { c.logErrorf("Update failed: %v", err) @@ -460,7 +517,6 @@ func (c *Client) handleUpdateDownload(stream net.Conn, msg *protocol.Message) { c.sendUpdateResult(stream, true, "update started") } -// sendUpdateResult 发送更新结果 func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string) { result := protocol.UpdateResultResponse{ Success: success, @@ -470,11 +526,13 @@ func (c *Client) sendUpdateResult(stream net.Conn, success bool, message string) protocol.WriteMessage(stream, msg) } -// performSelfUpdate 执行自更新 func (c *Client) performSelfUpdate(downloadURL string) error { + if runtime.GOOS == "android" { + return fmt.Errorf("self-update must be handled by the Android host app") + } + c.logf("Starting self-update from: %s", downloadURL) - // 获取当前可执行文件路径 currentPath, err := os.Executable() if err != nil { c.logErrorf("Update failed: cannot get executable path: %v", err) @@ -482,17 +540,12 @@ func (c *Client) performSelfUpdate(downloadURL string) error { } currentPath, _ = filepath.EvalSymlinks(currentPath) - // 预检查:验证是否有写权限(在下载前检查,避免浪费带宽) - // Windows 跳过预检查,因为 Windows 更新通过 batch 脚本以提升权限执行 - // 非 Windows:原始路径 → DataDir → 临时目录,逐级回退 fallbackDir := "" if runtime.GOOS != "windows" { if err := c.checkUpdatePermissions(currentPath); err != nil { - // 尝试 DataDir fallbackDir = c.DataDir testFile := filepath.Join(fallbackDir, ".gotunnel_update_test") if f, err := os.Create(testFile); err != nil { - // DataDir 也不可写,回退到临时目录 fallbackDir = os.TempDir() c.logf("DataDir not writable, falling back to temp directory: %s", fallbackDir) } else { @@ -503,7 +556,6 @@ func (c *Client) performSelfUpdate(downloadURL string) error { } } - // 使用共享的下载和解压逻辑 c.logf("Downloading update package...") binaryPath, cleanup, err := update.DownloadAndExtract(downloadURL, "client") if err != nil { @@ -512,12 +564,10 @@ func (c *Client) performSelfUpdate(downloadURL string) error { } defer cleanup() - // Windows 需要特殊处理 if runtime.GOOS == "windows" { return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token) } - // 确定目标路径 targetPath := currentPath if fallbackDir != "" { targetPath = filepath.Join(fallbackDir, filepath.Base(currentPath)) @@ -525,7 +575,6 @@ func (c *Client) performSelfUpdate(downloadURL string) error { } if fallbackDir == "" { - // 原地替换:备份 → 复制 → 清理 backupPath := currentPath + ".bak" c.logf("Backing up current binary...") @@ -549,7 +598,6 @@ func (c *Client) performSelfUpdate(downloadURL string) error { os.Remove(backupPath) } else { - // 回退路径:直接复制到回退目录 c.logf("Installing new binary to data directory...") if err := update.CopyFile(binaryPath, targetPath); err != nil { c.logErrorf("Update failed: cannot install new binary: %v", err) @@ -563,15 +611,11 @@ func (c *Client) performSelfUpdate(downloadURL string) error { } c.logf("Update completed successfully, restarting...") - - // 重启进程(从新路径启动) restartClientProcess(targetPath, c.ServerAddr, c.Token) return nil } -// checkUpdatePermissions 检查是否有更新权限 func (c *Client) checkUpdatePermissions(execPath string) error { - // 检查可执行文件所在目录是否可写 dir := filepath.Dir(execPath) testFile := filepath.Join(dir, ".gotunnel_update_test") @@ -583,7 +627,6 @@ func (c *Client) checkUpdatePermissions(execPath string) error { f.Close() os.Remove(testFile) - // 检查可执行文件本身是否可写 f, err = os.OpenFile(execPath, os.O_WRONLY, 0) if err != nil { c.logErrorf("No write permission to executable: %s", execPath) @@ -594,9 +637,7 @@ func (c *Client) checkUpdatePermissions(execPath string) error { return nil } -// performWindowsClientUpdate Windows 平台更新 func performWindowsClientUpdate(newFile, currentPath, serverAddr, token string) error { - // 创建批处理脚本 args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token) batchScript := fmt.Sprintf(`@echo off :: Check for admin rights, request UAC elevation if needed @@ -622,12 +663,10 @@ del "%%~f0" return fmt.Errorf("start batch: %w", err) } - // 退出当前进程 os.Exit(0) return nil } -// restartClientProcess 重启客户端进程 func restartClientProcess(path, serverAddr, token string) { args := []string{"-s", serverAddr, "-t", token} @@ -638,7 +677,6 @@ func restartClientProcess(path, serverAddr, token string) { os.Exit(0) } -// handleLogRequest 处理日志请求 func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) { if c.logger == nil { stream.Close() @@ -653,7 +691,6 @@ func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) { c.logger.Printf("Log request received: session=%s, follow=%v", req.SessionID, req.Follow) - // 发送历史日志 entries := c.logger.GetRecentLogs(req.Lines, req.Level) if len(entries) > 0 { data := protocol.LogData{ @@ -668,20 +705,16 @@ func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) { } } - // 如果不需要持续推送,关闭流 if !req.Follow { stream.Close() return } - // 订阅新日志 ch := c.logger.Subscribe(req.SessionID) defer c.logger.Unsubscribe(req.SessionID) defer stream.Close() - // 持续推送新日志 for entry := range ch { - // 应用级别过滤 if req.Level != "" && entry.Level != req.Level { continue } @@ -698,7 +731,6 @@ func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) { } } -// handleLogStop 处理停止日志流请求 func (c *Client) handleLogStop(stream net.Conn, msg *protocol.Message) { defer stream.Close() @@ -714,13 +746,20 @@ func (c *Client) handleLogStop(stream net.Conn, msg *protocol.Message) { c.logger.Unsubscribe(req.SessionID) } -// handleSystemStatsRequest 处理系统状态请求 func (c *Client) handleSystemStatsRequest(stream net.Conn, msg *protocol.Message) { defer stream.Close() + if !c.features.AllowSystemStats { + respMsg, _ := protocol.NewMessage(protocol.MsgTypeSystemStatsResponse, protocol.SystemStatsResponse{}) + protocol.WriteMessage(stream, respMsg) + return + } + stats, err := utils.GetSystemStats() if err != nil { log.Printf("Failed to get system stats: %v", err) + respMsg, _ := protocol.NewMessage(protocol.MsgTypeSystemStatsResponse, protocol.SystemStatsResponse{}) + protocol.WriteMessage(stream, respMsg) return } @@ -738,14 +777,19 @@ func (c *Client) handleSystemStatsRequest(stream net.Conn, msg *protocol.Message protocol.WriteMessage(stream, respMsg) } -// handleScreenshotRequest 处理截图请求 func (c *Client) handleScreenshotRequest(stream net.Conn, msg *protocol.Message) { defer stream.Close() var req protocol.ScreenshotRequest msg.ParsePayload(&req) - // 捕获截图 + if !c.features.AllowScreenshot { + resp := protocol.ScreenshotResponse{Error: "screenshot not supported on this platform"} + respMsg, _ := protocol.NewMessage(protocol.MsgTypeScreenshotResponse, resp) + protocol.WriteMessage(stream, respMsg) + return + } + data, width, height, err := utils.CaptureScreenshot(req.Quality) if err != nil { c.logErrorf("Screenshot capture failed: %v", err) @@ -755,9 +799,7 @@ func (c *Client) handleScreenshotRequest(stream net.Conn, msg *protocol.Message) return } - // 编码为 Base64 base64Data := base64.StdEncoding.EncodeToString(data) - resp := protocol.ScreenshotResponse{ Data: base64Data, Width: width, @@ -769,10 +811,16 @@ func (c *Client) handleScreenshotRequest(stream net.Conn, msg *protocol.Message) protocol.WriteMessage(stream, respMsg) } -// handleShellExecuteRequest 处理 Shell 执行请求 func (c *Client) handleShellExecuteRequest(stream net.Conn, msg *protocol.Message) { defer stream.Close() + if !c.features.AllowShellExecute { + resp := protocol.ShellExecuteResponse{ExitCode: -1, Error: "remote shell execution not supported on this platform"} + respMsg, _ := protocol.NewMessage(protocol.MsgTypeShellExecuteResponse, resp) + protocol.WriteMessage(stream, respMsg) + return + } + var req protocol.ShellExecuteRequest if err := msg.ParsePayload(&req); err != nil { resp := protocol.ShellExecuteResponse{Error: err.Error(), ExitCode: -1} @@ -781,7 +829,6 @@ func (c *Client) handleShellExecuteRequest(stream net.Conn, msg *protocol.Messag return } - // 设置默认超时 timeout := req.Timeout if timeout <= 0 { timeout = 30 @@ -789,7 +836,6 @@ func (c *Client) handleShellExecuteRequest(stream net.Conn, msg *protocol.Messag c.logf("Executing shell command: %s", req.Command) - // 根据操作系统选择 shell var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/C", req.Command) @@ -797,12 +843,10 @@ func (c *Client) handleShellExecuteRequest(stream net.Conn, msg *protocol.Messag cmd = exec.Command("sh", "-c", req.Command) } - // 设置超时上下文 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) - // 执行命令并获取输出 output, err := cmd.CombinedOutput() exitCode := 0 diff --git a/internal/client/tunnel/identity.go b/internal/client/tunnel/identity.go new file mode 100644 index 0000000..b269fe7 --- /dev/null +++ b/internal/client/tunnel/identity.go @@ -0,0 +1,90 @@ +package tunnel + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/google/uuid" +) + +const clientIDFileName = "client.id" + +func resolveDataDir(explicit string) string { + if explicit != "" { + return explicit + } + + if envDir := strings.TrimSpace(os.Getenv("GOTUNNEL_DATA_DIR")); envDir != "" { + return envDir + } + + if configDir, err := os.UserConfigDir(); err == nil && configDir != "" { + return filepath.Join(configDir, "gotunnel") + } + + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".gotunnel") + } + + if cwd, err := os.Getwd(); err == nil && cwd != "" { + return filepath.Join(cwd, ".gotunnel") + } + + return ".gotunnel" +} + +func resolveClientName(explicit string) string { + if explicit != "" { + return explicit + } + + if hostname, err := os.Hostname(); err == nil && hostname != "" { + return hostname + } + + if runtime.GOOS == "android" { + return "android-device" + } + + return "gotunnel-client" +} + +func resolveClientID(dataDir, explicit string) string { + if explicit != "" { + _ = persistClientID(dataDir, explicit) + return explicit + } + + if id := loadClientID(dataDir); id != "" { + return id + } + + if id := getMachineID(); id != "" { + _ = persistClientID(dataDir, id) + return id + } + + id := strings.ReplaceAll(uuid.NewString(), "-", "")[:16] + _ = persistClientID(dataDir, id) + return id +} + +func loadClientID(dataDir string) string { + data, err := os.ReadFile(filepath.Join(dataDir, clientIDFileName)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func persistClientID(dataDir, id string) error { + if id == "" { + return nil + } + if err := os.MkdirAll(dataDir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dataDir, clientIDFileName), []byte(id+"\n"), 0600) +} diff --git a/internal/client/tunnel/machine_id.go b/internal/client/tunnel/machine_id.go index d25bcb1..e38aaf9 100644 --- a/internal/client/tunnel/machine_id.go +++ b/internal/client/tunnel/machine_id.go @@ -14,11 +14,15 @@ import ( // getMachineID builds a stable fingerprint from multiple host identifiers // and hashes the combined result into the client ID we expose externally. func getMachineID() string { - return hashID(strings.Join(collectMachineIDParts(), "|")) + parts := collectMachineIDParts() + if len(parts) == 0 { + return "" + } + return hashID(strings.Join(parts, "|")) } func collectMachineIDParts() []string { - parts := []string{"os=" + runtime.GOOS, "arch=" + runtime.GOARCH} + parts := make([]string, 0, 6) if id := getSystemMachineID(); id != "" { parts = append(parts, "system="+id) @@ -36,6 +40,11 @@ func collectMachineIDParts() []string { parts = append(parts, "ifaces="+strings.Join(names, ",")) } + if len(parts) == 0 { + return nil + } + + parts = append(parts, "os="+runtime.GOOS, "arch="+runtime.GOARCH) return parts } @@ -47,6 +56,8 @@ func getSystemMachineID() string { return getDarwinMachineID() case "windows": return getWindowsMachineID() + case "android": + return "" default: return "" } diff --git a/internal/client/tunnel/options.go b/internal/client/tunnel/options.go new file mode 100644 index 0000000..969fdc9 --- /dev/null +++ b/internal/client/tunnel/options.go @@ -0,0 +1,41 @@ +package tunnel + +import "time" + +// PlatformFeatures controls which platform-specific capabilities the client may use. +type PlatformFeatures struct { + AllowSelfUpdate bool + AllowScreenshot bool + AllowShellExecute bool + AllowSystemStats bool +} + +// ClientOptions controls optional client runtime settings. +type ClientOptions struct { + DataDir string + ClientID string + ClientName string + Features *PlatformFeatures + ReconnectDelay time.Duration + ReconnectMaxDelay time.Duration +} + +// DefaultPlatformFeatures enables the desktop feature set. +func DefaultPlatformFeatures() PlatformFeatures { + return PlatformFeatures{ + AllowSelfUpdate: true, + AllowScreenshot: true, + AllowShellExecute: true, + AllowSystemStats: true, + } +} + +// MobilePlatformFeatures disables capabilities that are unsuitable for a mobile sandbox. +func MobilePlatformFeatures() PlatformFeatures { + return PlatformFeatures{ + AllowSelfUpdate: false, + AllowScreenshot: false, + AllowShellExecute: false, + AllowSystemStats: true, + } +} diff --git a/internal/server/router/handler/install.go b/internal/server/router/handler/install.go index 57a272e..170746f 100644 --- a/internal/server/router/handler/install.go +++ b/internal/server/router/handler/install.go @@ -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") +} diff --git a/internal/server/router/handler/install_scripts.go b/internal/server/router/handler/install_scripts.go new file mode 100644 index 0000000..dcd5e25 --- /dev/null +++ b/internal/server/router/handler/install_scripts.go @@ -0,0 +1,185 @@ +package handler + +const shellInstallScript = `#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bash install.sh -s -t -b + +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.' +} +` diff --git a/internal/server/router/router.go b/internal/server/router/router.go index b3453a5..ec4ffb2 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -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) } } diff --git a/mobile/gotunnelmobile/gotunnelmobile.go b/mobile/gotunnelmobile/gotunnelmobile.go new file mode 100644 index 0000000..cb988b2 --- /dev/null +++ b/mobile/gotunnelmobile/gotunnelmobile.go @@ -0,0 +1,144 @@ +package gotunnelmobile + +import ( + "context" + "strings" + "sync" + + "github.com/gotunnel/internal/client/tunnel" + "github.com/gotunnel/pkg/crypto" +) + +// Service exposes a gomobile-friendly wrapper around the Go tunnel client. +type Service struct { + mu sync.Mutex + + server string + token string + dataDir string + clientName string + clientID string + disableTLS bool + + client *tunnel.Client + cancel context.CancelFunc + running bool + status string + lastError string +} + +// NewService creates a mobile client service wrapper. +func NewService() *Service { + return &Service{status: "stopped"} +} + +// Configure stores the parameters used by Start. +func (s *Service) Configure(server, token, dataDir, clientName, clientID string, disableTLS bool) { + s.mu.Lock() + defer s.mu.Unlock() + + s.server = strings.TrimSpace(server) + s.token = strings.TrimSpace(token) + s.dataDir = strings.TrimSpace(dataDir) + s.clientName = strings.TrimSpace(clientName) + s.clientID = strings.TrimSpace(clientID) + s.disableTLS = disableTLS +} + +// Start launches the tunnel loop in the background. +func (s *Service) Start() string { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return "" + } + if s.server == "" || s.token == "" { + s.mu.Unlock() + return "server and token are required" + } + + features := tunnel.MobilePlatformFeatures() + client := tunnel.NewClientWithOptions(s.server, s.token, tunnel.ClientOptions{ + DataDir: s.dataDir, + ClientID: s.clientID, + ClientName: s.clientName, + Features: &features, + }) + if !s.disableTLS { + client.TLSEnabled = true + client.TLSConfig = crypto.ClientTLSConfig() + } + + ctx, cancel := context.WithCancel(context.Background()) + s.client = client + s.cancel = cancel + s.running = true + s.status = "running" + s.lastError = "" + s.mu.Unlock() + + go func() { + err := client.RunContext(ctx) + + s.mu.Lock() + defer s.mu.Unlock() + s.running = false + s.cancel = nil + s.client = nil + + if err != nil { + s.status = "error" + s.lastError = err.Error() + return + } + + if s.status != "stopped" { + s.status = "stopped" + } + }() + + return "" +} + +// Stop cancels the running tunnel loop. +func (s *Service) Stop() string { + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.running = false + s.status = "stopped" + s.mu.Unlock() + + if cancel != nil { + cancel() + } + + return "" +} + +// Restart restarts the service with the stored configuration. +func (s *Service) Restart() string { + s.Stop() + return s.Start() +} + +// IsRunning reports whether the tunnel loop is active. +func (s *Service) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +// Status returns a coarse-grained runtime status. +func (s *Service) Status() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.status +} + +// LastError returns the last background error string, if any. +func (s *Service) LastError() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastError +} diff --git a/pkg/utils/screenshot.go b/pkg/utils/screenshot_desktop.go similarity index 73% rename from pkg/utils/screenshot.go rename to pkg/utils/screenshot_desktop.go index ce95ae0..e974803 100644 --- a/pkg/utils/screenshot.go +++ b/pkg/utils/screenshot_desktop.go @@ -1,3 +1,5 @@ +//go:build windows || linux || darwin + package utils import ( @@ -9,34 +11,27 @@ import ( "github.com/kbinani/screenshot" ) -// CaptureScreenshot 捕获主屏幕截图 -// quality: JPEG 质量 (1-100), 0 使用默认值 (75) -// 返回: JPEG 图片数据, 宽度, 高度, 错误 +// CaptureScreenshot captures the primary display. func CaptureScreenshot(quality int) ([]byte, int, int, error) { - // 默认质量 if quality <= 0 || quality > 100 { quality = 75 } - // 获取活动显示器数量 n := screenshot.NumActiveDisplays() if n == 0 { return nil, 0, 0, fmt.Errorf("no active display found") } - // 获取主显示器边界 bounds := screenshot.GetDisplayBounds(0) if bounds.Empty() { return nil, 0, 0, fmt.Errorf("failed to get display bounds") } - // 捕获屏幕 img, err := screenshot.CaptureRect(bounds) if err != nil { return nil, 0, 0, fmt.Errorf("capture screen: %w", err) } - // 编码为 JPEG var buf bytes.Buffer opts := &jpeg.Options{Quality: quality} if err := jpeg.Encode(&buf, img, opts); err != nil { @@ -46,40 +41,30 @@ func CaptureScreenshot(quality int) ([]byte, int, int, error) { return buf.Bytes(), bounds.Dx(), bounds.Dy(), nil } -// CaptureAllScreens 捕获所有屏幕并拼接 -// quality: JPEG 质量 (1-100), 0 使用默认值 (75) -// 返回: JPEG 图片数据, 宽度, 高度, 错误 +// CaptureAllScreens captures all active displays and stitches them together. func CaptureAllScreens(quality int) ([]byte, int, int, error) { - // 默认质量 if quality <= 0 || quality > 100 { quality = 75 } - // 获取活动显示器数量 n := screenshot.NumActiveDisplays() if n == 0 { return nil, 0, 0, fmt.Errorf("no active display found") } - // 计算所有屏幕的总边界 var totalBounds image.Rectangle for i := 0; i < n; i++ { bounds := screenshot.GetDisplayBounds(i) totalBounds = totalBounds.Union(bounds) } - // 创建总画布 totalImg := image.NewRGBA(totalBounds) - - // 捕获每个屏幕并绘制到总画布 for i := 0; i < n; i++ { bounds := screenshot.GetDisplayBounds(i) img, err := screenshot.CaptureRect(bounds) if err != nil { - continue // 跳过失败的屏幕 + continue } - - // 绘制到总画布 for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { totalImg.Set(x, y, img.At(x-bounds.Min.X, y-bounds.Min.Y)) @@ -87,7 +72,6 @@ func CaptureAllScreens(quality int) ([]byte, int, int, error) { } } - // 编码为 JPEG var buf bytes.Buffer opts := &jpeg.Options{Quality: quality} if err := jpeg.Encode(&buf, totalImg, opts); err != nil { diff --git a/pkg/utils/screenshot_stub.go b/pkg/utils/screenshot_stub.go new file mode 100644 index 0000000..490ba78 --- /dev/null +++ b/pkg/utils/screenshot_stub.go @@ -0,0 +1,15 @@ +//go:build !windows && !linux && !darwin + +package utils + +import "fmt" + +// CaptureScreenshot is not available on this platform. +func CaptureScreenshot(quality int) ([]byte, int, int, error) { + return nil, 0, 0, fmt.Errorf("screenshot not supported on this platform") +} + +// CaptureAllScreens is not available on this platform. +func CaptureAllScreens(quality int) ([]byte, int, int, error) { + return nil, 0, 0, fmt.Errorf("screenshot not supported on this platform") +} diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 2ff0fc8..6089c53 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,6 +1,6 @@ # GoTunnel Build Script for Windows # Usage: .\build.ps1 [command] -# Commands: all, current, web, server, client, clean, help +# Commands: all, current, web, server, client, android, clean, help param( [Parameter(Position=0)] @@ -13,15 +13,11 @@ param( $ErrorActionPreference = "Stop" -# 项目根目录 -$RootDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -if (-not $RootDir) { - $RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -} +$RootDir = Split-Path -Parent $PSScriptRoot $BuildDir = Join-Path $RootDir "build" +$env:GOCACHE = if ($env:GOCACHE) { $env:GOCACHE } else { Join-Path $BuildDir ".gocache" } -# 版本信息 -$BuildTime = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") +$BuildTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") try { $GitCommit = (git -C $RootDir rev-parse --short HEAD 2>$null) if (-not $GitCommit) { $GitCommit = "unknown" } @@ -29,17 +25,14 @@ try { $GitCommit = "unknown" } -# 目标平台 -$Platforms = @( - @{OS="windows"; Arch="amd64"}, - @{OS="linux"; Arch="amd64"}, - @{OS="linux"; Arch="arm64"}, - @{OS="darwin"; Arch="amd64"}, - @{OS="darwin"; Arch="arm64"} -) +$DesktopPlatforms = @( + @{ OS = "windows"; Arch = "amd64" }, + @{ OS = "linux"; Arch = "amd64" }, + @{ OS = "linux"; Arch = "arm64" }, + @{ OS = "darwin"; Arch = "amd64" }, + @{ OS = "darwin"; Arch = "arm64" } ) -# 颜色输出函数 function Write-Info { param([string]$Message) Write-Host "[INFO] " -ForegroundColor Green -NoNewline @@ -58,7 +51,6 @@ function Write-Err { Write-Host $Message } -# 检查 UPX 是否可用 function Test-UPX { try { $null = Get-Command upx -ErrorAction Stop @@ -68,16 +60,17 @@ function Test-UPX { } } -# UPX 压缩二进制 function Compress-Binary { - param([string]$FilePath, [string]$OS) + param( + [string]$FilePath, + [string]$OS + ) if ($NoUPX) { return } if (-not (Test-UPX)) { Write-Warn "UPX not found, skipping compression" return } - # macOS 二进制不支持 UPX if ($OS -eq "darwin") { Write-Warn "Skipping UPX for macOS binary: $FilePath" return @@ -91,7 +84,6 @@ function Compress-Binary { } } -# 构建 Web UI function Build-Web { Write-Info "Building web UI..." @@ -111,7 +103,6 @@ function Build-Web { Pop-Location } - # 复制到 embed 目录 Write-Info "Copying dist to embed directory..." $DistSource = Join-Path $WebDir "dist" $DistDest = Join-Path $RootDir "internal\server\app\dist" @@ -124,51 +115,55 @@ function Build-Web { Write-Info "Web UI built successfully" } -# 构建单个二进制 +function Get-OutputName { + param( + [string]$Component, + [string]$OS + ) + + if ($OS -eq "windows") { + return "$Component.exe" + } + + return $Component +} + function Build-Binary { param( [string]$OS, [string]$Arch, - [string]$Component # server 或 client + [string]$Component ) - $OutputName = $Component - if ($OS -eq "windows") { - $OutputName = "$Component.exe" - } - $OutputDir = Join-Path $BuildDir "${OS}_${Arch}" if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } + $OutputName = Get-OutputName -Component $Component -OS $OS + $OutputPath = Join-Path $OutputDir $OutputName + $SourcePath = Join-Path $RootDir "cmd\$Component" + Write-Info "Building $Component for $OS/$Arch..." $env:GOOS = $OS $env:GOARCH = $Arch $env:CGO_ENABLED = "0" - $LDFlags = "-s -w -X 'github.com/gotunnel/pkg/version.Version=$Version' -X 'github.com/gotunnel/pkg/version.BuildTime=$BuildTime' -X 'github.com/gotunnel/pkg/version.GitCommit=$GitCommit'" - $OutputPath = Join-Path $OutputDir $OutputName - $SourcePath = Join-Path $RootDir "cmd\$Component" - - & go build -ldflags $LDFlags -o $OutputPath $SourcePath + $LdFlags = "-s -w -X 'main.Version=$Version' -X 'main.BuildTime=$BuildTime' -X 'main.GitCommit=$GitCommit'" + & go build -buildvcs=false -trimpath -ldflags $LdFlags -o $OutputPath $SourcePath if ($LASTEXITCODE -ne 0) { throw "Build failed for $Component $OS/$Arch" } - # UPX 压缩 Compress-Binary -FilePath $OutputPath -OS $OS - - # 显示文件大小 $FileSize = (Get-Item $OutputPath).Length / 1MB - Write-Info " -> $OutputPath ({0:N2} MB)" -f $FileSize + Write-Info (" -> {0} ({1:N2} MB)" -f $OutputPath, $FileSize) } -# 构建所有平台 function Build-All { - foreach ($Platform in $Platforms) { + foreach ($Platform in $DesktopPlatforms) { Build-Binary -OS $Platform.OS -Arch $Platform.Arch -Component "server" Build-Binary -OS $Platform.OS -Arch $Platform.Arch -Component "client" } @@ -184,7 +179,6 @@ function Build-All { } } -# 仅构建当前平台 function Build-Current { $OS = go env GOOS $Arch = go env GOARCH @@ -195,7 +189,51 @@ function Build-Current { Write-Info "Binaries built in $BuildDir\${OS}_${Arch}\" } -# 清理构建产物 +function Build-Android { + $OutputDir = Join-Path $BuildDir "android_arm64" + if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + } + + Write-Info "Building client for android/arm64..." + $env:GOOS = "android" + $env:GOARCH = "arm64" + $env:CGO_ENABLED = "0" + + $OutputPath = Join-Path $OutputDir "client" + $LdFlags = "-s -w -X 'main.Version=$Version' -X 'main.BuildTime=$BuildTime' -X 'main.GitCommit=$GitCommit'" + & go build -buildvcs=false -trimpath -ldflags $LdFlags -o $OutputPath (Join-Path $RootDir "cmd\client") + if ($LASTEXITCODE -ne 0) { + throw "Build failed for client android/arm64" + } + + if (Get-Command gomobile -ErrorAction SilentlyContinue) { + Write-Info "Building gomobile Android binding..." + & gomobile bind -target android/arm64 -o (Join-Path $OutputDir "gotunnelmobile.aar") "github.com/gotunnel/mobile/gotunnelmobile" + if ($LASTEXITCODE -ne 0) { + throw "gomobile bind failed" + } + } else { + Write-Warn "gomobile not found, skipping Android AAR build" + } + + $GradleWrapper = Join-Path $RootDir "android\gradlew.bat" + if (Test-Path $GradleWrapper) { + Write-Info "Building Android debug APK..." + Push-Location (Join-Path $RootDir "android") + try { + & $GradleWrapper assembleDebug + if ($LASTEXITCODE -ne 0) { + throw "Android APK build failed" + } + } finally { + Pop-Location + } + } else { + Write-Warn "android\\gradlew.bat not found, skipping APK build" + } +} + function Clean-Build { Write-Info "Cleaning build directory..." if (Test-Path $BuildDir) { @@ -204,7 +242,6 @@ function Clean-Build { Write-Info "Clean completed" } -# 显示帮助 function Show-Help { Write-Host @" GoTunnel Build Script for Windows @@ -212,11 +249,12 @@ GoTunnel Build Script for Windows Usage: .\build.ps1 [command] [-Version ] [-NoUPX] Commands: - all Build web UI + all platforms (default) + all Build web UI + all desktop platforms (default) current Build web UI + current platform only web Build web UI only server Build server for current platform client Build client for current platform + android Build android/arm64 client and optional Android artifacts clean Clean build directory help Show this help message @@ -228,18 +266,17 @@ Target platforms: - windows/amd64 - linux/amd64 - linux/arm64 - - darwin/amd64 (macOS Intel) - - darwin/arm64 (macOS Apple Silicon) + - darwin/amd64 + - darwin/arm64 Examples: - .\build.ps1 # Build all platforms + .\build.ps1 # Build all desktop platforms .\build.ps1 all -Version 1.0.0 # Build with version .\build.ps1 current # Build current platform only .\build.ps1 clean # Clean build directory "@ } -# 主函数 function Main { Push-Location $RootDir @@ -270,10 +307,13 @@ function Main { $Arch = go env GOARCH Build-Binary -OS $OS -Arch $Arch -Component "client" } + "android" { + Build-Android + } "clean" { Clean-Build } - { $_ -in "help", "--help", "-h", "/?" } { + { $_ -in @("help", "--help", "-h", "/?") } { Show-Help return } @@ -286,7 +326,6 @@ function Main { Write-Info "" Write-Info "Done!" - } finally { Pop-Location } diff --git a/scripts/build.sh b/scripts/build.sh index 99e9e9c..6f2ce07 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,27 +1,22 @@ #!/bin/bash -set -e +set -euo pipefail -# 项目根目录 ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" BUILD_DIR="$ROOT_DIR/build" +export GOCACHE="${GOCACHE:-$BUILD_DIR/.gocache}" -# 版本信息 VERSION="${VERSION:-dev}" -BUILD_TIME=$(date -u '+%Y-%m-%d %H:%M:%S') -GIT_COMMIT=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") - -# 默认目标平台 -DEFAULT_PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" - -# 是否启用 UPX 压缩 +BUILD_TIME="$(date -u '+%Y-%m-%d %H:%M:%S')" +GIT_COMMIT="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)" USE_UPX="${USE_UPX:-true}" -# 颜色输出 +DESKTOP_PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' -NC='\033[0m' # No Color +NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1" @@ -35,17 +30,14 @@ log_error() { echo -e "${RED}[ERROR]${NC} $1" } -# 检查 UPX 是否可用 check_upx() { - if command -v upx &> /dev/null; then - return 0 - fi - return 1 + command -v upx >/dev/null 2>&1 } -# UPX 压缩二进制 compress_binary() { local file=$1 + local os=$2 + if [ "$USE_UPX" != "true" ]; then return fi @@ -53,27 +45,27 @@ compress_binary() { log_warn "UPX not found, skipping compression" return fi - # macOS 二进制不支持 UPX - if [[ "$file" == *"darwin"* ]]; then + if [ "$os" = "darwin" ]; then log_warn "Skipping UPX for macOS binary: $file" return fi + log_info "Compressing $file with UPX..." upx -9 -q "$file" 2>/dev/null || log_warn "UPX compression failed for $file" } -# 构建 Web UI build_web() { log_info "Building web UI..." - cd "$ROOT_DIR/web" + pushd "$ROOT_DIR/web" >/dev/null + if [ ! -d "node_modules" ]; then log_info "Installing npm dependencies..." npm install fi npm run build - cd "$ROOT_DIR" - # 复制到 embed 目录 + popd >/dev/null + log_info "Copying dist to embed directory..." rm -rf "$ROOT_DIR/internal/server/app/dist" cp -r "$ROOT_DIR/web/dist" "$ROOT_DIR/internal/server/app/dist" @@ -81,85 +73,127 @@ build_web() { log_info "Web UI built successfully" } -# 构建单个二进制 +output_name() { + local component=$1 + local os=$2 + + if [ "$os" = "windows" ]; then + echo "${component}.exe" + else + echo "${component}" + fi +} + build_binary() { local os=$1 local arch=$2 - local component=$3 # server 或 client - - local output_name="${component}" - if [ "$os" = "windows" ]; then - output_name="${component}.exe" - fi + local component=$3 local output_dir="$BUILD_DIR/${os}_${arch}" - mkdir -p "$output_dir" + local output_file + output_file="$(output_name "$component" "$os")" + local output_path="$output_dir/$output_file" + mkdir -p "$output_dir" log_info "Building $component for $os/$arch..." - GOOS=$os GOARCH=$arch go build \ + GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build \ + -buildvcs=false \ + -trimpath \ -ldflags "-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME' -X 'main.GitCommit=$GIT_COMMIT'" \ - -o "$output_dir/$output_name" \ + -o "$output_path" \ "$ROOT_DIR/cmd/$component" - # UPX 压缩 - compress_binary "$output_dir/$output_name" + compress_binary "$output_path" "$os" + log_info " -> $output_path" } -# 构建所有平台 build_all() { - local platforms="${1:-$DEFAULT_PLATFORMS}" + local platforms="${1:-$DESKTOP_PLATFORMS}" + local platform os arch for platform in $platforms; do - local os="${platform%/*}" - local arch="${platform#*/}" - build_binary "$os" "$arch" "server" - build_binary "$os" "$arch" "client" + os="${platform%/*}" + arch="${platform#*/}" + build_binary "$os" "$arch" server + build_binary "$os" "$arch" client done } -# 仅构建当前平台 build_current() { - local os=$(go env GOOS) - local arch=$(go env GOARCH) + local os + local arch - build_binary "$os" "$arch" "server" - build_binary "$os" "$arch" "client" + os="$(go env GOOS)" + arch="$(go env GOARCH)" + + build_binary "$os" "$arch" server + build_binary "$os" "$arch" client log_info "Binaries built in $BUILD_DIR/${os}_${arch}/" } -# 清理构建产物 +build_android() { + local output_dir="$BUILD_DIR/android_arm64" + + mkdir -p "$output_dir" + log_info "Building client for android/arm64..." + GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build \ + -buildvcs=false \ + -trimpath \ + -ldflags "-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME' -X 'main.GitCommit=$GIT_COMMIT'" \ + -o "$output_dir/client" \ + "$ROOT_DIR/cmd/client" + + if command -v gomobile >/dev/null 2>&1; then + log_info "Building gomobile Android binding..." + gomobile bind -target=android/arm64 -o "$output_dir/gotunnelmobile.aar" github.com/gotunnel/mobile/gotunnelmobile + else + log_warn "gomobile not found, skipping Android AAR build" + fi + + if [ -d "$ROOT_DIR/android" ]; then + if [ -x "$ROOT_DIR/android/gradlew" ]; then + log_info "Building Android debug APK..." + (cd "$ROOT_DIR/android" && ./gradlew assembleDebug) + else + log_warn "android/gradlew not found, skipping APK build" + fi + else + log_warn "Android host project not found, skipping APK build" + fi +} + clean() { log_info "Cleaning build directory..." rm -rf "$BUILD_DIR" log_info "Clean completed" } -# 显示帮助 show_help() { - echo "Usage: $0 [command] [options]" - echo "" - echo "Commands:" - echo " all Build for all platforms (default: $DEFAULT_PLATFORMS)" - echo " current Build for current platform only" - echo " web Build web UI only" - echo " server Build server for current platform" - echo " client Build client for current platform" - echo " clean Clean build directory" - echo " help Show this help message" - echo "" - echo "Environment variables:" - echo " VERSION Set version string (default: dev)" - echo " USE_UPX Enable UPX compression (default: true)" - echo "" - echo "Examples:" - echo " $0 current # Build for current platform" - echo " $0 all # Build for all platforms" - echo " VERSION=1.0.0 $0 all # Build with version" + cat <<'EOF' +Usage: build.sh [command] [options] + +Commands: + all Build web UI + all desktop platforms (default) + current Build web UI + current platform only + web Build web UI only + server Build server for current platform + client Build client for current platform + android Build android/arm64 client and optional Android artifacts + clean Clean build directory + help Show this help message + +Environment variables: + VERSION Set version string (default: dev) + USE_UPX Enable UPX compression (default: true) + +Examples: + ./scripts/build.sh current + VERSION=1.0.0 ./scripts/build.sh all +EOF } -# 主函数 main() { cd "$ROOT_DIR" @@ -176,10 +210,13 @@ main() { build_web ;; server) - build_binary "$(go env GOOS)" "$(go env GOARCH)" "server" + build_binary "$(go env GOOS)" "$(go env GOARCH)" server ;; client) - build_binary "$(go env GOOS)" "$(go env GOARCH)" "client" + build_binary "$(go env GOOS)" "$(go env GOARCH)" client + ;; + android) + build_android ;; clean) clean diff --git a/web/src/views/ClientsView.vue b/web/src/views/ClientsView.vue index c12018b..c697dbc 100644 --- a/web/src/views/ClientsView.vue +++ b/web/src/views/ClientsView.vue @@ -17,12 +17,12 @@ const showInstallModal = ref(false) const installData = ref(null) const generatingInstall = ref(false) const search = ref('') -const installScriptUrl = 'https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.sh' -const installPs1Url = 'https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.ps1' const quoteShellArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'` +const quotePowerShellSingle = (value: string) => value.replace(/'/g, "''") const resolveTunnelHost = () => window.location.hostname || 'localhost' +const resolveWebBaseUrl = () => window.location.origin || 'http://localhost:7500' const formatServerAddr = (host: string, port: number) => { const normalizedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host @@ -30,12 +30,18 @@ const formatServerAddr = (host: string, port: number) => { } const buildInstallCommands = (data: InstallCommandResponse) => { + const webBaseUrl = resolveWebBaseUrl() const serverAddr = formatServerAddr(resolveTunnelHost(), data.tunnel_port) + const installScriptUrl = `${webBaseUrl}/install.sh` + const installPs1Url = `${webBaseUrl}/install.ps1` + const psServerAddr = quotePowerShellSingle(serverAddr) + const psToken = quotePowerShellSingle(data.token) + const psBaseUrl = quotePowerShellSingle(webBaseUrl) return { - linux: `bash <(curl -fsSL ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)}`, - macos: `bash <(curl -fsSL ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)}`, - windows: `powershell -c \"irm ${installPs1Url} | iex; Install-GoTunnel -Server '${serverAddr}' -Token '${data.token}'\"`, + linux: `bash <(curl -fsSL -H "X-GoTunnel-Install-Token: ${data.token}" ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)} -b ${quoteShellArg(webBaseUrl)}`, + macos: `bash <(curl -fsSL -H "X-GoTunnel-Install-Token: ${data.token}" ${installScriptUrl}) -s ${quoteShellArg(serverAddr)} -t ${quoteShellArg(data.token)} -b ${quoteShellArg(webBaseUrl)}`, + windows: `powershell -c \"irm ${installPs1Url} -Headers @{ 'X-GoTunnel-Install-Token' = '${psToken}' } | iex; Install-GoTunnel -Server '${psServerAddr}' -Token '${psToken}' -BaseUrl '${psBaseUrl}'\"`, } }