Add Android client support and unify cross-platform builds

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

View File

@@ -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

View File

@@ -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

View File

@@ -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/<os>_<arch>/"
@echo " build-all-platforms - Build Windows/Linux/macOS server/client binaries"
@echo " build-android - Android build placeholder"
@echo " clean - Remove build artifacts"

5
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.gradle
/build
/app/build
/local.properties
/captures

27
android/README.md Normal file
View File

@@ -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.

View File

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

2
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Placeholder rules for the Android host shell.
# Add Go bridge / native binding rules here when the core integration is introduced.

View File

@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".GoTunnelApp"
android:allowBackup="true"
android:icon="@drawable/ic_gotunnel_app"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_gotunnel_app"
android:supportsRtl="true"
android:theme="@style/Theme.GoTunnel">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.TunnelService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -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()),
)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0EA5A8"
android:pathData="M54,10a44,44 0 1,0 0,88a44,44 0 1,0 0,-88z" />
<path
android:fillColor="#0F172A"
android:pathData="M39,35h30c4.4,0 8,3.6 8,8v22c0,4.4 -3.6,8 -8,8H39c-4.4,0 -8,-3.6 -8,-8V43c0,-4.4 3.6,-8 8,-8z" />
<path
android:fillColor="#E5E7EB"
android:pathData="M44,43h20c2.2,0 4,1.8 4,4v14c0,2.2 -1.8,4 -4,4H44c-2.2,0 -4,-1.8 -4,-4V47c0,-2.2 1.8,-4 4,-4z" />
<path
android:fillColor="#38BDF8"
android:pathData="M52,49l10,6l-10,6z" />
</vector>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20z" />
<path
android:fillColor="#0EA5A8"
android:pathData="M8,8h8c1.1,0 2,0.9 2,2v4c0,1.1 -0.9,2 -2,2H8c-1.1,0 -2,-0.9 -2,-2v-4c0,-1.1 0.9,-2 2,-2z" />
<path
android:fillColor="#0F172A"
android:pathData="M10,10l4,2l-4,2z" />
</vector>

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="?attr/textAppearanceHeadlineMedium" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/main_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<EditText
android:id="@+id/serverAddressInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:hint="@string/server_address_hint"
android:inputType="textUri"
android:padding="12dp" />
<EditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/token_hint"
android:inputType="textPassword"
android:padding="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoStartSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/auto_start_label" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoReconnectSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/auto_reconnect_label" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/useTlsSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/use_tls_label" />
<TextView
android:id="@+id/stateHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/state_hint_stopped"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<TextView
android:id="@+id/stateValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<TextView
android:id="@+id/stateMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodySmall" />
<com.google.android.material.button.MaterialButton
android:id="@+id/batteryButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/battery_button" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/save_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="@string/stop_button" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,7 @@
<resources>
<color name="gotunnel_background">#0F172A</color>
<color name="gotunnel_surface">#111827</color>
<color name="gotunnel_primary">#0EA5A8</color>
<color name="gotunnel_secondary">#38BDF8</color>
<color name="gotunnel_text">#E5E7EB</color>
</resources>

View File

@@ -0,0 +1,36 @@
<resources>
<string name="app_name">GoTunnel</string>
<string name="main_subtitle">Android host shell for the GoTunnel client core.</string>
<string name="server_address_hint">Server address, for example 10.0.2.2:7000</string>
<string name="token_hint">Authentication token</string>
<string name="auto_start_label">Start on boot</string>
<string name="auto_reconnect_label">Restart on network restore</string>
<string name="use_tls_label">Use TLS</string>
<string name="save_button">Save</string>
<string name="start_button">Start</string>
<string name="stop_button">Stop</string>
<string name="battery_button">Battery optimization</string>
<string name="config_saved">Configuration saved</string>
<string name="service_start_requested">Service start requested</string>
<string name="service_stop_requested">Service stop requested</string>
<string name="battery_optimization_already_disabled">Battery optimization is already disabled for GoTunnel.</string>
<string name="notification_permission_denied">Notification permission was denied. Foreground service notifications may be limited.</string>
<string name="state_never_updated">Never updated</string>
<string name="state_no_detail">No detail</string>
<string name="state_format">State: %1$s\nDetail: %2$s</string>
<string name="state_meta_format">Updated: %1$s</string>
<string name="state_hint_stopped">The foreground service is idle until a configuration is saved and started.</string>
<string name="state_hint_starting">The host shell is preparing a tunnel session.</string>
<string name="state_hint_running">The host shell is ready. The native Go tunnel core can be attached here later.</string>
<string name="state_hint_reconnecting">The host shell is waiting for connectivity to return.</string>
<string name="state_hint_error">The last session reported an error. Check the configuration and service notification.</string>
<string name="notification_channel_name">GoTunnel service</string>
<string name="notification_channel_description">Keeps the Android host shell running in the foreground</string>
<string name="notification_title">GoTunnel - %1$s</string>
<string name="notification_text_configured">Configured for %1$s</string>
<string name="notification_text_unconfigured">No server configured yet</string>
<string name="notification_action_restart">Restart</string>
<string name="notification_action_stop">Stop</string>
<string name="config_missing">Server address and token are required</string>
<string name="network_lost">Network lost</string>
</resources>

View File

@@ -0,0 +1,13 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.GoTunnel" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/gotunnel_primary</item>
<item name="colorSecondary">@color/gotunnel_secondary</item>
<item name="colorBackground">@color/gotunnel_background</item>
<item name="colorSurface">@color/gotunnel_surface</item>
<item name="android:textColorPrimary">@color/gotunnel_text</item>
<item name="android:textColorSecondary">@color/gotunnel_text</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/gotunnel_background</item>
<item name="android:navigationBarColor" tools:targetApi="l">@color/gotunnel_background</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View File

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

View File

@@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

View File

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

View File

@@ -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 <server:port> -t <token>]")
}
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)
}
}

View File

@@ -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 {

View File

@@ -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
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 // 数据目录
DataDir string
features PlatformFeatures
reconnectDelay time.Duration
reconnectMaxDelay time.Duration
session *yamux.Session
rules []protocol.ProxyRule
mu sync.RWMutex
logger *Logger // 日志收集器
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,
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

View File

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

View File

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

View File

@@ -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,
}
}

View File

@@ -3,7 +3,10 @@ package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -14,6 +17,11 @@ type InstallHandler struct {
app AppInterface
}
const (
installTokenHeader = "X-GoTunnel-Install-Token"
installTokenTTL = 3600
)
func NewInstallHandler(app AppInterface) *InstallHandler {
return &InstallHandler{app: app}
}
@@ -61,7 +69,115 @@ func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
c.JSON(http.StatusOK, InstallCommandResponse{
Token: token,
ExpiresAt: now + 3600,
ExpiresAt: now + installTokenTTL,
TunnelPort: h.app.GetServer().GetBindPort(),
})
}
func (h *InstallHandler) ServeShellScript(c *gin.Context) {
if !h.validateInstallToken(c) {
return
}
applyInstallSecurityHeaders(c)
c.Header("Content-Type", "text/x-shellscript; charset=utf-8")
c.String(http.StatusOK, shellInstallScript)
}
func (h *InstallHandler) ServePowerShellScript(c *gin.Context) {
if !h.validateInstallToken(c) {
return
}
applyInstallSecurityHeaders(c)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, powerShellInstallScript)
}
func (h *InstallHandler) DownloadClient(c *gin.Context) {
if !h.validateInstallToken(c) {
return
}
osName := c.Query("os")
arch := c.Query("arch")
updateInfo, err := checkClientUpdateForPlatform(osName, arch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve client package"})
return
}
if updateInfo.DownloadURL == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no client package found for this platform"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, updateInfo.DownloadURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create download request"})
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download client package"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("upstream returned %s", resp.Status)})
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
applyInstallSecurityHeaders(c)
c.Header("Content-Type", contentType)
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
c.Header("Content-Length", contentLength)
}
if updateInfo.AssetName != "" {
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, updateInfo.AssetName))
}
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, resp.Body)
}
func (h *InstallHandler) validateInstallToken(c *gin.Context) bool {
token := strings.TrimSpace(c.GetHeader(installTokenHeader))
if token == "" {
c.AbortWithStatus(http.StatusNotFound)
return false
}
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
if !ok {
c.AbortWithStatus(http.StatusNotFound)
return false
}
installToken, err := store.GetInstallToken(token)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return false
}
if installToken.Used || time.Now().Unix()-installToken.CreatedAt >= installTokenTTL {
c.AbortWithStatus(http.StatusNotFound)
return false
}
return true
}
func applyInstallSecurityHeaders(c *gin.Context) {
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Robots-Tag", "noindex, nofollow, noarchive")
}

View File

@@ -0,0 +1,185 @@
package handler
const shellInstallScript = `#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: bash install.sh -s <server:port> -t <token> -b <web-base-url>
Options:
-s Tunnel server address, for example 10.0.0.2:7000
-t One-time install token generated by the server
-b Web console base URL, for example https://example.com:7500
EOF
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
detect_os() {
case "$(uname -s)" in
Linux) echo "linux" ;;
Darwin) echo "darwin" ;;
*)
echo "unsupported operating system: $(uname -s)" >&2
exit 1
;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
i386|i686) echo "386" ;;
armv7l|armv6l|arm) echo "arm" ;;
*)
echo "unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac
}
SERVER_ADDR=""
INSTALL_TOKEN=""
BASE_URL=""
while getopts ":s:t:b:h" opt; do
case "$opt" in
s) SERVER_ADDR="$OPTARG" ;;
t) INSTALL_TOKEN="$OPTARG" ;;
b) BASE_URL="$OPTARG" ;;
h)
usage
exit 0
;;
:)
echo "option -$OPTARG requires a value" >&2
usage
exit 1
;;
\?)
echo "unknown option: -$OPTARG" >&2
usage
exit 1
;;
esac
done
if [[ -z "$SERVER_ADDR" || -z "$INSTALL_TOKEN" || -z "$BASE_URL" ]]; then
usage
exit 1
fi
require_cmd curl
require_cmd tar
require_cmd mktemp
OS_NAME="$(detect_os)"
ARCH_NAME="$(detect_arch)"
BASE_URL="${BASE_URL%/}"
INSTALL_ROOT="${HOME:-$(pwd)}/.gotunnel"
BIN_DIR="$INSTALL_ROOT/bin"
TARGET_BIN="$BIN_DIR/gotunnel-client"
LOG_FILE="$INSTALL_ROOT/client.log"
PID_FILE="$INSTALL_ROOT/client.pid"
TMP_DIR="$(mktemp -d)"
ARCHIVE_PATH="$TMP_DIR/gotunnel-client.tar.gz"
DOWNLOAD_URL="$BASE_URL/install/client?os=$OS_NAME&arch=$ARCH_NAME"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
echo "Downloading GoTunnel client from $DOWNLOAD_URL"
curl -fsSL -H "X-GoTunnel-Install-Token: $INSTALL_TOKEN" "$DOWNLOAD_URL" -o "$ARCHIVE_PATH"
tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
EXTRACTED_BIN="$(find "$TMP_DIR" -type f -name 'gotunnel-client*' ! -name '*.tar.gz' ! -name '*.zip' | head -n 1)"
if [[ -z "$EXTRACTED_BIN" ]]; then
echo "failed to find extracted client binary" >&2
exit 1
fi
cp "$EXTRACTED_BIN" "$TARGET_BIN"
chmod 0755 "$TARGET_BIN"
if [[ -f "$PID_FILE" ]]; then
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$OLD_PID" ]]; then
kill "$OLD_PID" >/dev/null 2>&1 || true
fi
fi
nohup "$TARGET_BIN" -s "$SERVER_ADDR" -t "$INSTALL_TOKEN" >>"$LOG_FILE" 2>&1 &
NEW_PID=$!
echo "$NEW_PID" >"$PID_FILE"
echo "GoTunnel client installed to $TARGET_BIN"
echo "Client started in background with PID $NEW_PID"
echo "Logs: $LOG_FILE"
`
const powerShellInstallScript = `function Get-GoTunnelArch {
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) {
'x64' { return 'amd64' }
'arm64' { return 'arm64' }
'x86' { return '386' }
default { throw "Unsupported architecture: $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" }
}
}
function Install-GoTunnel {
param(
[Parameter(Mandatory = $true)][string]$Server,
[Parameter(Mandatory = $true)][string]$Token,
[Parameter(Mandatory = $true)][string]$BaseUrl
)
$BaseUrl = $BaseUrl.TrimEnd('/')
$Arch = Get-GoTunnelArch
$InstallRoot = Join-Path $env:LOCALAPPDATA 'GoTunnel'
$ExtractDir = Join-Path $InstallRoot 'extract'
$ArchivePath = Join-Path $InstallRoot 'gotunnel-client.zip'
$TargetPath = Join-Path $InstallRoot 'gotunnel-client.exe'
$DownloadUrl = "$BaseUrl/install/client?os=windows&arch=$Arch"
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
Write-Host "Downloading GoTunnel client from $DownloadUrl"
$Headers = @{ 'X-GoTunnel-Install-Token' = $Token }
Invoke-WebRequest -Uri $DownloadUrl -Headers $Headers -OutFile $ArchivePath -MaximumRedirection 5
if (Test-Path $ExtractDir) {
Remove-Item -Path $ExtractDir -Recurse -Force
}
Expand-Archive -Path $ArchivePath -DestinationPath $ExtractDir -Force
$Binary = Get-ChildItem -Path $ExtractDir -Recurse -File |
Where-Object { $_.Name -eq 'gotunnel-client.exe' } |
Select-Object -First 1
if (-not $Binary) {
throw 'Failed to find extracted client binary.'
}
Copy-Item -Path $Binary.FullName -Destination $TargetPath -Force
Get-Process |
Where-Object { $_.Path -eq $TargetPath } |
Stop-Process -Force -ErrorAction SilentlyContinue
Start-Process -FilePath $TargetPath -ArgumentList @('-s', $Server, '-t', $Token) -WindowStyle Hidden
Write-Host "GoTunnel client installed to $TargetPath"
Write-Host 'Client started in background.'
}
`

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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 = @(
$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 <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
}

View File

@@ -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

View File

@@ -17,12 +17,12 @@ const showInstallModal = ref(false)
const installData = ref<InstallCommandResponse | null>(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}'\"`,
}
}