16 Commits
v1.2.0 ... main

Author SHA1 Message Date
b161b7ac79 feat: Add support for Windows ARM64 architecture in build workflows and scripts 2026-03-22 22:46:27 +08:00
0a932211f1 fix: Update references from Gitea to GitHub for versioning and self-update
Some checks failed
Build Multi-Platform Binaries / build-android-apk (push) Failing after 6s
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m37s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m28s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m0s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m52s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 2m2s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m18s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m33s
2026-03-22 22:37:27 +08:00
cdc1dd60d1 fix: Correct binding reference for status metadata in MainActivity 2026-03-22 22:10:01 +08:00
21621b15f4 feat: Add SettingsActivity for configuration management and logging
- Introduced SettingsActivity to manage server address and token settings.
- Integrated LogStore for logging status updates and messages.
- Updated MainActivity to navigate to SettingsActivity and handle configuration.
- Modified UI in activity_main.xml and activity_settings.xml for improved user experience.
- Adjusted color scheme in colors.xml for better visibility and aesthetics.
- Enhanced string resources in strings.xml for clarity and consistency.
- Refactored notification handling in NotificationHelper.kt to use status labels.
- Updated TunnelService to log status changes and messages.
2026-03-22 22:04:14 +08:00
4b09fe817d Update theme colors to use android namespace for background attributes 2026-03-22 21:35:57 +08:00
f644d3764a Add Android build workflow and setup for APK generation
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 39s
Build Multi-Platform Binaries / build-android-apk (push) Failing after 3m40s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m27s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 4m38s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m21s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
2026-03-22 21:30:12 +08:00
4210ab7675 Add Android client support and unify cross-platform builds 2026-03-22 21:25:09 +08:00
6558d1acdb Refactor install command generation and update response structure
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
2026-03-19 20:49:23 +08:00
Flik
8901581d0c Merge pull request #2 from Flikify/codex/build-github-workflow-for-new-changes
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
CI overhaul and major frontend UI refactor (new components, layouts, and release workflow)
2026-03-19 20:22:23 +08:00
Flik
c6bab83e24 Refactor web console UI 2026-03-19 20:21:05 +08:00
Flik
9cd74b43d0 Merge pull request #1 from Flikify/codex/build-github-workflow-for-new-changes
Add GitHub Actions CI and Release workflows
2026-03-19 20:12:13 +08:00
Flik
2d8cc8ebe5 Add GitHub CI and release workflows 2026-03-19 20:08:00 +08:00
58bb324d82 Merge remote-tracking branch 'refs/remotes/github-temp/main' 2026-03-19 19:47:35 +08:00
bed78a36d0 Remove plugin store config from server settings
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 28s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m24s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m57s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m9s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m24s
2026-03-19 19:42:03 +08:00
e4999abf47 Remove manual client ID and TLS CLI options
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 34s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m46s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m31s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m58s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
2026-03-19 19:32:57 +08:00
Flik
6496d56e0e 1 2026-01-22 14:11:56 +08:00
67 changed files with 4660 additions and 3078 deletions

View File

@@ -64,6 +64,20 @@ jobs:
cache: 'npm'
cache-dependency-path: web/package-lock.json
- 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 frontend
run: |
cd web
@@ -75,6 +89,17 @@ jobs:
echo "Frontend build completed"
ls -la internal/server/app/dist/
- name: Build Android release APK
run: |
cd android
gradle --no-daemon assembleRelease
cd ..
mkdir -p dist
cp android/app/build/outputs/apk/release/app-release-unsigned.apk \
"dist/gotunnel-android-${{ inputs.version }}-release-unsigned.apk"
echo "Android APK build completed"
ls -lah dist/gotunnel-android-${{ inputs.version }}-release-unsigned.apk
- name: Build all platforms
run: |
mkdir -p dist
@@ -182,6 +207,7 @@ jobs:
echo "- **Linux (amd64/arm64)**: \`.tar.gz\` files" >> release_notes.md
echo "- **macOS (amd64/arm64)**: \`.tar.gz\` files" >> release_notes.md
echo "- **Windows (amd64)**: \`.zip\` files" >> release_notes.md
echo "- **Android**: unsigned \`.apk\` file" >> release_notes.md
echo "" >> release_notes.md
echo "Verify downloads with \`SHA256SUMS\`" >> release_notes.md
fi
@@ -197,6 +223,7 @@ jobs:
files: |-
dist/*.tar.gz
dist/*.zip
dist/*.apk
dist/SHA256SUMS
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
@@ -205,4 +232,4 @@ jobs:
- name: Release created successfully
run: |
echo "✅ Release ${{ inputs.version }} created successfully!"
echo "🔗 View it at: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.version }}"
echo "🔗 View it at: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.version }}"

View File

@@ -40,6 +40,37 @@ jobs:
path: web/dist
retention-days: 1
build-android-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 Android debug APK
working-directory: android
run: gradle --no-daemon assembleDebug
- name: Upload Android APK
uses: actions/upload-artifact@v3
with:
name: gotunnel-android-debug-apk
path: android/app/build/outputs/apk/debug/app-debug.apk
retention-days: 7
build-binaries:
needs: build-frontend
runs-on: golang
@@ -142,4 +173,4 @@ jobs:
with:
name: ${{ env.CURRENT_FILENAME }}
path: ${{ env.CURRENT_FILENAME }}
retention-days: 7
retention-days: 7

152
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: CI Build
on:
push:
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install frontend dependencies
working-directory: web
run: npm ci
- name: Build frontend
working-directory: web
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
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
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
goos: linux
goarch: amd64
- runner: ubuntu-latest
goos: linux
goarch: arm64
- runner: windows-latest
goos: windows
goarch: amd64
- runner: windows-latest
goos: windows
goarch: arm64
- runner: macos-latest
goos: darwin
goarch: amd64
- runner: macos-latest
goos: darwin
goarch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: internal/server/app/dist
- name: Download Go modules
run: go mod download
- name: Build server and client
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p build/${GOOS}_${GOARCH}
EXT=""
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
fi
BUILD_TIME="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
GIT_COMMIT="${GITHUB_SHA::7}"
VERSION="${GITHUB_REF_NAME}"
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
VERSION="${GITHUB_SHA::7}"
fi
LDFLAGS="-s -w -X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}' -X 'main.GitCommit=${GIT_COMMIT}'"
go build -trimpath -ldflags "$LDFLAGS" -o "build/${GOOS}_${GOARCH}/server${EXT}" ./cmd/server
go build -trimpath -ldflags "$LDFLAGS" -o "build/${GOOS}_${GOARCH}/client${EXT}" ./cmd/client
- name: Upload binaries artifact
uses: actions/upload-artifact@v4
with:
name: gotunnel-${{ matrix.goos }}-${{ matrix.goarch }}
path: build/${{ matrix.goos }}_${{ matrix.goarch }}/
retention-days: 7

278
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,278 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish, e.g. v1.2.3'
required: true
type: string
permissions:
contents: write
concurrency:
group: release-${{ github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
frontend:
name: Build frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install frontend dependencies
working-directory: web
run: npm ci
- name: Build frontend
working-directory: web
run: npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
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
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- component: server
goos: linux
goarch: amd64
archive_ext: tar.gz
- component: client
goos: linux
goarch: amd64
archive_ext: tar.gz
- component: server
goos: linux
goarch: arm64
archive_ext: tar.gz
- component: client
goos: linux
goarch: arm64
archive_ext: tar.gz
- component: server
goos: darwin
goarch: amd64
archive_ext: tar.gz
- component: client
goos: darwin
goarch: amd64
archive_ext: tar.gz
- component: server
goos: darwin
goarch: arm64
archive_ext: tar.gz
- component: client
goos: darwin
goarch: arm64
archive_ext: tar.gz
- component: server
goos: windows
goarch: amd64
archive_ext: zip
- component: client
goos: windows
goarch: amd64
archive_ext: zip
- component: server
goos: windows
goarch: arm64
archive_ext: zip
- component: client
goos: windows
goarch: arm64
archive_ext: zip
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: internal/server/app/dist
- name: Download Go modules
run: go mod download
- 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"
echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
- name: Build binary
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
VERSION: ${{ steps.meta.outputs.tag }}
GIT_COMMIT: ${{ steps.meta.outputs.commit }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
run: |
mkdir -p dist/package
EXT=""
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
fi
OUTPUT_NAME="${{ matrix.component }}${EXT}"
LDFLAGS="-s -w -X 'main.Version=${VERSION}' -X 'main.BuildTime=${BUILD_TIME}' -X 'main.GitCommit=${GIT_COMMIT}'"
go build -trimpath -ldflags "$LDFLAGS" -o "dist/package/${OUTPUT_NAME}" ./cmd/${{ matrix.component }}
- name: Create release archive
id: package
shell: bash
run: |
TAG="${{ steps.meta.outputs.tag }}"
ARCHIVE="gotunnel-${{ matrix.component }}-${TAG}-${{ matrix.goos }}-${{ matrix.goarch }}.${{ matrix.archive_ext }}"
mkdir -p dist/out
if [ "${{ matrix.archive_ext }}" = "zip" ]; then
(cd dist/package && zip -r "../out/${ARCHIVE}" .)
else
tar -C dist/package -czf "dist/out/${ARCHIVE}" .
fi
echo "archive=dist/out/${ARCHIVE}" >> "$GITHUB_OUTPUT"
- name: Upload release asset artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.component }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: ${{ steps.package.outputs.archive }}
retention-days: 1
publish:
name: Publish release
needs:
- build-assets
- android-apk
runs-on: ubuntu-latest
steps:
- name: Download packaged assets
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
merge-multiple: true
- 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: Generate checksums
shell: bash
run: |
cd release-artifacts
sha256sum * > SHA256SUMS.txt
- name: Create or update GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.meta.outputs.tag }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
release-artifacts/*

View File

@@ -14,8 +14,7 @@ go build -o client ./cmd/client
./server -c server.yaml # with config file
# Run client
./client -s <server>:7000 -t <token> -id <client-id>
./client -s <server>:7000 -t <token> -id <client-id> -no-tls # disable TLS
./client -s <server>:7000 -t <token>
# Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server
@@ -60,7 +59,7 @@ pkg/
├── relay/ # Bidirectional data relay (32KB buffers)
├── auth/ # JWT authentication
├── utils/ # Port availability checking
├── version/ # Version info and update checking (Gitea API)
├── version/ # Version info and update checking (GitHub Releases API)
└── update/ # Shared update logic (download, extract tar.gz/zip)
web/ # Vue 3 + TypeScript frontend (Vite + naive-ui)
scripts/ # Build scripts (build.sh, build.ps1)
@@ -86,7 +85,7 @@ External User → Server Port → Yamux Stream → Client → Local Service
### Configuration
- Server: YAML config + SQLite database for client rules and JS plugins
- Client: Command-line flags only (server address, token, client ID)
- Client: Command-line flags only (server address, token)
- Default ports: 7000 (tunnel), 7500 (web console)
## API Documentation
@@ -107,7 +106,7 @@ The server provides Swagger-documented REST APIs at `/api/`.
## Update System
Both server and client support self-update from Gitea releases.
Both server and client support self-update from GitHub releases.
- Release assets are compressed archives (`.tar.gz` for Linux/Mac, `.zip` for Windows)
- The `pkg/update/` package handles download, extraction, and binary replacement

View File

@@ -14,8 +14,7 @@ go build -o client ./cmd/client
./server -c server.yaml # with config file
# Run client
./client -s <server>:7000 -t <token> -id <client-id>
./client -s <server>:7000 -t <token> -id <client-id> -no-tls # disable TLS
./client -s <server>:7000 -t <token>
# Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server
@@ -60,7 +59,7 @@ pkg/
├── relay/ # Bidirectional data relay (32KB buffers)
├── auth/ # JWT authentication
├── utils/ # Port availability checking
├── version/ # Version info and update checking (Gitea API)
├── version/ # Version info and update checking (GitHub Releases API)
└── update/ # Shared update logic (download, extract tar.gz/zip)
web/ # Vue 3 + TypeScript frontend (Vite + naive-ui)
scripts/ # Build scripts (build.sh, build.ps1)
@@ -86,7 +85,7 @@ External User → Server Port → Yamux Stream → Client → Local Service
### Configuration
- Server: YAML config + SQLite database for client rules and JS plugins
- Client: Command-line flags only (server address, token, client ID)
- Client: Command-line flags only (server address, token)
- Default ports: 7000 (tunnel), 7500 (web console)
## API Documentation
@@ -107,7 +106,7 @@ The server provides Swagger-documented REST APIs at `/api/`.
## Update System
Both server and client support self-update from Gitea releases.
Both server and client support self-update from GitHub releases.
- Release assets are compressed archives (`.tar.gz` for Linux/Mac, `.zip` for Windows)
- The `pkg/update/` package handles download, extraction, and binary replacement

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"

View File

@@ -17,7 +17,7 @@ GoTunnel 是一个类似 frp 的内网穿透解决方案,核心特点是**服
| TLS 证书 | 自动生成,零配置 | 需手动配置 |
| 管理界面 | 内置 Web 控制台 (naive-ui) | 需额外部署 Dashboard |
| 客户端部署 | 仅需 2 个参数 | 需配置文件 |
| 客户端 ID | 可选,服务端自动分配 | 需手动配置 |
| 客户端 ID | 自动根据设备标识计算 | 需手动配置 |
### 架构设计
@@ -111,14 +111,9 @@ go build -o client ./cmd/client
### 客户端启动
```bash
# 最简启动ID 由服务端自动分配
# 最简启动ID 由客户端根据设备标识自动计算
./client -s <服务器IP>:7000 -t <Token>
# 指定客户端 ID
./client -s <服务器IP>:7000 -t <Token> -id <客户端ID>
# 禁用 TLS需服务端也禁用
./client -s <服务器IP>:7000 -t <Token> -no-tls
```
**参数说明:**
@@ -127,9 +122,6 @@ go build -o client ./cmd/client
|------|------|------|
| `-s` | 服务器地址 (ip:port) | 是 |
| `-t` | 认证 Token | 是 |
| `-id` | 客户端 ID | 否(服务端自动分配) |
| `-no-tls` | 禁用 TLS 加密 | 否 |
| `-skip-verify` | 跳过证书验证(不安全,仅测试用) | 否 |
## 配置系统
@@ -388,7 +380,7 @@ curl -X POST http://server:7500/api/clients \
-d '{"id":"home","rules":[{"name":"web","type":"tcp","local_ip":"127.0.0.1","local_port":80,"remote_port":8080}]}'
# 客户端连接
./client -s server:7000 -t <token> -id home
./client -s server:7000 -t <token>
# 访问http://server:8080 -> 内网 127.0.0.1:80
```
@@ -411,7 +403,7 @@ A: 在 Web 控制台点击客户端详情,进入编辑模式即可设置昵称
**Q: 如何禁用 TLS**
A: 服务端配置 `tls_disabled: true`,客户端使用 `-no-tls` 参数
A: 客户端命令行默认使用 TLS如需兼容旧的非 TLS 部署,请改用客户端配置文件中的 `no_tls: true`
**Q: 端口被占用怎么办?**
@@ -419,7 +411,7 @@ A: 服务端会自动检测端口冲突,请检查日志并更换端口。
**Q: 客户端 ID 是如何分配的?**
A: 如果客户端未指定 `-id` 参数,服务端会自动生成 16 位随机 ID。
A: 客户端会把系统机器 ID、全部可用 MAC、主机名和网卡名等稳定标识组合后再进行哈希得到固定客户端 ID服务端不再为客户端分配或修正 ID。
**Q: 如何更新服务端/客户端?**

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,51 @@
<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>
<activity
android:name=".SettingsActivity"
android:exported="false" />
<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,132 @@
package com.gotunnel.android
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
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.ConfigStore
import com.gotunnel.android.config.LogStore
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 lateinit var logStore: LogStore
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)
logStore = LogStore(this)
binding.topToolbar.setNavigationOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
binding.startButton.setOnClickListener {
val config = configStore.load()
if (config.serverAddress.isBlank() || config.token.isBlank()) {
Toast.makeText(this, R.string.config_missing, Toast.LENGTH_SHORT).show()
startActivity(Intent(this, SettingsActivity::class.java))
return@setOnClickListener
}
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()
}
}
override fun onResume() {
super.onResume()
renderScreen()
}
private fun renderScreen() {
val config = configStore.load()
val state = stateStore.load()
val timestamp = if (state.updatedAt > 0L) {
DateFormat.getDateTimeInstance().format(Date(state.updatedAt))
} else {
getString(R.string.state_never_updated)
}
binding.statusValue.text = getStatusLabel(state.status)
binding.statusDetail.text = state.detail.ifBlank { getString(R.string.state_no_detail) }
binding.stateMeta.text = getString(R.string.state_meta_format, timestamp)
binding.stateHint.text = getStateHint(state.status)
binding.serverSummary.text = if (config.serverAddress.isBlank()) {
getString(R.string.status_server_unconfigured)
} else {
getString(R.string.status_server_configured, config.serverAddress)
}
binding.logValue.text = logStore.render()
}
private fun getStatusLabel(status: TunnelStatus): String {
return when (status) {
TunnelStatus.RUNNING -> getString(R.string.status_running)
TunnelStatus.STARTING -> getString(R.string.status_starting)
TunnelStatus.RECONNECTING -> getString(R.string.status_reconnecting)
TunnelStatus.ERROR -> getString(R.string.status_error)
TunnelStatus.STOPPED -> getString(R.string.status_stopped)
}
}
private fun getStateHint(status: TunnelStatus): String {
val messageId = when (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
}
return getString(messageId)
}
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)
}
}
}

View File

@@ -0,0 +1,70 @@
package com.gotunnel.android
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.gotunnel.android.config.AppConfig
import com.gotunnel.android.config.ConfigStore
import com.gotunnel.android.databinding.ActivitySettingsBinding
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
private lateinit var configStore: ConfigStore
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
configStore = ConfigStore(this)
populateForm(configStore.load())
binding.topToolbar.setNavigationOnClickListener {
finish()
}
binding.saveButton.setOnClickListener {
configStore.save(readForm())
Toast.makeText(this, R.string.config_saved, Toast.LENGTH_SHORT).show()
finish()
}
binding.batteryButton.setOnClickListener {
openBatteryOptimizationSettings()
}
}
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
}
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,
)
}
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,8 @@
package com.gotunnel.android.config
data class AppConfig(
val serverAddress: String = "",
val token: String = "",
val autoStart: Boolean = true,
val autoReconnect: Boolean = true,
)

View File

@@ -0,0 +1,35 @@
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),
)
}
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)
.remove(KEY_USE_TLS)
.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,52 @@
package com.gotunnel.android.config
import android.content.Context
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun append(message: String) {
if (message.isBlank()) {
return
}
val current = load().toMutableList()
current += "${timestamp()} $message"
while (current.size > MAX_LINES) {
current.removeAt(0)
}
prefs.edit().putString(KEY_LOGS, current.joinToString(SEPARATOR)).apply()
}
fun load(): List<String> {
val raw = prefs.getString(KEY_LOGS, "") ?: ""
if (raw.isBlank()) {
return emptyList()
}
return raw.split(SEPARATOR).filter { it.isNotBlank() }
}
fun render(): String {
val lines = load()
return if (lines.isEmpty()) {
"No logs yet."
} else {
lines.joinToString("\n")
}
}
private fun timestamp(): String {
return SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
}
companion object {
private const val PREFS_NAME = "gotunnel_logs"
private const val KEY_LOGS = "logs"
private const val MAX_LINES = 80
private const val SEPARATOR = "\u0001"
}
}

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,106 @@
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, statusLabel(context, status)))
.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 statusLabel(context: Context, status: TunnelStatus): String {
return when (status) {
TunnelStatus.RUNNING -> context.getString(R.string.status_running)
TunnelStatus.STARTING -> context.getString(R.string.status_starting)
TunnelStatus.RECONNECTING -> context.getString(R.string.status_reconnecting)
TunnelStatus.ERROR -> context.getString(R.string.status_error)
TunnelStatus.STOPPED -> context.getString(R.string.status_stopped)
}
}
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,180 @@
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.LogStore
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 logStore: LogStore
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)
logStore = LogStore(this)
controller = GoTunnelBridge.create(applicationContext)
controller.setListener(object : TunnelController.Listener {
override fun onStatusChanged(status: TunnelStatus, detail: String) {
stateStore.save(status, detail)
logStore.append("status: ${status.name} ${detail.ifBlank { "" }}".trim())
updateNotification(status, detail)
}
override fun onLog(message: String) {
val current = stateStore.load()
logStore.append(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)
logStore.append(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)
logStore.append("start requested: $reason")
updateNotification(TunnelStatus.STARTING, reason)
if (!isConfigReady(currentConfig)) {
val detail = getString(com.gotunnel.android.R.string.config_missing)
stateStore.save(TunnelStatus.STOPPED, detail)
logStore.append(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)
logStore.append("stop requested: $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,223 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/gotunnel_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
app:navigationIcon="@android:drawable/ic_menu_manage"
app:navigationIconTint="@color/gotunnel_text"
app:subtitle="@string/main_subtitle"
app:subtitleTextColor="@color/gotunnel_text_muted"
app:title="@string/home_title"
app:titleTextColor="@color/gotunnel_text" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_card_title"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
<TextView
android:id="@+id/statusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/gotunnel_text"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statusDetail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/gotunnel_text"
android:textSize="15sp" />
<TextView
android:id="@+id/stateHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/gotunnel_text_muted"
android:textSize="14sp" />
<TextView
android:id="@+id/serverSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:textColor="@color/gotunnel_text"
android:textSize="14sp" />
<TextView
android:id="@+id/stateMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/gotunnel_text_muted"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="@string/stop_button" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/log_card_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/logValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:lineSpacingExtra="4dp"
android:textColor="@color/gotunnel_text"
android:textIsSelectable="true"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/proxy_card_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/proxy_card_subtitle"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
<com.google.android.material.chip.ChipGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:chipSpacingHorizontal="8dp"
app:chipSpacingVertical="8dp"
app:singleLine="false">
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TCP" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="UDP" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HTTP" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HTTPS" />
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SOCKS5" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/gotunnel_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/topToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
app:navigationIcon="@android:drawable/ic_media_previous"
app:navigationIconTint="@color/gotunnel_text"
app:subtitle="@string/settings_subtitle"
app:subtitleTextColor="@color/gotunnel_text_muted"
app:title="@string/settings_title"
app:titleTextColor="@color/gotunnel_text" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_basic_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:hint="@string/server_address_label"
app:boxBackgroundMode="outline"
app:helperText="@string/server_address_helper">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/serverAddressInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:hint="@string/token_label"
app:boxBackgroundMode="outline"
app:helperText="@string/token_helper">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
app:cardBackgroundColor="@color/gotunnel_surface"
app:cardCornerRadius="24dp"
app:strokeColor="@color/gotunnel_border"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_behavior_title"
android:textColor="@color/gotunnel_text"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoStartSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/auto_start_label"
android:textColor="@color/gotunnel_text" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/auto_start_helper"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoReconnectSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/auto_reconnect_label"
android:textColor="@color/gotunnel_text" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/auto_reconnect_helper"
android:textColor="@color/gotunnel_text_muted"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<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="18dp"
android:text="@string/battery_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/save_button" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -0,0 +1,9 @@
<resources>
<color name="gotunnel_background">#F3F7FB</color>
<color name="gotunnel_surface">#FFFFFF</color>
<color name="gotunnel_primary">#0F766E</color>
<color name="gotunnel_secondary">#0891B2</color>
<color name="gotunnel_text">#0F172A</color>
<color name="gotunnel_text_muted">#475569</color>
<color name="gotunnel_border">#D9E2EC</color>
</resources>

View File

@@ -0,0 +1,54 @@
<resources>
<string name="app_name">GoTunnel</string>
<string name="home_title">GoTunnel Client</string>
<string name="main_subtitle">Status, recent logs, and supported proxy types</string>
<string name="settings_title">Client Settings</string>
<string name="settings_subtitle">Connection settings and startup behavior</string>
<string name="settings_basic_title">Basic Connection</string>
<string name="settings_behavior_title">Startup and Recovery</string>
<string name="server_address_label">Server Address</string>
<string name="server_address_helper">Example: 1.2.3.4:7000. This is the GoTunnel server endpoint.</string>
<string name="token_label">Access Token</string>
<string name="token_helper">The token issued by the server to identify this client.</string>
<string name="auto_start_label">Start on boot</string>
<string name="auto_start_helper">Restore the foreground service after reboot or app update.</string>
<string name="auto_reconnect_label">Reconnect when network returns</string>
<string name="auto_reconnect_helper">Restart the client automatically after connectivity is restored.</string>
<string name="save_button">Save Settings</string>
<string name="start_button">Start Client</string>
<string name="stop_button">Stop Client</string>
<string name="battery_button">Battery Optimization</string>
<string name="status_card_title">Software Status</string>
<string name="log_card_title">Recent Logs</string>
<string name="proxy_card_title">Supported Proxies</string>
<string name="proxy_card_subtitle">The current Android shell can present these proxy types. Live proxy rules can be shown after the native Go core is connected.</string>
<string name="config_saved">Client settings saved.</string>
<string name="service_start_requested">Client start requested.</string>
<string name="service_stop_requested">Client 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">Not updated yet</string>
<string name="state_no_detail">No detail available</string>
<string name="state_meta_format">Last update: %1$s</string>
<string name="state_hint_stopped">The client is idle. Save settings first, then start it from the home screen.</string>
<string name="state_hint_starting">The client is preparing a tunnel connection.</string>
<string name="state_hint_running">The client is running and ready for the native Go core to handle real proxy traffic.</string>
<string name="state_hint_reconnecting">The client is waiting for the network and will reconnect automatically.</string>
<string name="state_hint_error">The last run failed. Check the settings and recent logs.</string>
<string name="status_running">Running</string>
<string name="status_starting">Starting</string>
<string name="status_reconnecting">Reconnecting</string>
<string name="status_error">Error</string>
<string name="status_stopped">Stopped</string>
<string name="status_server_unconfigured">No server is configured yet. Open Settings to finish the basic client setup.</string>
<string name="status_server_configured">Current server: %1$s</string>
<string name="notification_channel_name">GoTunnel foreground service</string>
<string name="notification_channel_description">Keeps the GoTunnel Android client running in the foreground</string>
<string name="notification_title">GoTunnel - %1$s</string>
<string name="notification_text_configured">Current target: %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">Please open Settings and fill in the server address and access token first.</string>
<string name="network_lost">Network connection lost</string>
</resources>

View File

@@ -0,0 +1,18 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.GoTunnel" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/gotunnel_primary</item>
<item name="colorOnPrimary">@android:color/white</item>
<item name="colorSecondary">@color/gotunnel_secondary</item>
<item name="colorOnSecondary">@android:color/white</item>
<item name="colorSurface">@color/gotunnel_surface</item>
<item name="colorOnSurface">@color/gotunnel_text</item>
<item name="colorOutline">@color/gotunnel_border</item>
<item name="android:colorBackground">@color/gotunnel_background</item>
<item name="android:windowBackground">@color/gotunnel_background</item>
<item name="android:textColorPrimary">@color/gotunnel_text</item>
<item name="android:textColorSecondary">@color/gotunnel_text_muted</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
@@ -23,12 +24,14 @@ func init() {
func main() {
server := flag.String("s", "", "server address (ip:port)")
token := flag.String("t", "", "auth token")
id := flag.String("id", "", "client id (optional, auto-assigned if empty)")
noTLS := flag.Bool("no-tls", false, "disable TLS")
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
@@ -40,32 +43,53 @@ func main() {
cfg = &config.ClientConfig{}
}
// 命令行参数覆盖配置文件
if *server != "" {
cfg.Server = *server
}
if *token != "" {
cfg.Token = *token
}
if *id != "" {
cfg.ID = *id
if *dataDir != "" {
cfg.DataDir = *dataDir
}
if *noTLS {
cfg.NoTLS = *noTLS
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> [-id <client_id>] [-no-tls]]")
log.Fatal("Usage: client [-c config.yaml] | [-s <server:port> -t <token>]")
}
client := tunnel.NewClient(cfg.Server, cfg.Token, cfg.ID)
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,15 +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
ID string `yaml:"id"` // 客户端 ID
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,85 +22,90 @@ import (
"github.com/hashicorp/yamux"
)
// 客户端常量
const (
dialTimeout = 10 * time.Second
localDialTimeout = 5 * time.Second
udpTimeout = 10 * time.Second
reconnectDelay = 5 * time.Second
disconnectDelay = 3 * time.Second
udpBufferSize = 65535
dialTimeout = 10 * time.Second
localDialTimeout = 5 * time.Second
udpTimeout = 10 * time.Second
reconnectDelay = 5 * time.Second
maxReconnectDelay = 30 * time.Second
disconnectDelay = 3 * time.Second
tcpKeepAlive = 30 * time.Second
udpBufferSize = 65535
)
// Client 隧道客户端
// Client is the tunnel client runtime.
type Client struct {
ServerAddr string
Token string
ID string
Name string // 客户端名称(主机名)
Name string
TLSEnabled bool
TLSConfig *tls.Config
DataDir string // 数据目录
session *yamux.Session
rules []protocol.ProxyRule
mu sync.RWMutex
logger *Logger // 日志收集器
DataDir string
features PlatformFeatures
reconnectDelay time.Duration
reconnectMaxDelay time.Duration
session *yamux.Session
rules []protocol.ProxyRule
mu sync.RWMutex
logger *Logger
}
// NewClient 创建客户端
func NewClient(serverAddr, token, id 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")
}
}
// NewClient creates a client with default desktop options.
func NewClient(serverAddr, token string) *Client {
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
if id == "" {
id = getMachineID()
}
// 获取主机名作为客户端名称
hostname, _ := os.Hostname()
// 初始化日志收集器
logger, err := NewLogger(dataDir)
if err != nil {
log.Printf("Failed to initialize logger: %v", err)
}
features := DefaultPlatformFeatures()
if opts.Features != nil {
features = *opts.Features
}
delay := opts.ReconnectDelay
if delay <= 0 {
delay = reconnectDelay
}
maxDelay := opts.ReconnectMaxDelay
if maxDelay <= 0 {
maxDelay = maxReconnectDelay
}
if maxDelay < delay {
maxDelay = delay
}
return &Client{
ServerAddr: serverAddr,
Token: token,
ID: id,
Name: hostname,
DataDir: dataDir,
logger: logger,
ServerAddr: serverAddr,
Token: token,
ID: resolveClientID(dataDir, opts.ClientID),
Name: resolveClientName(opts.ClientName),
DataDir: dataDir,
features: features,
reconnectDelay: delay,
reconnectMaxDelay: maxDelay,
logger: logger,
}
}
// InitVersionStore 初始化版本存储
// InitVersionStore is kept for compatibility with older callers.
func (c *Client) InitVersionStore() error {
return nil
}
// logf 安全地记录日志(同时输出到标准日志和日志收集器)
func (c *Client) logf(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Print(msg)
@@ -109,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)
@@ -118,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)
@@ -127,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
@@ -187,11 +240,9 @@ func (c *Client) connect() error {
conn.Close()
return fmt.Errorf("auth failed: %s", authResp.Message)
}
// 如果服务端分配了新 ID则更新
if authResp.ClientID != "" && authResp.ClientID != c.ID {
c.ID = authResp.ClientID
c.logf("ID updated to: %s", c.ID)
conn.Close()
return fmt.Errorf("server returned unexpected client id: %s", authResp.ClientID)
}
c.logf("Authenticated as %s", c.ID)
@@ -209,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
}
@@ -222,7 +292,6 @@ func (c *Client) handleSession() {
}
}
// handleStream 处理流
func (c *Client) handleStream(stream net.Conn) {
msg, err := protocol.ReadMessage(stream)
if err != nil {
@@ -257,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()
@@ -279,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 {
@@ -294,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
}
}
@@ -317,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()
@@ -333,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())
@@ -341,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()
@@ -366,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 {
@@ -380,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,
@@ -403,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()
@@ -416,16 +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()
@@ -434,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",
@@ -442,19 +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)
@@ -464,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)
@@ -474,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,
@@ -484,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)
@@ -496,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 {
@@ -517,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 {
@@ -526,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, c.ID)
return performWindowsClientUpdate(binaryPath, currentPath, c.ServerAddr, c.Token)
}
// 确定目标路径
targetPath := currentPath
if fallbackDir != "" {
targetPath = filepath.Join(fallbackDir, filepath.Base(currentPath))
@@ -539,7 +575,6 @@ func (c *Client) performSelfUpdate(downloadURL string) error {
}
if fallbackDir == "" {
// 原地替换:备份 → 复制 → 清理
backupPath := currentPath + ".bak"
c.logf("Backing up current binary...")
@@ -563,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)
@@ -577,15 +611,11 @@ func (c *Client) performSelfUpdate(downloadURL string) error {
}
c.logf("Update completed successfully, restarting...")
// 重启进程(从新路径启动)
restartClientProcess(targetPath, c.ServerAddr, c.Token, c.ID)
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")
@@ -597,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)
@@ -608,15 +637,8 @@ func (c *Client) checkUpdatePermissions(execPath string) error {
return nil
}
// performWindowsClientUpdate Windows 平台更新
func performWindowsClientUpdate(newFile, currentPath, serverAddr, token, id string) error {
// 创建批处理脚本
func performWindowsClientUpdate(newFile, currentPath, serverAddr, token string) error {
args := fmt.Sprintf(`-s "%s" -t "%s"`, serverAddr, token)
if id != "" {
args += fmt.Sprintf(` -id "%s"`, id)
}
batchScript := fmt.Sprintf(`@echo off
:: Check for admin rights, request UAC elevation if needed
net session >nul 2>&1
@@ -641,17 +663,12 @@ del "%%~f0"
return fmt.Errorf("start batch: %w", err)
}
// 退出当前进程
os.Exit(0)
return nil
}
// restartClientProcess 重启客户端进程
func restartClientProcess(path, serverAddr, token, id string) {
func restartClientProcess(path, serverAddr, token string) {
args := []string{"-s", serverAddr, "-t", token}
if id != "" {
args = append(args, "-id", id)
}
cmd := exec.Command(path, args...)
cmd.Stdout = os.Stdout
@@ -660,8 +677,6 @@ func restartClientProcess(path, serverAddr, token, id string) {
os.Exit(0)
}
// handleLogRequest 处理日志请求
func (c *Client) handleLogRequest(stream net.Conn, msg *protocol.Message) {
if c.logger == nil {
stream.Close()
@@ -676,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{
@@ -691,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
}
@@ -721,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()
@@ -737,15 +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
}
@@ -763,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)
@@ -780,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,
@@ -794,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}
@@ -806,7 +829,6 @@ func (c *Client) handleShellExecuteRequest(stream net.Conn, msg *protocol.Messag
return
}
// 设置默认超时
timeout := req.Timeout
if timeout <= 0 {
timeout = 30
@@ -814,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)
@@ -822,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

@@ -7,27 +7,47 @@ import (
"os"
"os/exec"
"runtime"
"sort"
"strings"
)
// getMachineID 获取机器唯一标识
// 优先级系统机器ID > MAC地址哈希
// getMachineID builds a stable fingerprint from multiple host identifiers
// and hashes the combined result into the client ID we expose externally.
func getMachineID() string {
// 尝试获取系统机器 ID
if id := getSystemMachineID(); id != "" {
return hashID(id)
parts := collectMachineIDParts()
if len(parts) == 0 {
return ""
}
// 备选:使用主网卡 MAC 地址
if id := getMACAddress(); id != "" {
return hashID(id)
}
// 都失败则返回空,让服务端生成
return ""
return hashID(strings.Join(parts, "|"))
}
func collectMachineIDParts() []string {
parts := make([]string, 0, 6)
if id := getSystemMachineID(); id != "" {
parts = append(parts, "system="+id)
}
if hostname, err := os.Hostname(); err == nil && hostname != "" {
parts = append(parts, "host="+hostname)
}
if macs := getMACAddresses(); len(macs) > 0 {
parts = append(parts, "macs="+strings.Join(macs, ","))
}
if names := getInterfaceNames(); len(names) > 0 {
parts = append(parts, "ifaces="+strings.Join(names, ","))
}
if len(parts) == 0 {
return nil
}
parts = append(parts, "os="+runtime.GOOS, "arch="+runtime.GOARCH)
return parts
}
// getSystemMachineID 获取系统机器 ID
func getSystemMachineID() string {
switch runtime.GOOS {
case "linux":
@@ -36,25 +56,23 @@ func getSystemMachineID() string {
return getDarwinMachineID()
case "windows":
return getWindowsMachineID()
case "android":
return ""
default:
return ""
}
}
// getLinuxMachineID 获取 Linux 机器 ID
func getLinuxMachineID() string {
// 优先读取 /etc/machine-id
if data, err := os.ReadFile("/etc/machine-id"); err == nil {
return strings.TrimSpace(string(data))
}
// 备选 /var/lib/dbus/machine-id
if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
return strings.TrimSpace(string(data))
}
return ""
}
// getDarwinMachineID 获取 macOS 机器 ID (IOPlatformUUID)
func getDarwinMachineID() string {
cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
output, err := cmd.Output()
@@ -62,72 +80,84 @@ func getDarwinMachineID() string {
return ""
}
// 解析 IOPlatformUUID
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "IOPlatformUUID") {
parts := strings.Split(line, "=")
if len(parts) == 2 {
uuid := strings.TrimSpace(parts[1])
uuid = strings.Trim(uuid, "\"")
return uuid
}
for _, line := range strings.Split(string(output), "\n") {
if !strings.Contains(line, "IOPlatformUUID") {
continue
}
parts := strings.Split(line, "=")
if len(parts) != 2 {
continue
}
uuid := strings.TrimSpace(parts[1])
return strings.Trim(uuid, "\"")
}
return ""
}
// getWindowsMachineID 获取 Windows 机器 ID
func getWindowsMachineID() string {
cmd := exec.Command("reg", "query",
`HKLM\SOFTWARE\Microsoft\Cryptography`,
"/v", "MachineGuid")
cmd := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
output, err := cmd.Output()
if err != nil {
return ""
}
// 解析注册表输出
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "MachineGuid") {
fields := strings.Fields(line)
if len(fields) >= 3 {
return fields[len(fields)-1]
}
for _, line := range strings.Split(string(output), "\n") {
if !strings.Contains(line, "MachineGuid") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 3 {
return fields[len(fields)-1]
}
}
return ""
}
// getMACAddress 获取主网卡 MAC 地址
func getMACAddress() string {
func getMACAddresses() []string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
return nil
}
macs := make([]string, 0, len(interfaces))
for _, iface := range interfaces {
// 跳过回环和无效接口
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagUp == 0 {
continue
}
if len(iface.HardwareAddr) == 0 {
continue
}
// 返回第一个有效的 MAC 地址
return iface.HardwareAddr.String()
macs = append(macs, iface.HardwareAddr.String())
}
return ""
sort.Strings(macs)
return macs
}
func getInterfaceNames() []string {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
names := make([]string, 0, len(interfaces))
for _, iface := range interfaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
names = append(names, iface.Name)
}
sort.Strings(names)
return names
}
// hashID 对 ID 进行哈希处理,生成固定长度的客户端 ID
func hashID(id string) string {
hash := sha256.Sum256([]byte(id))
// 取前 16 个字符作为客户端 ID
return hex.EncodeToString(hash[:])[:16]
}

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

@@ -5,8 +5,8 @@ func (s *SQLiteStore) CreateInstallToken(token *InstallToken) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`INSERT INTO install_tokens (token, client_id, created_at, used) VALUES (?, ?, ?, ?)`,
token.Token, token.ClientID, token.CreatedAt, 0)
_, err := s.db.Exec(`INSERT INTO install_tokens (token, client_id, created_at, used) VALUES (?, '', ?, ?)`,
token.Token, token.CreatedAt, 0)
return err
}
@@ -17,8 +17,8 @@ func (s *SQLiteStore) GetInstallToken(token string) (*InstallToken, error) {
var t InstallToken
var used int
err := s.db.QueryRow(`SELECT token, client_id, created_at, used FROM install_tokens WHERE token = ?`, token).
Scan(&t.Token, &t.ClientID, &t.CreatedAt, &used)
err := s.db.QueryRow(`SELECT token, created_at, used FROM install_tokens WHERE token = ?`, token).
Scan(&t.Token, &t.CreatedAt, &used)
if err != nil {
return nil, err
}

View File

@@ -46,7 +46,6 @@ type TrafficStore interface {
// InstallToken 安装token
type InstallToken struct {
Token string `json:"token"`
ClientID string `json:"client_id"`
CreatedAt int64 `json:"created_at"`
Used bool `json:"used"`
}

View File

@@ -81,7 +81,7 @@ func (s *SQLiteStore) init() error {
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS install_tokens (
token TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
client_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
)

View File

@@ -1,15 +1,12 @@
package dto
// UpdateServerConfigRequest 更新服务器配置请求
// @Description 更新服务器配置
// UpdateServerConfigRequest is the config update payload.
type UpdateServerConfigRequest struct {
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
PluginStore *PluginStoreConfigPart `json:"plugin_store"`
Server *ServerConfigPart `json:"server"`
Web *WebConfigPart `json:"web"`
}
// ServerConfigPart 服务器配置部分
// @Description 隧道服务器配置
// ServerConfigPart is the server config subset.
type ServerConfigPart struct {
BindAddr string `json:"bind_addr" binding:"omitempty"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
@@ -18,8 +15,7 @@ type ServerConfigPart struct {
HeartbeatTimeout int `json:"heartbeat_timeout" binding:"omitempty,min=1,max=600"`
}
// WebConfigPart Web 配置部分
// @Description Web 控制台配置
// WebConfigPart is the web console config subset.
type WebConfigPart struct {
Enabled bool `json:"enabled"`
BindPort int `json:"bind_port" binding:"omitempty,min=1,max=65535"`
@@ -27,37 +23,25 @@ type WebConfigPart struct {
Password string `json:"password" binding:"omitempty,min=6,max=64"`
}
// ServerConfigResponse 服务器配置响应
// @Description 服务器配置信息
// ServerConfigResponse is the config response payload.
type ServerConfigResponse struct {
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
PluginStore PluginStoreConfigInfo `json:"plugin_store"`
Server ServerConfigInfo `json:"server"`
Web WebConfigInfo `json:"web"`
}
// ServerConfigInfo 服务器配置信息
// ServerConfigInfo describes the server config.
type ServerConfigInfo struct {
BindAddr string `json:"bind_addr"`
BindPort int `json:"bind_port"`
Token string `json:"token"` // 脱敏后的 token
Token string `json:"token"`
HeartbeatSec int `json:"heartbeat_sec"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
}
// WebConfigInfo Web 配置信息
// WebConfigInfo describes the web console config.
type WebConfigInfo struct {
Enabled bool `json:"enabled"`
BindPort int `json:"bind_port"`
Username string `json:"username"`
Password string `json:"password"` // 显示为 ****
}
// PluginStoreConfigPart 插件商店配置部分
type PluginStoreConfigPart struct {
URL string `json:"url"`
}
// PluginStoreConfigInfo 插件商店配置信息
type PluginStoreConfigInfo struct {
URL string `json:"url"`
Password string `json:"password"`
}

View File

@@ -4,105 +4,180 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gotunnel/internal/server/db"
)
// InstallHandler 安装处理器
type InstallHandler struct {
app AppInterface
}
// NewInstallHandler 创建安装处理器
const (
installTokenHeader = "X-GoTunnel-Install-Token"
installTokenTTL = 3600
)
func NewInstallHandler(app AppInterface) *InstallHandler {
return &InstallHandler{app: app}
}
// GenerateInstallCommandRequest 生成安装命令请求
type GenerateInstallCommandRequest struct {
ClientID string `json:"client_id" binding:"required"`
}
// InstallCommandResponse 安装命令响应
type InstallCommandResponse struct {
Token string `json:"token"`
Commands map[string]string `json:"commands"`
ExpiresAt int64 `json:"expires_at"`
ServerAddr string `json:"server_addr"`
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
TunnelPort int `json:"tunnel_port"`
}
// GenerateInstallCommand 生成安装命令
// @Summary 生成客户端安装命令
// GenerateInstallCommand creates a one-time install token and returns
// the tunnel port so the frontend can build a host-aware command.
//
// @Summary Generate install command payload
// @Tags install
// @Accept json
// @Produce json
// @Param body body GenerateInstallCommandRequest true "客户端ID"
// @Success 200 {object} InstallCommandResponse
// @Router /api/install/generate [post]
func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
var req GenerateInstallCommandRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 生成随机token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
token := hex.EncodeToString(tokenBytes)
// 保存到数据库
token := hex.EncodeToString(tokenBytes)
now := time.Now().Unix()
installToken := &db.InstallToken{
Token: token,
ClientID: req.ClientID,
CreatedAt: now,
Used: false,
}
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储不支持安装token"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "install token store is not supported"})
return
}
if err := store.CreateInstallToken(installToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存token失败"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist token"})
return
}
// 获取服务器地址
serverAddr := fmt.Sprintf("%s:%d", h.app.GetConfig().Server.BindAddr, h.app.GetServer().GetBindPort())
if h.app.GetConfig().Server.BindAddr == "" || h.app.GetConfig().Server.BindAddr == "0.0.0.0" {
serverAddr = fmt.Sprintf("your-server-ip:%d", h.app.GetServer().GetBindPort())
}
// 生成安装命令
expiresAt := now + 3600 // 1小时过期
tlsFlag := ""
if h.app.GetConfig().Server.TLSDisabled {
tlsFlag = " -no-tls"
}
commands := map[string]string{
"linux": fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.sh | bash -s -- -s %s -t %s -id %s%s",
serverAddr, token, req.ClientID, tlsFlag),
"macos": fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.sh | bash -s -- -s %s -t %s -id %s%s",
serverAddr, token, req.ClientID, tlsFlag),
"windows": fmt.Sprintf("powershell -c \"irm https://raw.githubusercontent.com/gotunnel/gotunnel/main/scripts/install.ps1 | iex; Install-GoTunnel -Server '%s' -Token '%s' -ClientID '%s'%s\"",
serverAddr, token, req.ClientID, tlsFlag),
}
c.JSON(http.StatusOK, InstallCommandResponse{
Token: token,
Commands: commands,
ExpiresAt: expiresAt,
ServerAddr: serverAddr,
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

@@ -1,10 +1,8 @@
package tunnel
import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net"
@@ -38,13 +36,6 @@ func isValidClientID(id string) bool {
return clientIDRegex.MatchString(id)
}
// generateClientID 生成随机客户端 ID
func generateClientID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// Server 隧道服务端
type Server struct {
clientStore db.ClientStore
@@ -239,13 +230,7 @@ func (s *Server) handleConnection(conn net.Conn) {
validToken = true
isInstallToken = true
// 验证客户端ID匹配
if authReq.ClientID != "" && authReq.ClientID != installToken.ClientID {
security.LogInvalidClientID(clientIP, authReq.ClientID)
s.sendAuthResponse(conn, false, "client id mismatch", "")
return
}
// 使用token中的客户端ID
authReq.ClientID = installToken.ClientID
}
}
}
@@ -259,9 +244,7 @@ func (s *Server) handleConnection(conn net.Conn) {
// 处理客户端 ID
clientID := authReq.ClientID
if clientID == "" {
clientID = generateClientID()
} else if !isValidClientID(clientID) {
if clientID == "" || !isValidClientID(clientID) {
security.LogInvalidClientID(clientIP, clientID)
s.sendAuthResponse(conn, false, "invalid client id format", "")
return
@@ -757,11 +740,6 @@ func (s *Server) DisconnectClient(clientID string) error {
return cs.Session.Close()
}
// startUDPListener 启动 UDP 监听
func (s *Server) startUDPListener(cs *ClientSession, rule *protocol.ProxyRule) {
if err := s.portManager.Reserve(rule.RemotePort, cs.ID); err != nil {
@@ -856,15 +834,6 @@ func (s *Server) sendUDPPacket(cs *ClientSession, conn *net.UDPConn, clientAddr
}
}
// checkHTTPBasicAuth 检查 HTTP Basic Auth
// 返回 (认证成功, 已读取的数据)
func (s *Server) checkHTTPBasicAuth(conn net.Conn, username, password string) (bool, []byte) {
@@ -933,8 +902,6 @@ func (s *Server) sendHTTPUnauthorized(conn net.Conn) {
conn.Write([]byte(response))
}
// shouldPushToClient 检查是否应推送到指定客户端
func (s *Server) shouldPushToClient(autoPush []string, clientID string) bool {
if len(autoPush) == 0 {
@@ -980,11 +947,6 @@ func (s *Server) RestartClient(clientID string) error {
return nil
}
// IsPortAvailable 检查端口是否可用
func (s *Server) IsPortAvailable(port int, excludeClientID string) bool {
// 检查系统端口
@@ -1008,11 +970,6 @@ func (s *Server) IsPortAvailable(port int, excludeClientID string) bool {
return true
}
// SendUpdateToClient 发送更新命令到客户端
func (s *Server) SendUpdateToClient(clientID, downloadURL string) error {
s.mu.RLock()

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

@@ -11,37 +11,19 @@ import (
"time"
)
// 版本信息
var Version = "1.0.0"
var GitCommit = ""
var BuildTime = ""
// SetVersion 设置版本号(由 main 包在初始化时调用)
func SetVersion(v string) {
if v != "" {
Version = v
}
}
// SetBuildInfo 设置构建信息(由 main 包在初始化时调用)
func SetBuildInfo(gitCommit, buildTime string) {
if gitCommit != "" {
GitCommit = gitCommit
}
if buildTime != "" {
BuildTime = buildTime
}
}
// 仓库信息
const (
RepoURL = "https://git.92coco.cn/flik/GoTunnel"
APIBaseURL = "https://git.92coco.cn/api/v1"
RepoOwner = "flik"
RepoName = "GoTunnel"
RepoURL = "https://github.com/Flikify/Gotunnel"
APIBaseURL = "https://api.github.com"
RepoOwner = "Flikify"
RepoName = "Gotunnel"
GitHubAPIVersion = "2022-11-28"
GitHubUserAgent = "GoTunnel-Updater"
)
// Info 版本详细信息
type Info struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
@@ -51,7 +33,43 @@ type Info struct {
Arch string `json:"arch"`
}
// GetInfo 获取版本信息
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
}
type ReleaseAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type UpdateInfo struct {
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
func SetVersion(v string) {
if v != "" {
Version = v
}
}
func SetBuildInfo(gitCommit, buildTime string) {
if gitCommit != "" {
GitCommit = gitCommit
}
if buildTime != "" {
BuildTime = buildTime
}
}
func GetInfo() Info {
return Info{
Version: Version,
@@ -63,39 +81,27 @@ func GetInfo() Info {
}
}
// ReleaseInfo Release 信息
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
func newGitHubRequest(url string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", GitHubAPIVersion)
req.Header.Set("User-Agent", GitHubUserAgent)
return req, nil
}
// ReleaseAsset Release 资产
type ReleaseAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// UpdateInfo 更新信息
type UpdateInfo struct {
Latest string `json:"latest"`
ReleaseNote string `json:"release_note"`
DownloadURL string `json:"download_url"`
AssetName string `json:"asset_name"`
AssetSize int64 `json:"asset_size"`
}
// GetLatestRelease 获取最新 Release
// Gitea 兼容:先尝试 /releases/latest失败则尝试 /releases 取第一个
func GetLatestRelease() (*ReleaseInfo, error) {
client := &http.Client{Timeout: 30 * time.Second}
// 首先尝试 /releases/latest 端点GitHub 兼容)
latestURL := fmt.Sprintf("%s/repos/%s/%s/releases/latest", APIBaseURL, RepoOwner, RepoName)
resp, err := client.Get(latestURL)
req, err := newGitHubRequest(latestURL)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
@@ -109,10 +115,15 @@ func GetLatestRelease() (*ReleaseInfo, error) {
return &release, nil
}
// 如果 /releases/latest 不可用,尝试 /releases 并取第一个
resp.Body.Close()
listURL := fmt.Sprintf("%s/repos/%s/%s/releases?limit=1", APIBaseURL, RepoOwner, RepoName)
resp, err = client.Get(listURL)
listURL := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=1", APIBaseURL, RepoOwner, RepoName)
req, err = newGitHubRequest(listURL)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err = client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
@@ -135,14 +146,12 @@ func GetLatestRelease() (*ReleaseInfo, error) {
return &releases[0], nil
}
// CheckUpdate 检查更新(返回最新版本信息)
func CheckUpdate(component string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
@@ -162,14 +171,12 @@ func CheckUpdate(component string) (*UpdateInfo, error) {
}, nil
}
// CheckUpdateForPlatform 检查指定平台的更新
func CheckUpdateForPlatform(component, osName, arch string) (*UpdateInfo, error) {
release, err := GetLatestRelease()
if err != nil {
return nil, fmt.Errorf("get latest release: %w", err)
}
// 查找对应平台的资产
var downloadURL string
var assetName string
var assetSize int64
@@ -189,17 +196,12 @@ func CheckUpdateForPlatform(component, osName, arch string) (*UpdateInfo, error)
}, nil
}
// findAssetForPlatform 在 Release 资产中查找匹配的文件
func findAssetForPlatform(assets []ReleaseAsset, component, osName, arch string) *ReleaseAsset {
// 构建匹配模式
// CI 格式: gotunnel-server-v1.0.0-linux-amd64.tar.gz
// 或者: gotunnel-client-v1.0.0-windows-amd64.zip
prefix := fmt.Sprintf("gotunnel-%s-", component)
suffix := fmt.Sprintf("-%s-%s", osName, arch)
for i := range assets {
name := assets[i].Name
// 检查是否匹配 gotunnel-{component}-{version}-{os}-{arch}.{ext}
if strings.HasPrefix(name, prefix) && strings.Contains(name, suffix) {
return &assets[i]
}
@@ -207,8 +209,6 @@ func findAssetForPlatform(assets []ReleaseAsset, component, osName, arch string)
return nil
}
// CompareVersions 比较版本号
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
func CompareVersions(v1, v2 string) int {
parts1 := parseVersionParts(v1)
parts2 := parseVersionParts(v2)

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,15 @@ try {
$GitCommit = "unknown"
}
# 目标平台
$Platforms = @(
@{OS="windows"; Arch="amd64"},
@{OS="linux"; Arch="amd64"},
@{OS="linux"; Arch="arm64"},
@{OS="darwin"; Arch="amd64"},
@{OS="darwin"; Arch="arm64"}
)
$DesktopPlatforms = @(
@{ OS = "windows"; Arch = "amd64" },
@{ OS = "windows"; Arch = "arm64" },
@{ 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 +52,6 @@ function Write-Err {
Write-Host $Message
}
# 检查 UPX 是否可用
function Test-UPX {
try {
$null = Get-Command upx -ErrorAction Stop
@@ -68,16 +61,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 +85,6 @@ function Compress-Binary {
}
}
# 构建 Web UI
function Build-Web {
Write-Info "Building web UI..."
@@ -111,7 +104,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 +116,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 +180,6 @@ function Build-All {
}
}
# 仅构建当前平台
function Build-Current {
$OS = go env GOOS
$Arch = go env GOARCH
@@ -195,7 +190,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 +243,6 @@ function Clean-Build {
Write-Info "Clean completed"
}
# 显示帮助
function Show-Help {
Write-Host @"
GoTunnel Build Script for Windows
@@ -212,11 +250,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
@@ -226,20 +265,20 @@ Options:
Target platforms:
- windows/amd64
- windows/arm64
- 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 +309,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 +328,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 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64"
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

File diff suppressed because it is too large Load Diff

View File

@@ -162,25 +162,19 @@ export interface WebConfigInfo {
password: string
}
export interface PluginStoreConfigInfo {
url: string
}
export interface ServerConfigResponse {
server: ServerConfigInfo
web: WebConfigInfo
plugin_store: PluginStoreConfigInfo
}
export interface UpdateServerConfigRequest {
server?: Partial<ServerConfigInfo>
web?: Partial<WebConfigInfo>
plugin_store?: Partial<PluginStoreConfigInfo>
}
export const getServerConfig = () => get<ServerConfigResponse>('/config')
export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config)
// 安装命令生成
export const generateInstallCommand = (clientId: string) =>
post<InstallCommandResponse>('/install/generate', { client_id: clientId })
export const generateInstallCommand = () =>
post<InstallCommandResponse>('/install/generate')

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps<{
label: string
value: string | number
hint?: string
tone?: 'default' | 'success' | 'warning' | 'info'
}>()
const toneClass = props.tone || 'default'
</script>
<template>
<article class="metric-card" :class="`metric-card--${toneClass}`">
<span class="metric-card__label">{{ label }}</span>
<strong class="metric-card__value">{{ value }}</strong>
<span v-if="hint" class="metric-card__hint">{{ hint }}</span>
</article>
</template>
<style scoped>
.metric-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
min-height: 128px;
border-radius: 20px;
background: var(--glass-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
}
.metric-card__label {
font-size: 13px;
color: var(--color-text-secondary);
}
.metric-card__value {
font-size: clamp(26px, 4vw, 34px);
line-height: 1;
color: var(--color-text-primary);
letter-spacing: -0.04em;
}
.metric-card__hint {
margin-top: auto;
font-size: 13px;
color: var(--color-text-muted);
}
.metric-card--success {
border-color: rgba(16, 185, 129, 0.24);
}
.metric-card--warning {
border-color: rgba(245, 158, 11, 0.24);
}
.metric-card--info {
border-color: rgba(6, 182, 212, 0.24);
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
defineProps<{
title: string
subtitle?: string
eyebrow?: string
}>()
</script>
<template>
<section class="page-shell">
<div class="page-shell__glow page-shell__glow--primary"></div>
<div class="page-shell__glow page-shell__glow--secondary"></div>
<header class="page-shell__header">
<div class="page-shell__heading">
<span v-if="eyebrow" class="page-shell__eyebrow">{{ eyebrow }}</span>
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="page-shell__actions">
<slot name="actions" />
</div>
</header>
<div v-if="$slots.metrics" class="page-shell__metrics">
<slot name="metrics" />
</div>
<div class="page-shell__content">
<slot />
</div>
</section>
</template>
<style scoped>
.page-shell {
position: relative;
padding: 32px;
overflow: hidden;
}
.page-shell__glow {
position: absolute;
border-radius: 999px;
filter: blur(80px);
opacity: 0.18;
pointer-events: none;
}
.page-shell__glow--primary {
width: 320px;
height: 320px;
top: -120px;
right: -80px;
background: var(--color-accent);
}
.page-shell__glow--secondary {
width: 280px;
height: 280px;
bottom: -120px;
left: -40px;
background: #8b5cf6;
}
.page-shell__header,
.page-shell__metrics,
.page-shell__content {
position: relative;
z-index: 1;
}
.page-shell__header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
margin-bottom: 24px;
}
.page-shell__heading {
max-width: 720px;
}
.page-shell__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin-bottom: 12px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.18);
color: var(--color-accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.page-shell__heading h1 {
margin: 0;
font-size: clamp(28px, 4vw, 40px);
font-weight: 700;
letter-spacing: -0.03em;
color: var(--color-text-primary);
}
.page-shell__heading p {
margin: 10px 0 0;
max-width: 640px;
color: var(--color-text-secondary);
font-size: 15px;
line-height: 1.7;
}
.page-shell__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.page-shell__metrics {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-bottom: 24px;
}
.page-shell__content {
display: flex;
flex-direction: column;
gap: 20px;
}
@media (max-width: 768px) {
.page-shell {
padding: 20px;
}
.page-shell__header {
flex-direction: column;
}
.page-shell__actions {
width: 100%;
justify-content: stretch;
}
.page-shell__actions :deep(*) {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<section class="section-card glass-card">
<header class="section-card__header">
<div>
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<div v-if="$slots.header" class="section-card__extra">
<slot name="header" />
</div>
</header>
<div class="section-card__body">
<slot />
</div>
</section>
</template>
<style scoped>
.section-card {
padding: 22px;
}
.section-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.section-card__header h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.section-card__header p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-secondary);
}
.section-card__extra {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.section-card__body {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 768px) {
.section-card {
padding: 18px;
}
.section-card__header {
flex-direction: column;
}
.section-card__extra {
width: 100%;
}
}
</style>

View File

@@ -67,11 +67,6 @@ export interface LogStreamOptions {
// 安装命令响应
export interface InstallCommandResponse {
token: string
commands: {
linux: string
macos: string
windows: string
}
expires_at: number
server_addr: string
tunnel_port: number
}

View File

@@ -1,611 +1,323 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getClients, generateInstallCommand } from '../api'
import GlassModal from '../components/GlassModal.vue'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
import { generateInstallCommand, getClients } from '../api'
import { useToast } from '../composables/useToast'
import type { ClientStatus, InstallCommandResponse } from '../types'
const router = useRouter()
const message = useToast()
const clients = ref<ClientStatus[]>([])
const loading = ref(true)
const showInstallModal = ref(false)
const installData = ref<InstallCommandResponse | null>(null)
const installClientId = ref('')
const generatingInstall = ref(false)
const search = ref('')
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
return `${normalizedHost}:${port}`
}
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 -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}'\"`,
}
}
const loadClients = async () => {
loading.value = true
try {
const { data } = await getClients()
clients.value = data || []
} catch (e) {
console.error('Failed to load clients', e)
} catch (error) {
console.error('Failed to load clients', error)
message.error('客户端列表加载失败')
} finally {
loading.value = false
}
}
const onlineClients = computed(() => clients.value.filter(c => c.online).length)
const viewClient = (id: string) => {
router.push(`/client/${id}`)
}
const openInstallModal = async () => {
installClientId.value = `client-${Date.now()}`
generatingInstall.value = true
try {
const { data } = await generateInstallCommand(installClientId.value)
const { data } = await generateInstallCommand()
installData.value = data
showInstallModal.value = true
} catch (e) {
console.error('Failed to generate install command', e)
} catch (error) {
console.error('Failed to generate install command', error)
message.error('安装命令生成失败')
} finally {
generatingInstall.value = false
}
}
const copyCommand = (cmd: string) => {
navigator.clipboard.writeText(cmd)
const copyCommand = async (command: string) => {
try {
await navigator.clipboard.writeText(command)
message.success('命令已复制')
} catch (error) {
console.error('Failed to copy command', error)
message.error('复制失败,请手动复制')
}
}
const closeInstallModal = () => {
showInstallModal.value = false
installData.value = null
}
const filteredClients = computed(() => {
const keyword = search.value.trim().toLowerCase()
if (!keyword) return clients.value
return clients.value.filter((client) => {
return [client.id, client.nickname, client.remote_addr, client.os, client.arch]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
})
})
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
const installCommands = computed(() => (installData.value ? buildInstallCommands(installData.value) : null))
onMounted(loadClients)
</script>
<template>
<div class="clients-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<PageShell title="客户端" eyebrow="Clients" subtitle="统一管理已注册节点、连接状态与快速安装命令,减少操作跳转。">
<template #actions>
<button class="glass-btn" :disabled="generatingInstall" @click="openInstallModal">
{{ generatingInstall ? '生成中...' : '安装命令' }}
</button>
<button class="glass-btn primary" @click="loadClients">{{ loading ? '刷新中...' : '刷新列表' }}</button>
</template>
<div class="clients-content">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">客户端管理</h1>
<p class="page-subtitle">管理所有连接的客户端</p>
</div>
<template #metrics>
<MetricCard label="客户端总数" :value="clients.length" hint="已接入的全部节点" />
<MetricCard label="在线节点" :value="onlineClients" hint="可立即推送配置" tone="success" />
<MetricCard label="离线节点" :value="offlineClients" hint="等待心跳恢复" tone="warning" />
<MetricCard label="当前筛选结果" :value="filteredClients.length" hint="支持 ID / 昵称 / 地址搜索" tone="info" />
</template>
<!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ clients.length }}</span>
<span class="stat-label">总客户端</span>
</div>
<div class="stat-card">
<span class="stat-value online">{{ onlineClients }}</span>
<span class="stat-label">在线</span>
</div>
<div class="stat-card">
<span class="stat-value offline">{{ clients.length - onlineClients }}</span>
<span class="stat-label">离线</span>
</div>
</div>
<SectionCard title="节点列表" description="使用统一卡片样式展示连接信息,便于快速判断状态与进入详情页。">
<template #header>
<input v-model="search" class="glass-input search-input" type="search" placeholder="搜索 ID / 昵称 / 地址" />
</template>
<!-- Client List -->
<div class="glass-card">
<div class="card-header">
<h3>客户端列表</h3>
<div style="display: flex; gap: 8px;">
<button class="glass-btn small" @click="openInstallModal" :disabled="generatingInstall">
{{ generatingInstall ? '生成中...' : '安装命令' }}
</button>
<button class="glass-btn small" @click="loadClients">刷新</button>
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="clients.length === 0" class="empty-state">
<p>暂无客户端连接</p>
<p class="empty-hint">等待客户端连接...</p>
</div>
<div v-else class="clients-grid">
<div
v-for="client in clients"
:key="client.id"
class="client-card"
@click="viewClient(client.id)"
>
<div class="client-header">
<div class="client-status" :class="{ online: client.online }"></div>
<h4 class="client-name">{{ client.nickname || client.id }}</h4>
</div>
<p v-if="client.nickname" class="client-id">{{ client.id }}</p>
<div class="client-info">
<span v-if="client.remote_addr && client.online">{{ client.remote_addr }}</span>
<span>{{ client.rule_count || 0 }} 条规则</span>
</div>
<div class="client-tag" :class="client.online ? 'online' : 'offline'">
{{ client.online ? '在线' : '离线' }}
</div>
<!-- Heartbeat indicator -->
<div class="heartbeat-indicator" :class="{ online: client.online, offline: !client.online }">
<span class="heartbeat-dot"></span>
<div v-if="loading" class="empty-state">正在加载客户端列表...</div>
<div v-else-if="filteredClients.length === 0" class="empty-state">未找到匹配的客户端</div>
<div v-else class="client-grid">
<article v-for="client in filteredClients" :key="client.id" class="client-card" @click="router.push(`/client/${client.id}`)">
<div class="client-card__header">
<div>
<div class="client-card__title">
<span class="status-dot" :class="{ online: client.online }"></span>
<strong>{{ client.nickname || client.id }}</strong>
</div>
<p>{{ client.nickname ? client.id : client.remote_addr || '等待首次连接' }}</p>
</div>
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
</div>
</div>
</div>
</div>
<!-- Install Command Modal -->
<div v-if="showInstallModal" class="modal-overlay" @click="closeInstallModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>客户端安装命令</h3>
<button class="close-btn" @click="closeInstallModal">×</button>
</div>
<div class="modal-body" v-if="installData">
<p class="install-hint">选择您的操作系统复制命令并在目标机器上执行</p>
<div class="install-section">
<h4>Linux</h4>
<div class="command-box">
<code>{{ installData.commands.linux }}</code>
<button class="copy-btn" @click="copyCommand(installData.commands.linux)">复制</button>
<dl class="client-card__meta">
<div>
<dt>地址</dt>
<dd>{{ client.remote_addr || '未上报' }}</dd>
</div>
</div>
<div class="install-section">
<h4>macOS</h4>
<div class="command-box">
<code>{{ installData.commands.macos }}</code>
<button class="copy-btn" @click="copyCommand(installData.commands.macos)">复制</button>
<div>
<dt>规则数</dt>
<dd>{{ client.rule_count || 0 }}</dd>
</div>
</div>
<div class="install-section">
<h4>Windows</h4>
<div class="command-box">
<code>{{ installData.commands.windows }}</code>
<button class="copy-btn" @click="copyCommand(installData.commands.windows)">复制</button>
<div>
<dt>平台</dt>
<dd>{{ [client.os, client.arch].filter(Boolean).join(' / ') || '未知' }}</dd>
</div>
</div>
<p class="token-info">客户端ID: <strong>{{ installClientId }}</strong></p>
<p class="token-warning"> 此命令包含一次性token使用后需重新生成</p>
</div>
</dl>
</article>
</div>
</div>
</div>
</SectionCard>
<GlassModal :show="showInstallModal" title="安装命令" width="760px" @close="showInstallModal = false">
<div v-if="installCommands" class="install-grid">
<article v-for="item in [
{ label: 'Linux', value: installCommands.linux },
{ label: 'macOS', value: installCommands.macos },
{ label: 'Windows', value: installCommands.windows },
]" :key="item.label" class="install-card">
<header>
<strong>{{ item.label }}</strong>
<button class="glass-btn small" @click="copyCommand(item.value)">复制</button>
</header>
<code>{{ item.value }}</code>
</article>
</div>
<template #footer>
<span class="install-footnote">命令内含一次性 token使用后请重新生成</span>
</template>
</GlassModal>
</PageShell>
</template>
<style scoped>
.clients-page {
min-height: calc(100vh - 116px);
position: relative;
overflow: hidden;
padding: 32px;
.search-input {
min-width: min(320px, 100%);
}
/* 动画背景粒子 */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.clients-content {
position: relative;
z-index: 10;
max-width: 1200px;
margin: 0 auto;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.page-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Stats */
.stats-row {
.client-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px;
border: 1px solid var(--color-border);
padding: 20px;
text-align: center;
box-shadow: var(--shadow-card);
transition: all 0.2s ease;
position: relative;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: var(--color-text-primary);
}
.stat-value.online { color: var(--color-success); }
.stat-value.offline { color: var(--color-text-muted); }
.stat-label {
font-size: 13px;
color: var(--color-text-secondary);
}
/* Glass Card */
.glass-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
position: relative;
}
.glass-card::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.card-body { padding: 20px; }
.loading-state, .empty-state {
text-align: center;
padding: 48px;
color: var(--color-text-muted);
}
.empty-hint {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 8px;
}
/* Clients Grid */
.clients-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 900px) {
.clients-grid { grid-template-columns: repeat(2, 1fr); }
.stats-row { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.clients-grid { grid-template-columns: 1fr; }
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.client-card {
background: var(--glass-bg-light);
border-radius: 12px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.client-card:hover {
background: var(--glass-bg-hover);
transform: translateY(-2px);
border-color: rgba(59, 130, 246, 0.24);
box-shadow: var(--shadow-md);
}
.client-header {
.client-card__header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.client-card__title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.client-status {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-text-muted);
}
.client-status.online {
background: var(--color-success);
box-shadow: 0 0 10px var(--color-success-glow);
}
.client-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.client-id {
font-size: 12px;
color: var(--color-text-muted);
margin: 0 0 8px 0;
font-family: monospace;
}
.client-info {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
.client-card__header p {
color: var(--color-text-secondary);
margin-bottom: 12px;
}
.client-tag {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.client-tag.online {
background: rgba(16, 185, 129, 0.15);
color: var(--color-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.client-tag.offline {
background: var(--glass-bg-light);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
/* Button */
.glass-btn {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur-light);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 8px 16px;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.glass-btn:hover {
background: var(--glass-bg-hover);
transform: translateY(-1px);
}
.glass-btn.small { padding: 6px 12px; font-size: 12px; }
/* Heartbeat Indicator */
.heartbeat-indicator {
position: absolute;
top: 18px;
right: 18px;
}
.heartbeat-dot {
display: block;
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border-radius: 999px;
background: var(--color-error);
}
.heartbeat-indicator.online .heartbeat-dot {
.status-dot.online {
background: var(--color-success);
animation: heartbeat-pulse 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--color-success-glow);
}
.heartbeat-indicator.offline .heartbeat-dot {
background: var(--color-error);
animation: none;
.state-pill {
height: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
}
@keyframes heartbeat-pulse {
0%, 100% {
box-shadow: 0 0 0 0 var(--color-success-glow);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
transform: scale(1.1);
}
.state-pill.online {
color: var(--color-success);
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.2);
}
/* Install Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.state-pill.offline {
color: var(--color-text-secondary);
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
}
.modal-content {
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
.client-card__meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.client-card__meta dt {
margin-bottom: 6px;
color: var(--color-text-muted);
font-size: 12px;
}
.client-card__meta dd {
color: var(--color-text-primary);
font-size: 13px;
word-break: break-word;
}
.install-grid {
display: grid;
gap: 14px;
}
.install-card {
padding: 16px;
border-radius: 16px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
.modal-header {
.install-card header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--glass-border);
gap: 12px;
margin-bottom: 12px;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--color-text-secondary);
line-height: 1;
}
.modal-body {
padding: 24px;
}
.install-hint {
margin-bottom: 20px;
color: var(--color-text-secondary);
}
.install-section {
margin-bottom: 20px;
}
.install-section h4 {
margin: 0 0 8px 0;
font-size: 14px;
.install-card code {
display: block;
color: var(--color-text-primary);
}
.command-box {
display: flex;
gap: 8px;
background: rgba(0, 0, 0, 0.3);
padding: 12px;
border-radius: 8px;
border: 1px solid var(--glass-border);
}
.command-box code {
flex: 1;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
color: var(--color-text-primary);
}
.copy-btn {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
color: var(--color-text-primary);
white-space: nowrap;
line-height: 1.7;
}
.copy-btn:hover {
background: var(--glass-bg-hover);
.install-footnote {
color: var(--color-text-secondary);
font-size: 12px;
}
.token-info {
margin-top: 20px;
padding: 12px;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
font-size: 13px;
.empty-state {
padding: 48px 20px;
text-align: center;
color: var(--color-text-secondary);
background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
}
.token-warning {
margin-top: 12px;
padding: 12px;
background: rgba(245, 158, 11, 0.1);
border-radius: 8px;
font-size: 13px;
color: #f59e0b;
}
@media (max-width: 768px) {
.client-card__header {
flex-direction: column;
}
.client-card__meta {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,653 +1,324 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getClients, getTrafficStats, getTrafficHourly, type TrafficRecord } from '../api'
import { computed, onMounted, ref } from 'vue'
import { getClients, getTrafficHourly, getTrafficStats, type TrafficRecord } from '../api'
import type { ClientStatus } from '../types'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
const clients = ref<ClientStatus[]>([])
// 流量统计数据
const traffic24h = ref({ inbound: 0, outbound: 0 })
const trafficTotal = ref({ inbound: 0, outbound: 0 })
const trafficHistory = ref<TrafficRecord[]>([])
const loading = ref(true)
// 格式化字节数
const formatBytes = (bytes: number): { value: string; unit: string } => {
if (bytes === 0) return { value: '0', unit: 'B' }
const k = 1024
const sizes: string[] = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return {
value: parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString(),
unit: sizes[i] as string
value: (bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1),
unit: units[index] ?? 'B',
}
}
// 加载流量统计
const loadTrafficStats = async () => {
const loadDashboard = async () => {
loading.value = true
try {
const { data } = await getTrafficStats()
traffic24h.value = data.traffic_24h
trafficTotal.value = data.traffic_total
} catch (e) {
console.error('Failed to load traffic stats', e)
}
}
const [{ data: clientData }, { data: statsData }, { data: hourlyData }] = await Promise.all([
getClients(),
getTrafficStats(),
getTrafficHourly(),
])
// 加载每小时流量
const loadTrafficHourly = async () => {
try {
const { data } = await getTrafficHourly()
const records = data.records || []
// 如果没有数据生成从当前时间开始的24小时空数据
if (records.length === 0) {
const now = new Date()
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours())
const emptyRecords: TrafficRecord[] = []
for (let i = 23; i >= 0; i--) {
const ts = new Date(currentHour.getTime() - i * 3600 * 1000)
emptyRecords.push({
timestamp: Math.floor(ts.getTime() / 1000),
inbound: 0,
outbound: 0
})
}
trafficHistory.value = emptyRecords
} else {
trafficHistory.value = records
clients.value = clientData || []
traffic24h.value = statsData.traffic_24h
trafficTotal.value = statsData.traffic_total
const records = hourlyData.records || []
if (records.length) {
trafficHistory.value = records.slice(-12)
return
}
} catch (e) {
console.error('Failed to load hourly traffic', e)
const now = new Date()
trafficHistory.value = Array.from({ length: 12 }, (_, index) => {
const slot = new Date(now.getTime() - (11 - index) * 3600 * 1000)
return {
timestamp: Math.floor(slot.getTime() / 1000),
inbound: 0,
outbound: 0,
}
})
} catch (error) {
console.error('Failed to load dashboard', error)
} finally {
loading.value = false
}
}
const loadClients = async () => {
try {
const { data } = await getClients()
clients.value = data || []
} catch (e) {
console.error('Failed to load clients', e)
}
}
const onlineClients = computed(() => {
return clients.value.filter(client => client.online).length
})
const totalRules = computed(() => {
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
})
// 格式化后的流量统计
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
const totalRules = computed(() => clients.value.reduce((sum, client) => sum + (client.rule_count || 0), 0))
const topClients = computed(() => [...clients.value].sort((a, b) => Number(b.online) - Number(a.online)).slice(0, 6))
const chartMax = computed(() => Math.max(...trafficHistory.value.flatMap((item) => [item.inbound, item.outbound]), 1))
const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound))
const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound))
const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound))
const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound))
const formatHour = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
// Chart helpers
const maxTraffic = computed(() => {
const max = Math.max(
...trafficHistory.value.map(d => Math.max(d.inbound, d.outbound))
)
return max || 100
})
const getBarHeight = (value: number) => {
return (value / maxTraffic.value) * 100
}
// 格式化时间戳为小时
const formatHour = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.getHours().toString().padStart(2, '0') + ':00'
}
onMounted(() => {
loadClients()
loadTrafficStats()
loadTrafficHourly()
})
onMounted(loadDashboard)
</script>
<template>
<div class="dashboard-container">
<!-- Animated background particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
<div class="particle particle-4"></div>
<div class="particle particle-5"></div>
<PageShell title="控制台" eyebrow="Overview" subtitle="统一查看连接状态、流量趋势与客户端健康情况,减少页面层级并突出关键数据。">
<template #actions>
<button class="glass-btn" @click="loadDashboard">{{ loading ? '刷新中...' : '刷新数据' }}</button>
</template>
<template #metrics>
<MetricCard label="在线客户端" :value="onlineClients" :hint="`离线 ${offlineClients} 台`" tone="success" />
<MetricCard label="代理规则" :value="totalRules" hint="全部客户端规则总数" />
<MetricCard
label="24H 出站"
:value="formatted24hOutbound.value"
:hint="formatted24hOutbound.unit"
tone="info"
/>
<MetricCard
label="总入站"
:value="formattedTotalInbound.value"
:hint="formattedTotalInbound.unit"
tone="warning"
/>
</template>
<div class="dashboard-grid">
<SectionCard title="流量趋势" description="近 12 小时入站 / 出站流量概览。">
<div class="traffic-summary">
<div class="traffic-pill">
<span>24H 入站</span>
<strong>{{ formatted24hInbound.value }} {{ formatted24hInbound.unit }}</strong>
</div>
<div class="traffic-pill">
<span>24H 出站</span>
<strong>{{ formatted24hOutbound.value }} {{ formatted24hOutbound.unit }}</strong>
</div>
<div class="traffic-pill">
<span>总出站</span>
<strong>{{ formattedTotalOutbound.value }} {{ formattedTotalOutbound.unit }}</strong>
</div>
</div>
<div class="traffic-chart">
<div v-for="item in trafficHistory" :key="item.timestamp" class="traffic-chart__item">
<div class="traffic-chart__bars">
<span class="bar bar--inbound" :style="{ height: `${(item.inbound / chartMax) * 100}%` }"></span>
<span class="bar bar--outbound" :style="{ height: `${(item.outbound / chartMax) * 100}%` }"></span>
</div>
<span class="traffic-chart__label">{{ formatHour(item.timestamp) }}</span>
</div>
</div>
</SectionCard>
<SectionCard title="客户端概况" description="优先展示在线客户端,并保留连接来源与规则数量。">
<div v-if="topClients.length" class="client-list">
<article v-for="client in topClients" :key="client.id" class="client-row">
<div>
<div class="client-row__title">
<span class="client-dot" :class="{ online: client.online }"></span>
<strong>{{ client.nickname || client.id }}</strong>
</div>
<p>{{ client.remote_addr || '等待连接地址' }}</p>
</div>
<div class="client-row__meta">
<span>{{ client.rule_count || 0 }} 条规则</span>
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
</div>
</article>
</div>
<div v-else class="empty-state">暂无客户端数据</div>
</SectionCard>
</div>
<!-- Main content -->
<div class="dashboard-content">
<!-- Header -->
<div class="dashboard-header">
<h1 class="dashboard-title">仪表盘</h1>
<p class="dashboard-subtitle">监控隧道连接和流量状态</p>
</div>
<!-- Stats Grid -->
<div class="stats-grid">
<!-- 24H Traffic Combined -->
<div class="stat-card glass-stat">
<div class="stat-icon-large traffic-24h">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</div>
<div class="stat-details">
<div class="stat-row">
<span class="stat-title">24H 出站</span>
<div class="stat-data">
<span class="stat-number">{{ formatted24hOutbound.value }}</span>
<span class="stat-unit">{{ formatted24hOutbound.unit }}</span>
</div>
</div>
<div class="stat-row">
<span class="stat-title">24H 入站</span>
<div class="stat-data">
<span class="stat-number">{{ formatted24hInbound.value }}</span>
<span class="stat-unit">{{ formatted24hInbound.unit }}</span>
</div>
</div>
</div>
</div>
<!-- Total Traffic Combined -->
<div class="stat-card glass-stat">
<div class="stat-icon-large traffic-total">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="stat-details">
<div class="stat-row">
<span class="stat-title">总出站</span>
<div class="stat-data">
<span class="stat-number">{{ formattedTotalOutbound.value }}</span>
<span class="stat-unit">{{ formattedTotalOutbound.unit }}</span>
</div>
</div>
<div class="stat-row">
<span class="stat-title">总入站</span>
<div class="stat-data">
<span class="stat-number">{{ formattedTotalInbound.value }}</span>
<span class="stat-unit">{{ formattedTotalInbound.unit }}</span>
</div>
</div>
</div>
</div>
<!-- Client Count -->
<div class="stat-card glass-stat">
<div class="stat-icon-large clients">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<div class="stat-details">
<div class="stat-row">
<span class="stat-title">在线客户端</span>
<div class="stat-data">
<span class="stat-number online">{{ onlineClients }}</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-row">
<span class="stat-title">总客户端</span>
<div class="stat-data">
<span class="stat-number">{{ clients.length }}</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
<div class="online-indicator" :class="{ active: onlineClients > 0 }">
<span class="pulse"></span>
</div>
</div>
<!-- Rules Count -->
<div class="stat-card glass-stat">
<div class="stat-icon-large rules">
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</div>
<div class="stat-details">
<div class="stat-row single">
<span class="stat-title">代理规则</span>
<div class="stat-data">
<span class="stat-number">{{ totalRules }}</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Traffic Chart Section -->
<div class="chart-section">
<div class="section-header">
<h2 class="section-title">24小时流量趋势</h2>
<div class="chart-legend">
<span class="legend-item inbound"><span class="legend-dot"></span>入站</span>
<span class="legend-item outbound"><span class="legend-dot"></span>出站</span>
</div>
</div>
<div class="chart-card glass-card">
<div class="chart-container">
<div class="chart-bars">
<div v-for="(data, index) in trafficHistory" :key="index" class="bar-group">
<div class="bar-wrapper">
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
</div>
<span class="bar-label">{{ formatHour(data.timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</PageShell>
</template>
<style scoped>
/* Container */
.dashboard-container {
min-height: calc(100vh - 116px);
position: relative;
overflow: hidden;
padding: 32px;
}
/* 动画背景粒子 */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 400px;
height: 400px;
background: var(--color-accent);
top: -100px;
right: -100px;
}
.particle-2 {
width: 300px;
height: 300px;
background: #8b5cf6;
bottom: -50px;
left: -50px;
animation-delay: -5s;
}
.particle-3 {
width: 250px;
height: 250px;
background: var(--color-info);
top: 50%;
left: 50%;
animation-delay: -10s;
}
.particle-4 {
width: 200px;
height: 200px;
background: var(--color-success);
bottom: 20%;
right: 20%;
animation-delay: -15s;
}
.particle-5 {
width: 350px;
height: 350px;
background: #ec4899;
top: 30%;
left: 10%;
animation-delay: -7s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
/* Main content */
.dashboard-content {
position: relative;
z-index: 10;
max-width: 1200px;
margin: 0 auto;
}
.dashboard-header {
margin-bottom: 32px;
}
.dashboard-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.dashboard-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Stats Grid */
.stats-grid {
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 32px;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.traffic-summary {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
/* Glass stat card - 毛玻璃效果 */
.glass-stat {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
.traffic-pill {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--color-border);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
position: relative;
transition: all 0.2s ease;
box-shadow: var(--shadow-card);
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
/* 卡片顶部高光 */
.glass-stat::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
}
.glass-stat:hover {
background: var(--glass-bg-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
/* Large Stat icon */
.stat-icon-large {
width: 64px;
height: 64px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.stat-icon-large.traffic-24h {
background: var(--gradient-accent);
}
.stat-icon-large.traffic-total {
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
}
.stat-icon-large.clients {
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
}
.stat-icon-large.rules {
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
}
.icon-lg {
width: 32px;
height: 32px;
color: white;
}
/* Stat details */
.stat-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-row.single {
padding: 12px 0;
}
.stat-title {
font-size: 13px;
color: var(--color-text-secondary);
font-weight: 500;
}
.stat-data {
display: flex;
align-items: baseline;
gap: 4px;
}
.stat-number {
font-size: 20px;
font-weight: 700;
color: var(--color-text-primary);
}
.stat-number.online {
color: var(--color-success);
}
.stat-unit {
font-size: 12px;
color: var(--color-text-muted);
}
/* Online indicator with pulse */
.online-indicator {
position: absolute;
top: 16px;
right: 16px;
}
.online-indicator .pulse {
.traffic-pill span {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-text-muted);
}
.online-indicator.active .pulse {
background: var(--color-success);
animation: pulse-animation 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--color-success-glow);
}
@keyframes pulse-animation {
0%, 100% { box-shadow: 0 0 0 0 var(--color-success-glow); }
50% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
}
/* Chart Section */
.chart-section {
margin-top: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.chart-legend {
display: flex;
gap: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-secondary);
font-size: 12px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
.traffic-pill strong {
display: block;
margin-top: 8px;
color: var(--color-text-primary);
font-size: 18px;
}
.legend-item.inbound .legend-dot {
background: #8b5cf6;
.traffic-chart {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
align-items: end;
min-height: 240px;
}
.legend-item.outbound .legend-dot {
background: var(--color-accent);
}
/* Chart Card */
.chart-card {
padding: 24px;
}
.chart-container {
height: 200px;
overflow-x: auto;
}
.chart-bars {
display: flex;
gap: 4px;
height: 100%;
min-width: 600px;
align-items: flex-end;
}
.bar-group {
flex: 1;
.traffic-chart__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 10px;
}
.bar-wrapper {
flex: 1;
width: 100%;
.traffic-chart__bars {
display: flex;
gap: 2px;
align-items: flex-end;
align-items: end;
gap: 4px;
height: 200px;
width: 100%;
}
.bar {
flex: 1;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: height 0.3s ease;
min-height: 4px;
border-radius: 999px 999px 6px 6px;
}
.bar.inbound {
background: #8b5cf6;
.bar--inbound {
background: linear-gradient(180deg, rgba(6, 182, 212, 0.95), rgba(6, 182, 212, 0.25));
}
.bar.outbound {
background: var(--color-accent);
.bar--outbound {
background: linear-gradient(180deg, rgba(59, 130, 246, 0.95), rgba(59, 130, 246, 0.25));
}
.bar-label {
font-size: 10px;
color: var(--color-text-muted);
white-space: nowrap;
}
.chart-hint {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
text-align: center;
font-size: 12px;
.traffic-chart__label {
font-size: 11px;
color: var(--color-text-muted);
}
/* Glass card base - 毛玻璃效果 */
.glass-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
.client-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.client-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
position: relative;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
.glass-card::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%);
.client-row__title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.client-row p {
color: var(--color-text-secondary);
font-size: 13px;
}
.client-row__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
color: var(--color-text-secondary);
font-size: 13px;
}
.client-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--color-error);
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.08);
}
.client-dot.online {
background: var(--color-success);
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.1);
}
.state-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid transparent;
}
.state-pill.online {
color: var(--color-success);
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.2);
}
.state-pill.offline {
color: var(--color-text-secondary);
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
}
.empty-state {
padding: 36px 16px;
text-align: center;
color: var(--color-text-secondary);
background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.traffic-chart {
overflow-x: auto;
padding-bottom: 8px;
}
.traffic-chart__bars {
width: 28px;
}
.client-row {
flex-direction: column;
}
.client-row__meta {
align-items: flex-start;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { login, setToken } from '../api'
@@ -9,6 +9,14 @@ const password = ref('')
const error = ref('')
const loading = ref(false)
const features = [
'统一管理隧道、客户端与规则状态',
'自动下发配置,客户端零配置接入',
'内置更新与运行状态查看,便于运维排障',
]
const canSubmit = computed(() => Boolean(username.value && password.value) && !loading.value)
const handleLogin = async () => {
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
@@ -17,7 +25,6 @@ const handleLogin = async () => {
loading.value = true
error.value = ''
try {
const { data } = await login(username.value, password.value)
setToken(data.token)
@@ -32,65 +39,39 @@ const handleLogin = async () => {
<template>
<div class="login-page">
<!-- Animated particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
<div class="particle particle-4"></div>
</div>
<div class="login-shell glass-card">
<section class="login-hero">
<span class="login-badge">GoTunnel Console</span>
<h1>更统一更轻量的管理界面</h1>
<p>聚焦连接状态更新能力与节点管理让日常操作更直观页面更简洁</p>
<ul>
<li v-for="item in features" :key="item">{{ item }}</li>
</ul>
</section>
<!-- Login card -->
<div class="login-card">
<div class="login-header">
<div class="logo-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 class="logo-text">GoTunnel</h1>
<p class="subtitle">安全的内网穿透工具</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label class="form-label">用户名</label>
<input
v-model="username"
type="text"
class="glass-input"
placeholder="请输入用户名"
:disabled="loading"
/>
<section class="login-panel">
<div class="login-panel__header">
<h2>登录控制台</h2>
<p>使用服务端配置的 Web 账号进入管理界面</p>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input
v-model="password"
type="password"
class="glass-input"
placeholder="请输入密码"
:disabled="loading"
/>
</div>
<form class="login-form" @submit.prevent="handleLogin">
<label class="form-group">
<span>用户名</span>
<input v-model="username" class="glass-input" type="text" autocomplete="username" placeholder="请输入用户名" />
</label>
<label class="form-group">
<span>密码</span>
<input v-model="password" class="glass-input" type="password" autocomplete="current-password" placeholder="请输入密码" />
</label>
<div v-if="error" class="error-alert">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ error }}</span>
</div>
<div v-if="error" class="error-alert">{{ error }}</div>
<button type="submit" class="glass-button" :disabled="loading">
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<div class="login-footer">
<span>欢迎使用 GoTunnel</span>
</div>
<button class="glass-btn primary submit-btn" type="submit" :disabled="!canSubmit">
{{ loading ? '登录中...' : '进入控制台' }}
</button>
</form>
</section>
</div>
</div>
</template>
@@ -101,145 +82,94 @@ const handleLogin = async () => {
display: flex;
align-items: center;
justify-content: center;
background: var(--gradient-bg);
padding: 16px;
position: relative;
padding: 24px;
}
.login-shell {
width: min(1080px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
overflow: hidden;
}
/* 动画背景粒子 */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
.login-hero,
.login-panel {
padding: 40px;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.2;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 400px;
height: 400px;
background: var(--color-accent);
top: -100px;
right: -100px;
}
.particle-2 {
width: 300px;
height: 300px;
background: #8b5cf6;
bottom: -50px;
left: -50px;
animation-delay: -5s;
}
.particle-3 {
width: 250px;
height: 250px;
background: var(--color-info);
top: 50%;
left: 20%;
animation-delay: -10s;
}
.particle-4 {
width: 200px;
height: 200px;
background: #ec4899;
bottom: 30%;
right: 10%;
animation-delay: -15s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
/* Login card - 毛玻璃效果 */
.login-card {
width: 100%;
max-width: 400px;
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 20px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
padding: 48px 36px;
position: relative;
z-index: 10;
}
/* 卡片顶部高光 */
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%);
}
/* Header */
.login-header {
text-align: center;
margin-bottom: 36px;
}
.logo-icon {
width: 64px;
height: 64px;
margin: 0 auto 20px;
background: var(--gradient-accent);
border-radius: 16px;
.login-hero {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
box-shadow: 0 8px 24px var(--color-accent-glow);
gap: 18px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(139, 92, 246, 0.08));
border-right: 1px solid var(--color-border);
}
.logo-icon svg {
color: white;
width: 32px;
height: 32px;
.login-badge {
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--color-accent);
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.18);
}
.logo-text {
font-size: 28px;
font-weight: 700;
background: var(--gradient-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 8px 0;
}
.subtitle {
color: var(--color-text-secondary);
.login-hero h1 {
margin: 0;
font-size: 14px;
font-size: clamp(34px, 4.5vw, 54px);
line-height: 1.1;
letter-spacing: -0.05em;
}
.login-hero p {
margin: 0;
color: var(--color-text-secondary);
font-size: 15px;
line-height: 1.8;
}
.login-hero ul {
display: grid;
gap: 12px;
padding: 0;
margin: 8px 0 0;
list-style: none;
}
.login-hero li {
padding: 14px 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
color: var(--color-text-primary);
}
.login-panel {
display: flex;
flex-direction: column;
justify-content: center;
gap: 26px;
}
.login-panel__header h2 {
margin: 0 0 8px;
font-size: 28px;
}
.login-panel__header p {
margin: 0;
color: var(--color-text-secondary);
line-height: 1.7;
}
/* Form */
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.form-group {
@@ -248,111 +178,44 @@ const handleLogin = async () => {
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.glass-input {
background: var(--glass-bg-light);
backdrop-filter: var(--glass-blur-light);
-webkit-backdrop-filter: var(--glass-blur-light);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 14px 16px;
color: var(--color-text-primary);
font-size: 15px;
width: 100%;
transition: all 0.2s ease;
outline: none;
}
.glass-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-glow);
}
.glass-input::placeholder {
color: var(--color-text-muted);
}
.glass-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Error alert */
.error-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 10px;
color: var(--color-error);
font-size: 14px;
}
.error-alert svg {
flex-shrink: 0;
}
/* Button */
.glass-button {
background: var(--gradient-accent);
border: none;
border-radius: 12px;
padding: 14px 24px;
color: white;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 15px var(--color-accent-glow);
}
.glass-button:hover:not(:disabled) {
box-shadow: 0 6px 20px var(--color-accent-glow);
transform: translateY(-2px);
filter: brightness(1.1);
}
.glass-button:active:not(:disabled) {
transform: translateY(0) scale(0.98);
}
.glass-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Loading spinner */
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Footer */
.login-footer {
text-align: center;
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
.form-group span {
color: var(--color-text-secondary);
font-size: 13px;
}
.submit-btn {
justify-content: center;
width: 100%;
margin-top: 8px;
}
.error-alert {
padding: 12px 14px;
border-radius: 14px;
color: var(--color-error);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.18);
}
@media (max-width: 900px) {
.login-shell {
grid-template-columns: 1fr;
}
.login-hero {
border-right: none;
border-bottom: 1px solid var(--color-border);
}
}
@media (max-width: 640px) {
.login-page {
padding: 16px;
}
.login-hero,
.login-panel {
padding: 28px 22px;
}
}
</style>

View File

@@ -1,88 +1,90 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
import { useToast } from '../composables/useToast'
import { onMounted, ref } from 'vue'
import MetricCard from '../components/MetricCard.vue'
import PageShell from '../components/PageShell.vue'
import SectionCard from '../components/SectionCard.vue'
import {
getVersionInfo, getServerConfig, updateServerConfig,
type VersionInfo, type ServerConfigResponse
getServerConfig,
getVersionInfo,
updateServerConfig,
type ServerConfigResponse,
type UpdateServerConfigRequest,
type VersionInfo,
} from '../api'
import { useToast } from '../composables/useToast'
const message = useToast()
const versionInfo = ref<VersionInfo | null>(null)
const loading = ref(true)
// 服务器配置
const serverConfig = ref<ServerConfigResponse | null>(null)
const configLoading = ref(false)
const savingConfig = ref(false)
const loadingVersion = ref(true)
const loadingConfig = ref(true)
const saving = ref(false)
// 配置表单
const configForm = ref({
heartbeat_sec: 30,
heartbeat_timeout: 90,
web_username: '',
web_password: '',
plugin_store_url: ''
})
const loadVersionInfo = async () => {
loadingVersion.value = true
try {
const { data } = await getVersionInfo()
versionInfo.value = data
} catch (e) {
console.error('Failed to load version info', e)
} catch (error) {
console.error('Failed to load version info', error)
} finally {
loading.value = false
loadingVersion.value = false
}
}
const loadServerConfig = async () => {
configLoading.value = true
loadingConfig.value = true
try {
const { data } = await getServerConfig()
serverConfig.value = data
// 填充表单
configForm.value = {
heartbeat_sec: data.server.heartbeat_sec,
heartbeat_timeout: data.server.heartbeat_timeout,
web_username: data.web.username,
web_password: '',
plugin_store_url: data.plugin_store.url
}
} catch (e) {
console.error('Failed to load server config', e)
} catch (error) {
console.error('Failed to load server config', error)
message.error('服务器配置加载失败')
} finally {
configLoading.value = false
loadingConfig.value = false
}
}
const handleSaveConfig = async () => {
savingConfig.value = true
saving.value = true
try {
const updateReq: any = {
const payload: UpdateServerConfigRequest = {
server: {
heartbeat_sec: configForm.value.heartbeat_sec,
heartbeat_timeout: configForm.value.heartbeat_timeout
heartbeat_timeout: configForm.value.heartbeat_timeout,
},
web: {
username: configForm.value.web_username
username: configForm.value.web_username,
},
plugin_store: {
url: configForm.value.plugin_store_url
}
if (configForm.value.web_password) {
payload.web = {
...payload.web,
password: configForm.value.web_password,
}
}
// 只有填写了密码才更新
if (configForm.value.web_password) {
updateReq.web.password = configForm.value.web_password
}
await updateServerConfig(updateReq)
message.success('配置已保存,部分配置需要重启服务后生效')
await updateServerConfig(payload)
configForm.value.web_password = ''
} catch (e: any) {
message.error(e.response?.data || '保存配置失败')
message.success('配置已保存,部分配置需要重启后生效')
} catch (error: any) {
message.error(error.response?.data || '保存配置失败')
} finally {
savingConfig.value = false
saving.value = false
}
}
@@ -93,458 +95,122 @@ onMounted(() => {
</script>
<template>
<div class="settings-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
<PageShell title="系统设置" eyebrow="Settings" subtitle="统一整理运行版本与服务配置,减少样式重复并保留关键运维操作。">
<template #actions>
<button class="glass-btn" @click="loadVersionInfo">刷新版本</button>
<button class="glass-btn primary" :disabled="saving" @click="handleSaveConfig">{{ saving ? '保存中...' : '保存配置' }}</button>
</template>
<template #metrics>
<MetricCard label="当前版本" :value="versionInfo?.version || '—'" :hint="versionInfo?.git_commit?.slice(0, 8) || '未知提交'" />
<MetricCard label="Go 版本" :value="versionInfo?.go_version || '—'" hint="运行时版本" tone="info" />
<MetricCard label="运行平台" :value="versionInfo ? `${versionInfo.os}/${versionInfo.arch}` : '—'" hint="服务端当前平台" tone="success" />
<MetricCard label="Web 用户名" :value="configForm.web_username || '—'" hint="控制台登录账号" tone="warning" />
</template>
<div class="settings-grid">
<SectionCard title="版本信息" description="查看当前服务端构建信息,方便排查环境与升级状态。">
<div v-if="loadingVersion" class="empty-state">正在加载版本信息...</div>
<dl v-else-if="versionInfo" class="info-grid">
<div><dt>版本号</dt><dd>{{ versionInfo.version }}</dd></div>
<div><dt>Git 提交</dt><dd>{{ versionInfo.git_commit || 'N/A' }}</dd></div>
<div><dt>构建时间</dt><dd>{{ versionInfo.build_time || 'N/A' }}</dd></div>
<div><dt>Go 版本</dt><dd>{{ versionInfo.go_version }}</dd></div>
<div><dt>操作系统</dt><dd>{{ versionInfo.os }}</dd></div>
<div><dt>架构</dt><dd>{{ versionInfo.arch }}</dd></div>
</dl>
<div v-else class="empty-state">无法获取版本信息</div>
</SectionCard>
<SectionCard title="服务配置" description="保留最常用的心跳与登录项配置,页面结构更精简。">
<div v-if="loadingConfig" class="empty-state">正在加载服务器配置...</div>
<form v-else class="config-form" @submit.prevent="handleSaveConfig">
<label class="form-group">
<span>心跳间隔</span>
<input v-model.number="configForm.heartbeat_sec" class="glass-input" min="1" max="300" type="number" />
</label>
<label class="form-group">
<span>心跳超时</span>
<input v-model.number="configForm.heartbeat_timeout" class="glass-input" min="1" max="600" type="number" />
</label>
<label class="form-group form-group--full">
<span>Web 用户名</span>
<input v-model="configForm.web_username" class="glass-input" type="text" placeholder="admin" />
</label>
<label class="form-group form-group--full">
<span>Web 密码</span>
<input v-model="configForm.web_password" class="glass-input" type="password" placeholder="留空则保持不变" />
</label>
</form>
</SectionCard>
</div>
<div class="settings-content">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">系统设置</h1>
<p class="page-subtitle">管理服务端配置和系统更新</p>
</div>
<!-- Version Info Card -->
<div class="glass-card">
<div class="card-header">
<h3>版本信息</h3>
<ServerOutline class="header-icon" />
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="versionInfo" class="info-grid">
<div class="info-item">
<span class="info-label">版本号</span>
<span class="info-value">{{ versionInfo.version }}</span>
</div>
<div class="info-item">
<span class="info-label">Git 提交</span>
<span class="info-value mono">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">构建时间</span>
<span class="info-value">{{ versionInfo.build_time || 'N/A' }}</span>
</div>
<div class="info-item">
<span class="info-label">Go 版本</span>
<span class="info-value">{{ versionInfo.go_version }}</span>
</div>
<div class="info-item">
<span class="info-label">操作系统</span>
<span class="info-value">{{ versionInfo.os }}</span>
</div>
<div class="info-item">
<span class="info-label">架构</span>
<span class="info-value">{{ versionInfo.arch }}</span>
</div>
</div>
<div v-else class="empty-state">无法加载版本信息</div>
</div>
</div>
<!-- Server Config Card -->
<div class="glass-card">
<div class="card-header">
<h3>服务器配置</h3>
<SettingsOutline class="header-icon" />
</div>
<div class="card-body">
<div v-if="configLoading" class="loading-state">加载中...</div>
<div v-else-if="serverConfig" class="config-form">
<div class="form-row">
<div class="form-group">
<label class="form-label">心跳间隔 ()</label>
<input
v-model.number="configForm.heartbeat_sec"
type="number"
class="glass-input"
min="1"
max="300"
/>
</div>
<div class="form-group">
<label class="form-label">心跳超时 ()</label>
<input
v-model.number="configForm.heartbeat_timeout"
type="number"
class="glass-input"
min="1"
max="600"
/>
</div>
</div>
<div class="form-divider"></div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Web 用户名</label>
<input
v-model="configForm.web_username"
type="text"
class="glass-input"
placeholder="admin"
/>
</div>
<div class="form-group">
<label class="form-label">Web 密码</label>
<input
v-model="configForm.web_password"
type="password"
class="glass-input"
placeholder="留空则不修改"
/>
</div>
</div>
<div class="form-divider"></div>
<div class="form-group">
<label class="form-label">插件商店地址</label>
<input
v-model="configForm.plugin_store_url"
type="text"
class="glass-input"
placeholder="https://git.92coco.cn/flik/GoTunnel-Plugins/raw/branch/main/store.json"
/>
<span class="form-hint">插件商店的 API 地址留空使用默认地址</span>
</div>
<div class="form-actions">
<button
class="glass-btn primary"
:disabled="savingConfig"
@click="handleSaveConfig"
>
<SaveOutline class="btn-icon" />
保存配置
</button>
</div>
</div>
<div v-else class="empty-state">无法加载配置信息</div>
</div>
</div>
</div>
</div>
</PageShell>
</template>
<style scoped>
.settings-page {
min-height: calc(100vh - 108px);
background: transparent;
position: relative;
overflow: hidden;
padding: 32px;
.settings-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* Hide particles */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.settings-content {
position: relative;
z-index: 10;
max-width: 900px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 8px 0;
}
.page-subtitle {
color: var(--color-text-secondary);
margin: 0;
font-size: 14px;
}
/* Glass Card */
.glass-card {
background: var(--color-bg-tertiary);
border-radius: 12px;
border: 1px solid var(--color-border);
margin-bottom: 20px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.card-body {
padding: 20px;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 600px) {
.info-grid { grid-template-columns: repeat(2, 1fr); }
.info-grid div,
.form-group {
padding: 16px;
border-radius: 16px;
background: var(--glass-bg-light);
border: 1px solid var(--color-border-light);
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 12px;
color: var(--color-text-muted);
}
.info-value {
font-size: 14px;
color: var(--color-text-primary);
font-weight: 500;
}
.info-value.mono {
font-family: monospace;
}
/* States */
.loading-state, .empty-state {
text-align: center;
padding: 32px;
color: var(--color-text-muted);
}
/* Update Alert */
.update-alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
}
.update-alert.success {
background: rgba(0, 186, 124, 0.15);
border: 1px solid rgba(0, 186, 124, 0.3);
color: var(--color-success);
}
.update-alert.info {
background: rgba(29, 155, 240, 0.15);
border: 1px solid rgba(29, 155, 240, 0.3);
color: var(--color-info);
}
/* Download Info */
.download-info {
color: var(--color-text-secondary);
font-size: 13px;
margin-bottom: 12px;
}
/* Release Note */
.release-note {
margin-bottom: 16px;
}
.note-label {
.info-grid dt,
.form-group span {
display: block;
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 6px;
}
.release-note pre {
margin: 0;
white-space: pre-wrap;
font-size: 12px;
margin-bottom: 8px;
color: var(--color-text-secondary);
background: var(--color-bg-elevated);
padding: 12px;
border-radius: 8px;
max-height: 150px;
overflow-y: auto;
}
/* Glass Button */
.glass-btn {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px 16px;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.glass-btn:hover:not(:disabled) {
background: var(--color-border);
.info-grid dd {
color: var(--color-text-primary);
word-break: break-word;
}
.glass-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.glass-btn.small {
padding: 6px 12px;
font-size: 12px;
}
.glass-btn.primary {
background: var(--color-accent);
border: none;
}
.glass-btn.primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
/* Icon styles */
.header-icon {
width: 20px;
height: 20px;
color: var(--color-text-muted);
}
.btn-icon {
width: 14px;
height: 14px;
}
/* Config Form */
.config-form {
display: flex;
flex-direction: column;
gap: 16px;
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.form-label {
font-size: 13px;
.form-group--full {
grid-column: 1 / -1;
}
.empty-state {
padding: 48px 20px;
text-align: center;
color: var(--color-text-secondary);
font-weight: 500;
background: var(--glass-bg-light);
border: 1px dashed var(--color-border);
border-radius: 16px;
}
.form-hint {
font-size: 11px;
color: var(--color-text-muted);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 500px) {
.form-row {
@media (max-width: 960px) {
.settings-grid,
.info-grid,
.config-form {
grid-template-columns: 1fr;
}
}
.form-divider {
height: 1px;
background: var(--color-border-light);
margin: 8px 0;
}
.form-actions {
margin-top: 8px;
}
/* Glass Input */
.glass-input {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 10px 14px;
color: var(--color-text-primary);
font-size: 14px;
outline: none;
transition: all 0.15s;
}
.glass-input:focus {
border-color: var(--color-accent);
}
.glass-input::placeholder {
color: var(--color-text-muted);
}
</style>