Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b161b7ac79 | |||
| 0a932211f1 | |||
| cdc1dd60d1 | |||
| 21621b15f4 | |||
| 4b09fe817d | |||
| f644d3764a | |||
| 4210ab7675 | |||
| 6558d1acdb | |||
|
|
8901581d0c | ||
|
|
c6bab83e24 | ||
|
|
9cd74b43d0 | ||
|
|
2d8cc8ebe5 | ||
| 58bb324d82 | |||
| bed78a36d0 | |||
| e4999abf47 | |||
| 937536e422 | |||
| 838bde28f0 | |||
| 743b91f3bf | |||
| 5a03d9e1f1 | |||
| dcfd2f4466 | |||
| f13dc2451c | |||
| 1c71a7633b | |||
| b7a1a249a4 | |||
| 5cee8daabc | |||
|
|
a9ca714b24 | ||
|
|
d6627a292d | ||
| e0d88e9ad7 | |||
|
|
ba9edd3c02 | ||
|
|
e40d079f7a | ||
|
|
8ce5b149f7 | ||
|
|
0a41e10793 | ||
|
|
3386b0fcb6 | ||
|
|
98a5525e6d | ||
|
|
5c8020d5fb | ||
|
|
6496d56e0e |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
152
.github/workflows/build.yml
vendored
Normal file
152
.github/workflows/build.yml
vendored
Normal 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
278
.github/workflows/release.yml
vendored
Normal 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/*
|
||||
113
AGENTS.md
Normal file
113
AGENTS.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Build server and client binaries
|
||||
go build -o server ./cmd/server
|
||||
go build -o client ./cmd/client
|
||||
|
||||
# Run server (zero-config, auto-generates token and TLS cert)
|
||||
./server
|
||||
./server -c server.yaml # with config file
|
||||
|
||||
# Run client
|
||||
./client -s <server>:7000 -t <token>
|
||||
|
||||
# Web UI development (in web/ directory)
|
||||
cd web && npm install && npm run dev # development server
|
||||
cd web && npm run build # production build (outputs to web/dist/)
|
||||
|
||||
# Cross-platform build (Windows PowerShell)
|
||||
.\scripts\build.ps1
|
||||
|
||||
# Cross-platform build (Linux/Mac)
|
||||
./scripts/build.sh all
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
GoTunnel is an intranet penetration tool (similar to frp) with **server-centric configuration** - clients require zero configuration and receive mapping rules from the server after authentication.
|
||||
|
||||
### Core Design
|
||||
|
||||
- **Yamux Multiplexing**: Single TCP connection carries both control (auth, config, heartbeat) and data channels
|
||||
- **Binary Protocol**: `[Type(1 byte)][Length(4 bytes)][Payload(JSON)]` - see `pkg/protocol/message.go`
|
||||
- **TLS by Default**: Auto-generated self-signed ECDSA P-256 certificates, no manual setup required
|
||||
- **Embedded Web UI**: Vue.js SPA embedded in server binary via `//go:embed`
|
||||
- **JS Plugin System**: Extensible plugin system using goja JavaScript runtime
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
cmd/server/ # Server entry point
|
||||
cmd/client/ # Client entry point
|
||||
internal/server/
|
||||
├── tunnel/ # Core tunnel server, client session management
|
||||
├── config/ # YAML configuration loading
|
||||
├── db/ # SQLite storage (ClientStore, JSPluginStore interfaces)
|
||||
├── app/ # Web server, SPA handler
|
||||
├── router/ # REST API endpoints (Swagger documented)
|
||||
└── plugin/ # Server-side JS plugin manager
|
||||
internal/client/
|
||||
└── tunnel/ # Client tunnel logic, auto-reconnect, plugin execution
|
||||
pkg/
|
||||
├── protocol/ # Message types and serialization
|
||||
├── crypto/ # TLS certificate generation
|
||||
├── relay/ # Bidirectional data relay (32KB buffers)
|
||||
├── auth/ # JWT authentication
|
||||
├── utils/ # Port availability checking
|
||||
├── 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)
|
||||
```
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
- `ClientStore` (internal/server/db/): Database abstraction for client rules storage
|
||||
- `ServerInterface` (internal/server/router/handler/): API handler interface
|
||||
|
||||
### Proxy Types
|
||||
|
||||
1. **TCP** (default): Direct port forwarding (remote_port → local_ip:local_port)
|
||||
2. **UDP**: UDP port forwarding
|
||||
3. **HTTP**: HTTP proxy through client network
|
||||
4. **HTTPS**: HTTPS proxy through client network
|
||||
5. **SOCKS5**: SOCKS5 proxy through client network
|
||||
|
||||
### Data Flow
|
||||
|
||||
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)
|
||||
- Default ports: 7000 (tunnel), 7500 (web console)
|
||||
|
||||
## API Documentation
|
||||
|
||||
The server provides Swagger-documented REST APIs at `/api/`.
|
||||
|
||||
### Key Endpoints
|
||||
|
||||
- `POST /api/auth/login` - JWT authentication
|
||||
- `GET /api/clients` - List all clients
|
||||
- `GET /api/client/{id}` - Get client details
|
||||
- `PUT /api/client/{id}` - Update client config
|
||||
- `POST /api/client/{id}/push` - Push config to online client
|
||||
- `POST /api/client/{id}/plugin/{name}/{action}` - Plugin actions (start/stop/restart/delete)
|
||||
- `GET /api/plugins` - List registered plugins
|
||||
- `GET /api/update/check/server` - Check server updates
|
||||
- `POST /api/update/apply/server` - Apply server update
|
||||
|
||||
## Update System
|
||||
|
||||
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
|
||||
- Updates can be triggered from the Web UI at `/update` page
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -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,14 +59,8 @@ pkg/
|
||||
├── relay/ # Bidirectional data relay (32KB buffers)
|
||||
├── auth/ # JWT authentication
|
||||
├── utils/ # Port availability checking
|
||||
├── version/ # Version info and update checking (Gitea API)
|
||||
├── update/ # Shared update logic (download, extract tar.gz/zip)
|
||||
└── plugin/ # Plugin system core
|
||||
├── types.go # Plugin interfaces
|
||||
├── registry.go # Plugin registry
|
||||
├── script/ # JS plugin runtime (goja)
|
||||
├── sign/ # Plugin signature verification
|
||||
└── store/ # Plugin persistence (SQLite)
|
||||
├── 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)
|
||||
```
|
||||
@@ -75,23 +68,16 @@ scripts/ # Build scripts (build.sh, build.ps1)
|
||||
### Key Interfaces
|
||||
|
||||
- `ClientStore` (internal/server/db/): Database abstraction for client rules storage
|
||||
- `JSPluginStore` (internal/server/db/): JS plugin persistence
|
||||
- `ServerInterface` (internal/server/router/handler/): API handler interface
|
||||
- `ClientPlugin` (pkg/plugin/): Plugin interface for client-side plugins
|
||||
|
||||
### Proxy Types
|
||||
|
||||
**内置类型** (直接在 tunnel 中处理):
|
||||
1. **TCP** (default): Direct port forwarding (remote_port → local_ip:local_port)
|
||||
2. **UDP**: UDP port forwarding
|
||||
3. **HTTP**: HTTP proxy through client network
|
||||
4. **HTTPS**: HTTPS proxy through client network
|
||||
5. **SOCKS5**: SOCKS5 proxy through client network
|
||||
|
||||
**JS 插件类型** (通过 goja 运行时):
|
||||
- Custom application plugins (file-server, api-server, etc.)
|
||||
- Runs on client side with sandbox restrictions
|
||||
|
||||
### Data Flow
|
||||
|
||||
External User → Server Port → Yamux Stream → Client → Local Service
|
||||
@@ -99,47 +85,9 @@ 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)
|
||||
|
||||
## Plugin System
|
||||
|
||||
GoTunnel supports a JavaScript-based plugin system using the goja runtime.
|
||||
|
||||
### Plugin Architecture
|
||||
|
||||
- **内置协议**: tcp, udp, http, https, socks5 直接在 tunnel 代码中处理
|
||||
- **JS Plugins**: 自定义应用插件通过 goja 运行时在客户端执行
|
||||
- **Plugin Store**: 从官方商店浏览和安装插件
|
||||
- **Signature Verification**: 插件需要签名验证才能运行
|
||||
|
||||
### JS Plugin Lifecycle
|
||||
|
||||
```javascript
|
||||
function metadata() {
|
||||
return {
|
||||
name: "plugin-name",
|
||||
version: "1.0.0",
|
||||
type: "app",
|
||||
description: "Plugin description",
|
||||
author: "Author"
|
||||
};
|
||||
}
|
||||
|
||||
function start() { /* called on plugin start */ }
|
||||
function handleConn(conn) { /* handle each connection */ }
|
||||
function stop() { /* called on plugin stop */ }
|
||||
```
|
||||
|
||||
### Plugin APIs
|
||||
|
||||
- **Basic**: `log()`, `config()`
|
||||
- **Connection**: `conn.Read()`, `conn.Write()`, `conn.Close()`
|
||||
- **File System**: `fs.readFile()`, `fs.writeFile()`, `fs.readDir()`, `fs.stat()`, etc.
|
||||
- **HTTP**: `http.serve()`, `http.json()`, `http.sendFile()`
|
||||
|
||||
See `PLUGINS.md` for detailed plugin development documentation.
|
||||
|
||||
## API Documentation
|
||||
|
||||
The server provides Swagger-documented REST APIs at `/api/`.
|
||||
@@ -158,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
|
||||
|
||||
65
Makefile
65
Makefile
@@ -1,16 +1,13 @@
|
||||
# GoTunnel Makefile
|
||||
|
||||
.PHONY: all build-frontend sync-frontend build-server build-client clean help
|
||||
.PHONY: all build-frontend sync-frontend sync-only build-server build-client build-all-platforms build-current-platform build-android clean help
|
||||
|
||||
# 默认目标
|
||||
all: build-frontend sync-frontend build-server build-client
|
||||
all: build-frontend sync-frontend build-current-platform
|
||||
|
||||
# 构建前端
|
||||
build-frontend:
|
||||
@echo "Building frontend..."
|
||||
cd web && npm ci && npm run build
|
||||
|
||||
# 同步前端到 embed 目录
|
||||
sync-frontend:
|
||||
@echo "Syncing frontend to embed directory..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -21,7 +18,6 @@ else
|
||||
cp -r web/dist internal/server/app/dist
|
||||
endif
|
||||
|
||||
# 仅同步(不重新构建前端)
|
||||
sync-only:
|
||||
@echo "Syncing existing frontend build..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -32,33 +28,38 @@ else
|
||||
cp -r web/dist internal/server/app/dist
|
||||
endif
|
||||
|
||||
# 构建服务端(当前平台)
|
||||
build-server:
|
||||
@echo "Building server..."
|
||||
go build -ldflags="-s -w" -o gotunnel-server ./cmd/server
|
||||
@echo "Building server for current platform..."
|
||||
go build -buildvcs=false -trimpath -ldflags="-s -w" -o gotunnel-server ./cmd/server
|
||||
|
||||
# 构建客户端(当前平台)
|
||||
build-client:
|
||||
@echo "Building client..."
|
||||
go build -ldflags="-s -w" -o gotunnel-client ./cmd/client
|
||||
@echo "Building client for current platform..."
|
||||
go build -buildvcs=false -trimpath -ldflags="-s -w" -o gotunnel-client ./cmd/client
|
||||
|
||||
# 构建 Linux ARM64 服务端
|
||||
build-server-linux-arm64: sync-only
|
||||
@echo "Building server for Linux ARM64..."
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-arm64 ./cmd/server
|
||||
build-current-platform:
|
||||
@echo "Building current platform binaries..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build.ps1 current
|
||||
else
|
||||
./scripts/build.sh current
|
||||
endif
|
||||
|
||||
# 构建 Linux AMD64 服务端
|
||||
build-server-linux-amd64: sync-only
|
||||
@echo "Building server for Linux AMD64..."
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o gotunnel-server-linux-amd64 ./cmd/server
|
||||
build-all-platforms:
|
||||
@echo "Building all desktop platform binaries..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build.ps1 all -NoUPX
|
||||
else
|
||||
./scripts/build.sh all
|
||||
endif
|
||||
|
||||
# 完整构建(包含前端)
|
||||
full-build: build-frontend sync-frontend build-server build-client
|
||||
build-android:
|
||||
@echo "Android build placeholder..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build.ps1 android
|
||||
else
|
||||
./scripts/build.sh android
|
||||
endif
|
||||
|
||||
# 开发模式:快速构建(假设前端已构建)
|
||||
dev-build: sync-only build-server
|
||||
|
||||
# 清理构建产物
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -68,21 +69,21 @@ ifeq ($(OS),Windows_NT)
|
||||
if exist gotunnel-client.exe del gotunnel-client.exe
|
||||
if exist gotunnel-server-* del gotunnel-server-*
|
||||
if exist gotunnel-client-* del gotunnel-client-*
|
||||
if exist build rmdir /s /q build
|
||||
else
|
||||
rm -f gotunnel-server gotunnel-client gotunnel-server-* gotunnel-client-*
|
||||
rm -rf build
|
||||
endif
|
||||
|
||||
# 帮助
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " all - Build frontend, sync, and build binaries"
|
||||
@echo " all - Build frontend, sync, and current platform binaries"
|
||||
@echo " build-frontend - Build frontend (npm)"
|
||||
@echo " sync-frontend - Sync web/dist to internal/server/app/dist"
|
||||
@echo " sync-only - Sync without rebuilding frontend"
|
||||
@echo " build-server - Build server for current platform"
|
||||
@echo " build-client - Build client for current platform"
|
||||
@echo " build-server-linux-arm64 - Cross-compile server for Linux ARM64"
|
||||
@echo " build-server-linux-amd64 - Cross-compile server for Linux AMD64"
|
||||
@echo " full-build - Complete build with frontend"
|
||||
@echo " dev-build - Quick build (assumes frontend exists)"
|
||||
@echo " build-current-platform - Build server/client into build/<os>_<arch>/"
|
||||
@echo " build-all-platforms - Build Windows/Linux/macOS server/client binaries"
|
||||
@echo " build-android - Android build placeholder"
|
||||
@echo " clean - Remove build artifacts"
|
||||
|
||||
645
PLUGINS.md
645
PLUGINS.md
@@ -1,645 +0,0 @@
|
||||
# GoTunnel 插件开发指南
|
||||
|
||||
本文档介绍如何为 GoTunnel 开发 JS 插件。JS 插件基于 [goja](https://github.com/dop251/goja) 运行时,运行在客户端上。
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
- [插件结构](#插件结构)
|
||||
- [API 参考](#api-参考)
|
||||
- [示例插件](#示例插件)
|
||||
- [插件签名](#插件签名)
|
||||
- [发布到商店](#发布到商店)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 最小插件示例
|
||||
|
||||
```javascript
|
||||
// 必须:定义插件元数据
|
||||
function metadata() {
|
||||
return {
|
||||
name: "my-plugin",
|
||||
version: "1.0.0",
|
||||
type: "app",
|
||||
description: "My first plugin",
|
||||
author: "Your Name"
|
||||
};
|
||||
}
|
||||
|
||||
// 可选:插件启动时调用
|
||||
function start() {
|
||||
log("Plugin started");
|
||||
}
|
||||
|
||||
// 必须:处理连接
|
||||
function handleConn(conn) {
|
||||
// 处理连接逻辑
|
||||
conn.Close();
|
||||
}
|
||||
|
||||
// 可选:插件停止时调用
|
||||
function stop() {
|
||||
log("Plugin stopped");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 插件结构
|
||||
|
||||
### 生命周期函数
|
||||
|
||||
| 函数 | 必须 | 说明 |
|
||||
|------|------|------|
|
||||
| `metadata()` | 否 | 返回插件元数据,不定义则使用默认值 |
|
||||
| `start()` | 否 | 插件启动时调用 |
|
||||
| `handleConn(conn)` | 是 | 处理每个连接 |
|
||||
| `stop()` | 否 | 插件停止时调用 |
|
||||
|
||||
### 元数据字段
|
||||
|
||||
```javascript
|
||||
function metadata() {
|
||||
return {
|
||||
name: "plugin-name", // 插件名称
|
||||
version: "1.0.0", // 版本号
|
||||
type: "app", // 类型: "app" (应用插件)
|
||||
description: "描述", // 插件描述
|
||||
author: "作者" // 作者名称
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
### 基础 API
|
||||
|
||||
#### `log(message)`
|
||||
|
||||
输出日志信息。
|
||||
|
||||
```javascript
|
||||
log("Hello, World!");
|
||||
// 输出: [JS:plugin-name] Hello, World!
|
||||
```
|
||||
|
||||
#### `config(key)`
|
||||
|
||||
获取插件配置值。
|
||||
|
||||
```javascript
|
||||
var port = config("port");
|
||||
var host = config("host") || "127.0.0.1";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 连接 API (conn)
|
||||
|
||||
`handleConn` 函数接收的 `conn` 对象提供以下方法:
|
||||
|
||||
#### `conn.Read(size)`
|
||||
|
||||
读取数据,返回字节数组,失败返回 `null`。
|
||||
|
||||
```javascript
|
||||
var data = conn.Read(1024);
|
||||
if (data) {
|
||||
log("Received " + data.length + " bytes");
|
||||
}
|
||||
```
|
||||
|
||||
#### `conn.Write(data)`
|
||||
|
||||
写入数据,返回写入的字节数。
|
||||
|
||||
```javascript
|
||||
var written = conn.Write(data);
|
||||
log("Wrote " + written + " bytes");
|
||||
```
|
||||
|
||||
#### `conn.Close()`
|
||||
|
||||
关闭连接。
|
||||
|
||||
```javascript
|
||||
conn.Close();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 文件系统 API (fs)
|
||||
|
||||
所有文件操作都在沙箱中执行,有路径和大小限制。
|
||||
|
||||
#### `fs.readFile(path)`
|
||||
|
||||
读取文件内容。
|
||||
|
||||
```javascript
|
||||
var result = fs.readFile("/path/to/file.txt");
|
||||
if (result.error) {
|
||||
log("Error: " + result.error);
|
||||
} else {
|
||||
log("Content: " + result.data);
|
||||
}
|
||||
```
|
||||
|
||||
#### `fs.writeFile(path, content)`
|
||||
|
||||
写入文件内容。
|
||||
|
||||
```javascript
|
||||
var result = fs.writeFile("/path/to/file.txt", "Hello");
|
||||
if (result.ok) {
|
||||
log("File written");
|
||||
} else {
|
||||
log("Error: " + result.error);
|
||||
}
|
||||
```
|
||||
|
||||
#### `fs.readDir(path)`
|
||||
|
||||
读取目录内容。
|
||||
|
||||
```javascript
|
||||
var result = fs.readDir("/path/to/dir");
|
||||
if (!result.error) {
|
||||
for (var i = 0; i < result.entries.length; i++) {
|
||||
var entry = result.entries[i];
|
||||
log(entry.name + " - " + (entry.isDir ? "DIR" : entry.size + " bytes"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `fs.stat(path)`
|
||||
|
||||
获取文件信息。
|
||||
|
||||
```javascript
|
||||
var result = fs.stat("/path/to/file");
|
||||
if (!result.error) {
|
||||
log("Name: " + result.name);
|
||||
log("Size: " + result.size);
|
||||
log("IsDir: " + result.isDir);
|
||||
log("ModTime: " + result.modTime);
|
||||
}
|
||||
```
|
||||
|
||||
#### `fs.exists(path)`
|
||||
|
||||
检查文件是否存在。
|
||||
|
||||
```javascript
|
||||
var result = fs.exists("/path/to/file");
|
||||
if (result.exists) {
|
||||
log("File exists");
|
||||
}
|
||||
```
|
||||
|
||||
#### `fs.mkdir(path)`
|
||||
|
||||
创建目录。
|
||||
|
||||
```javascript
|
||||
var result = fs.mkdir("/path/to/new/dir");
|
||||
if (result.ok) {
|
||||
log("Directory created");
|
||||
}
|
||||
```
|
||||
|
||||
#### `fs.remove(path)`
|
||||
|
||||
删除文件或目录。
|
||||
|
||||
```javascript
|
||||
var result = fs.remove("/path/to/file");
|
||||
if (result.ok) {
|
||||
log("Removed");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HTTP API (http)
|
||||
|
||||
用于构建简单的 HTTP 服务。
|
||||
|
||||
#### `http.serve(conn, handler)`
|
||||
|
||||
处理 HTTP 请求。
|
||||
|
||||
```javascript
|
||||
function handleConn(conn) {
|
||||
http.serve(conn, function(req) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: http.json({ message: "Hello", path: req.path })
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**请求对象 (req):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `method` | string | HTTP 方法 (GET, POST, etc.) |
|
||||
| `path` | string | 请求路径 |
|
||||
| `body` | string | 请求体 |
|
||||
|
||||
**响应对象:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `status` | number | HTTP 状态码 (默认 200) |
|
||||
| `contentType` | string | Content-Type (默认 application/json) |
|
||||
| `body` | string | 响应体 |
|
||||
|
||||
#### `http.json(data)`
|
||||
|
||||
将对象序列化为 JSON 字符串。
|
||||
|
||||
```javascript
|
||||
var jsonStr = http.json({ name: "test", value: 123 });
|
||||
// 返回: '{"name":"test","value":123}'
|
||||
```
|
||||
|
||||
#### `http.sendFile(conn, filePath)`
|
||||
|
||||
发送文件作为 HTTP 响应。
|
||||
|
||||
```javascript
|
||||
function handleConn(conn) {
|
||||
http.sendFile(conn, "/path/to/index.html");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 增强 API (Enhanced APIs)
|
||||
|
||||
GoTunnel v2.0+ 提供了更多强大的 API 能力。
|
||||
|
||||
#### `logger` (日志)
|
||||
|
||||
推荐使用结构化日志替代简单的 `log()`。
|
||||
|
||||
- `logger.info(msg)`
|
||||
- `logger.warn(msg)`
|
||||
- `logger.error(msg)`
|
||||
|
||||
```javascript
|
||||
logger.info("Server started");
|
||||
logger.error("Connection failed");
|
||||
```
|
||||
|
||||
#### `config` (配置)
|
||||
|
||||
增强的配置获取方式。
|
||||
|
||||
- `config.get(key)`: 获取配置值
|
||||
- `config.getAll()`: 获取所有配置
|
||||
|
||||
```javascript
|
||||
var all = config.getAll();
|
||||
var port = config.get("port");
|
||||
```
|
||||
|
||||
#### `storage` (持久化存储)
|
||||
|
||||
简单的 Key-Value 存储,数据保存在客户端本地。
|
||||
|
||||
- `storage.get(key, default)`
|
||||
- `storage.set(key, value)`
|
||||
- `storage.delete(key)`
|
||||
- `storage.keys()`
|
||||
|
||||
```javascript
|
||||
storage.set("last_run", Date.now());
|
||||
var last = storage.get("last_run", 0);
|
||||
```
|
||||
|
||||
#### `event` (事件总线)
|
||||
|
||||
插件内部或插件间的事件通信。
|
||||
|
||||
- `event.on(name, callback)`
|
||||
- `event.emit(name, data)`
|
||||
- `event.off(name)`
|
||||
|
||||
```javascript
|
||||
event.on("user_login", function(user) {
|
||||
logger.info("User logged in: " + user);
|
||||
});
|
||||
event.emit("user_login", "admin");
|
||||
```
|
||||
|
||||
#### `request` (HTTP 请求)
|
||||
|
||||
发起外部 HTTP 请求。
|
||||
|
||||
- `request.get(url)`
|
||||
- `request.post(url, contentType, body)`
|
||||
|
||||
```javascript
|
||||
var res = request.get("https://api.ipify.org");
|
||||
logger.info("My IP: " + res.body);
|
||||
```
|
||||
|
||||
#### `notify` (通知)
|
||||
|
||||
发送系统通知。
|
||||
|
||||
- `notify.send(title, message)`
|
||||
|
||||
```javascript
|
||||
notify.send("Download Complete", "File saved to disk");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例插件
|
||||
|
||||
### Echo 服务
|
||||
|
||||
```javascript
|
||||
function metadata() {
|
||||
return {
|
||||
name: "echo",
|
||||
version: "1.0.0",
|
||||
type: "app",
|
||||
description: "Echo back received data"
|
||||
};
|
||||
}
|
||||
|
||||
function handleConn(conn) {
|
||||
while (true) {
|
||||
var data = conn.Read(4096);
|
||||
if (!data || data.length === 0) {
|
||||
break;
|
||||
}
|
||||
conn.Write(data);
|
||||
}
|
||||
conn.Close();
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP 文件服务器
|
||||
|
||||
```javascript
|
||||
function metadata() {
|
||||
return {
|
||||
name: "file-server",
|
||||
version: "1.0.0",
|
||||
type: "app",
|
||||
description: "Simple HTTP file server"
|
||||
};
|
||||
}
|
||||
|
||||
var rootDir = "";
|
||||
|
||||
function start() {
|
||||
rootDir = config("root") || "/tmp";
|
||||
log("Serving files from: " + rootDir);
|
||||
}
|
||||
|
||||
function handleConn(conn) {
|
||||
http.serve(conn, function(req) {
|
||||
if (req.method === "GET") {
|
||||
var filePath = rootDir + req.path;
|
||||
if (req.path === "/") {
|
||||
filePath = rootDir + "/index.html";
|
||||
}
|
||||
|
||||
var stat = fs.stat(filePath);
|
||||
if (stat.error) {
|
||||
return { status: 404, body: "Not Found" };
|
||||
}
|
||||
|
||||
if (stat.isDir) {
|
||||
return listDirectory(filePath);
|
||||
}
|
||||
|
||||
var file = fs.readFile(filePath);
|
||||
if (file.error) {
|
||||
return { status: 500, body: file.error };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: file.data
|
||||
};
|
||||
}
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
});
|
||||
}
|
||||
|
||||
function listDirectory(path) {
|
||||
var result = fs.readDir(path);
|
||||
if (result.error) {
|
||||
return { status: 500, body: result.error };
|
||||
}
|
||||
|
||||
var html = "<html><body><h1>Directory Listing</h1><ul>";
|
||||
for (var i = 0; i < result.entries.length; i++) {
|
||||
var e = result.entries[i];
|
||||
html += "<li><a href='" + e.name + "'>" + e.name + "</a></li>";
|
||||
}
|
||||
html += "</ul></body></html>";
|
||||
|
||||
return { status: 200, contentType: "text/html", body: html };
|
||||
}
|
||||
```
|
||||
|
||||
### JSON API 服务
|
||||
|
||||
```javascript
|
||||
function metadata() {
|
||||
return {
|
||||
name: "api-server",
|
||||
version: "1.0.0",
|
||||
type: "app",
|
||||
description: "JSON API server"
|
||||
};
|
||||
}
|
||||
|
||||
var counter = 0;
|
||||
|
||||
function handleConn(conn) {
|
||||
http.serve(conn, function(req) {
|
||||
if (req.path === "/api/status") {
|
||||
return {
|
||||
status: 200,
|
||||
body: http.json({
|
||||
status: "ok",
|
||||
counter: counter++,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (req.path === "/api/echo" && req.method === "POST") {
|
||||
return {
|
||||
status: 200,
|
||||
body: http.json({
|
||||
received: req.body
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
body: http.json({ error: "Not Found" })
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 插件签名
|
||||
|
||||
为了安全,JS 插件需要官方签名才能运行。
|
||||
|
||||
### 签名格式
|
||||
|
||||
签名文件 (`.sig`) 包含 Base64 编码的签名数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": {
|
||||
"name": "plugin-name",
|
||||
"version": "1.0.0",
|
||||
"checksum": "sha256-hash",
|
||||
"key_id": "official-v1"
|
||||
},
|
||||
"signature": "base64-signature"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取签名
|
||||
|
||||
1. 提交插件到官方仓库
|
||||
2. 通过审核后获得签名
|
||||
3. 将 `.js` 和 `.sig` 文件一起分发
|
||||
|
||||
---
|
||||
|
||||
## 发布到商店
|
||||
|
||||
### 商店 JSON 格式
|
||||
|
||||
插件商店使用 `store.json` 文件索引所有插件:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "echo",
|
||||
"version": "1.0.0",
|
||||
"type": "app",
|
||||
"description": "Echo service plugin",
|
||||
"author": "GoTunnel",
|
||||
"icon": "https://example.com/icon.png",
|
||||
"download_url": "https://example.com/plugins/echo.js"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 提交流程
|
||||
|
||||
1. Fork 官方插件仓库
|
||||
2. 添加插件文件到 `plugins/` 目录
|
||||
3. 更新 `store.json`
|
||||
4. 提交 Pull Request
|
||||
5. 等待审核和签名
|
||||
|
||||
---
|
||||
|
||||
## 沙箱限制
|
||||
|
||||
为了安全,JS 插件运行在沙箱环境中:
|
||||
|
||||
| 限制项 | 默认值 |
|
||||
|--------|--------|
|
||||
| 最大读取文件大小 | 10 MB |
|
||||
| 最大写入文件大小 | 10 MB |
|
||||
| 允许读取路径 | 插件数据目录 |
|
||||
| 允许写入路径 | 插件数据目录 |
|
||||
|
||||
---
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 日志输出
|
||||
|
||||
使用 `log()` 函数输出调试信息:
|
||||
|
||||
```javascript
|
||||
log("Debug: variable = " + JSON.stringify(variable));
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
始终检查 API 返回的错误:
|
||||
|
||||
```javascript
|
||||
var result = fs.readFile(path);
|
||||
if (result.error) {
|
||||
log("Error reading file: " + result.error);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### 配置测试
|
||||
|
||||
在 Web 控制台的插件管理页面安装并配置插件,或通过 API 安装:
|
||||
|
||||
```bash
|
||||
# 安装 JS 插件到客户端
|
||||
POST /api/client/{id}/plugin/js/install
|
||||
Content-Type: application/json
|
||||
{
|
||||
"plugin_name": "my-plugin",
|
||||
"source": "function metadata() {...}",
|
||||
"rule_name": "my-rule",
|
||||
"remote_port": 8080,
|
||||
"config": {"debug": "true"},
|
||||
"auto_start": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 插件无法加载?**
|
||||
|
||||
A: 检查签名文件是否存在且有效。
|
||||
|
||||
**Q: 文件操作失败?**
|
||||
|
||||
A: 确认路径在沙箱允许范围内。
|
||||
|
||||
**Q: 如何获取客户端 IP?**
|
||||
|
||||
A: 目前 API 不支持,计划在后续版本添加。
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0
|
||||
|
||||
- 初始版本
|
||||
- 支持基础 API: log, config
|
||||
- 支持连接 API: Read, Write, Close
|
||||
- 支持文件系统 API: fs.*
|
||||
- 支持 HTTP API: http.*
|
||||
18
README.md
18
README.md
@@ -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
5
android/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/.gradle
|
||||
/build
|
||||
/app/build
|
||||
/local.properties
|
||||
/captures
|
||||
27
android/README.md
Normal file
27
android/README.md
Normal 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.
|
||||
48
android/app/build.gradle.kts
Normal file
48
android/app/build.gradle.kts
Normal 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
2
android/app/proguard-rules.pro
vendored
Normal 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.
|
||||
51
android/app/src/main/AndroidManifest.xml
Normal file
51
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
132
android/app/src/main/java/com/gotunnel/android/MainActivity.kt
Normal file
132
android/app/src/main/java/com/gotunnel/android/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
android/app/src/main/res/drawable/ic_gotunnel_app.xml
Normal file
19
android/app/src/main/res/drawable/ic_gotunnel_app.xml
Normal 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>
|
||||
@@ -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>
|
||||
223
android/app/src/main/res/layout/activity_main.xml
Normal file
223
android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
156
android/app/src/main/res/layout/activity_settings.xml
Normal file
156
android/app/src/main/res/layout/activity_settings.xml
Normal 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>
|
||||
9
android/app/src/main/res/values/colors.xml
Normal file
9
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
54
android/app/src/main/res/values/strings.xml
Normal file
54
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
18
android/app/src/main/res/values/themes.xml
Normal file
18
android/app/src/main/res/values/themes.xml
Normal 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
4
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal 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")
|
||||
@@ -3,15 +3,15 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gotunnel/internal/client/config"
|
||||
"github.com/gotunnel/internal/client/tunnel"
|
||||
"github.com/gotunnel/pkg/crypto"
|
||||
"github.com/gotunnel/pkg/plugin"
|
||||
"github.com/gotunnel/pkg/version"
|
||||
)
|
||||
|
||||
// 版本信息(通过 ldflags 注入)
|
||||
// Version information injected by ldflags.
|
||||
var Version string
|
||||
var BuildTime string
|
||||
var GitCommit string
|
||||
@@ -24,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
|
||||
@@ -41,41 +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")
|
||||
}
|
||||
|
||||
// 初始化插件注册表(用于 JS 插件)
|
||||
registry := plugin.NewRegistry()
|
||||
client.SetPluginRegistry(registry)
|
||||
|
||||
// 初始化版本存储
|
||||
if err := client.InitVersionStore(); err != nil {
|
||||
log.Printf("[Client] Warning: failed to init version store: %v", err)
|
||||
if err := client.Run(); err != nil {
|
||||
log.Fatalf("Client stopped: %v", err)
|
||||
}
|
||||
|
||||
client.Run()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
"github.com/gotunnel/internal/server/tunnel"
|
||||
"github.com/gotunnel/pkg/crypto"
|
||||
"github.com/gotunnel/pkg/plugin"
|
||||
"github.com/gotunnel/pkg/version"
|
||||
)
|
||||
|
||||
@@ -80,10 +79,8 @@ func main() {
|
||||
log.Printf("[Server] TLS enabled")
|
||||
}
|
||||
|
||||
// 初始化插件系统(用于客户端 JS 插件管理)
|
||||
registry := plugin.NewRegistry()
|
||||
server.SetPluginRegistry(registry)
|
||||
server.SetJSPluginStore(clientStore) // 设置 JS 插件存储,用于客户端重连时恢复插件
|
||||
// 设置流量存储,用于记录流量统计
|
||||
server.SetTrafficStore(clientStore)
|
||||
|
||||
// 启动 Web 控制台
|
||||
if cfg.Server.Web.Enabled {
|
||||
|
||||
11
go.mod
11
go.mod
@@ -3,13 +3,13 @@ module github.com/gotunnel
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/yamux v0.1.1
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
@@ -24,9 +24,9 @@ require (
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gen2brain/shm v0.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
@@ -41,15 +41,16 @@ require (
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/jezek/xgb v1.1.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -59,7 +60,7 @@ require (
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.7 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
||||
29
go.sum
29
go.sum
@@ -1,7 +1,5 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
@@ -14,14 +12,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9 h1:3uSSOd6mVlwcX3k5OYOpiDqFgRmaE2dBfLvVIFWWHrw=
|
||||
github.com/dop251/goja v0.0.0-20251201205617-2bb4c724c0f9/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
|
||||
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -65,12 +61,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -85,8 +81,12 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
||||
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -97,6 +97,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -122,10 +124,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0=
|
||||
github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -181,6 +183,7 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -213,8 +216,6 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
90
internal/client/tunnel/identity.go
Normal file
90
internal/client/tunnel/identity.go
Normal 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)
|
||||
}
|
||||
163
internal/client/tunnel/machine_id.go
Normal file
163
internal/client/tunnel/machine_id.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getMachineID builds a stable fingerprint from multiple host identifiers
|
||||
// and hashes the combined result into the client ID we expose externally.
|
||||
func getMachineID() string {
|
||||
parts := collectMachineIDParts()
|
||||
if len(parts) == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
func getSystemMachineID() string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return getLinuxMachineID()
|
||||
case "darwin":
|
||||
return getDarwinMachineID()
|
||||
case "windows":
|
||||
return getWindowsMachineID()
|
||||
case "android":
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getLinuxMachineID() string {
|
||||
if data, err := os.ReadFile("/etc/machine-id"); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getDarwinMachineID() string {
|
||||
cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
func getWindowsMachineID() string {
|
||||
cmd := exec.Command("reg", "query", `HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
func getMACAddresses() []string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
macs := make([]string, 0, len(interfaces))
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if len(iface.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
macs = append(macs, iface.HardwareAddr.String())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func hashID(id string) string {
|
||||
hash := sha256.Sum256([]byte(id))
|
||||
return hex.EncodeToString(hash[:])[:16]
|
||||
}
|
||||
41
internal/client/tunnel/options.go
Normal file
41
internal/client/tunnel/options.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ type WebServer struct {
|
||||
Server router.ServerInterface
|
||||
Config *config.ServerConfig
|
||||
ConfigPath string
|
||||
JSPluginStore db.JSPluginStore
|
||||
TrafficStore db.TrafficStore
|
||||
}
|
||||
|
||||
@@ -31,7 +30,6 @@ func NewWebServer(cs db.ClientStore, srv router.ServerInterface, cfg *config.Ser
|
||||
Server: srv,
|
||||
Config: cfg,
|
||||
ConfigPath: cfgPath,
|
||||
JSPluginStore: store,
|
||||
TrafficStore: store,
|
||||
}
|
||||
}
|
||||
@@ -107,11 +105,6 @@ func (w *WebServer) SaveConfig() error {
|
||||
return config.SaveServerConfig(w.ConfigPath, w.Config)
|
||||
}
|
||||
|
||||
// GetJSPluginStore 获取 JS 插件存储
|
||||
func (w *WebServer) GetJSPluginStore() db.JSPluginStore {
|
||||
return w.JSPluginStore
|
||||
}
|
||||
|
||||
// GetTrafficStore 获取流量存储
|
||||
func (w *WebServer) GetTrafficStore() db.TrafficStore {
|
||||
return w.TrafficStore
|
||||
|
||||
@@ -13,22 +13,6 @@ type ServerConfig struct {
|
||||
Server ServerSettings `yaml:"server"`
|
||||
}
|
||||
|
||||
// PluginStoreSettings 插件仓库设置
|
||||
type PluginStoreSettings struct {
|
||||
URL string `yaml:"url"` // 插件仓库 URL,为空则使用默认值
|
||||
}
|
||||
|
||||
// 默认插件仓库 URL
|
||||
const DefaultPluginStoreURL = "https://git.92coco.cn/flik/GoTunnel-Plugins/raw/branch/main/store.json"
|
||||
|
||||
// GetPluginStoreURL 获取插件仓库 URL
|
||||
func (s *PluginStoreSettings) GetPluginStoreURL() string {
|
||||
if s.URL != "" {
|
||||
return s.URL
|
||||
}
|
||||
return DefaultPluginStoreURL
|
||||
}
|
||||
|
||||
// ServerSettings 服务端设置
|
||||
type ServerSettings struct {
|
||||
BindAddr string `yaml:"bind_addr"`
|
||||
@@ -39,7 +23,6 @@ type ServerSettings struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
TLSDisabled bool `yaml:"tls_disabled"`
|
||||
Web WebSettings `yaml:"web"`
|
||||
PluginStore PluginStoreSettings `yaml:"plugin_store"`
|
||||
}
|
||||
|
||||
// WebSettings Web控制台设置
|
||||
|
||||
45
internal/server/db/install_token.go
Normal file
45
internal/server/db/install_token.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package db
|
||||
|
||||
// CreateInstallToken 创建安装token
|
||||
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.CreatedAt, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetInstallToken 获取安装token
|
||||
func (s *SQLiteStore) GetInstallToken(token string) (*InstallToken, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var t InstallToken
|
||||
var used int
|
||||
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
|
||||
}
|
||||
t.Used = used == 1
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// MarkTokenUsed 标记token已使用
|
||||
func (s *SQLiteStore) MarkTokenUsed(token string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`UPDATE install_tokens SET used = 1 WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteExpiredTokens 删除过期token
|
||||
func (s *SQLiteStore) DeleteExpiredTokens(expireTime int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM install_tokens WHERE created_at < ?`, expireTime)
|
||||
return err
|
||||
}
|
||||
@@ -2,52 +2,11 @@ package db
|
||||
|
||||
import "github.com/gotunnel/pkg/protocol"
|
||||
|
||||
// ConfigField 配置字段定义
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ClientPlugin 客户端已安装的插件
|
||||
type ClientPlugin struct {
|
||||
ID string `json:"id"` // 插件实例唯一 ID
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"` // 运行状态
|
||||
Config map[string]string `json:"config,omitempty"` // 插件配置
|
||||
RemotePort int `json:"remote_port,omitempty"` // 远程监听端口
|
||||
ConfigSchema []ConfigField `json:"config_schema,omitempty"` // 配置模式
|
||||
AuthEnabled bool `json:"auth_enabled,omitempty"` // 是否启用认证
|
||||
AuthUsername string `json:"auth_username,omitempty"` // 认证用户名
|
||||
AuthPassword string `json:"auth_password,omitempty"` // 认证密码
|
||||
}
|
||||
|
||||
// Client 客户端数据
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Rules []protocol.ProxyRule `json:"rules"`
|
||||
Plugins []ClientPlugin `json:"plugins,omitempty"` // 已安装的插件
|
||||
}
|
||||
|
||||
// JSPlugin JS 插件数据
|
||||
type JSPlugin struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Signature string `json:"signature"` // 官方签名 (Base64)
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version,omitempty"`
|
||||
AutoPush []string `json:"auto_push"`
|
||||
Config map[string]string `json:"config"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// ClientStore 客户端存储接口
|
||||
@@ -62,20 +21,9 @@ type ClientStore interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// JSPluginStore JS 插件存储接口
|
||||
type JSPluginStore interface {
|
||||
GetAllJSPlugins() ([]JSPlugin, error)
|
||||
GetJSPlugin(name string) (*JSPlugin, error)
|
||||
SaveJSPlugin(p *JSPlugin) error
|
||||
DeleteJSPlugin(name string) error
|
||||
SetJSPluginEnabled(name string, enabled bool) error
|
||||
UpdateJSPluginConfig(name string, config map[string]string) error
|
||||
}
|
||||
|
||||
// Store 统一存储接口
|
||||
type Store interface {
|
||||
ClientStore
|
||||
JSPluginStore
|
||||
TrafficStore
|
||||
Close() error
|
||||
}
|
||||
@@ -94,3 +42,18 @@ type TrafficStore interface {
|
||||
Get24HourTraffic() (inbound, outbound int64, err error)
|
||||
GetHourlyTraffic(hours int) ([]TrafficRecord, error)
|
||||
}
|
||||
|
||||
// InstallToken 安装token
|
||||
type InstallToken struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Used bool `json:"used"`
|
||||
}
|
||||
|
||||
// InstallTokenStore 安装token存储接口
|
||||
type InstallTokenStore interface {
|
||||
CreateInstallToken(token *InstallToken) error
|
||||
GetInstallToken(token string) (*InstallToken, error)
|
||||
MarkTokenUsed(token string) error
|
||||
DeleteExpiredTokens(expireTime int64) error
|
||||
}
|
||||
|
||||
@@ -40,8 +40,7 @@ func (s *SQLiteStore) init() error {
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
rules TEXT NOT NULL DEFAULT '[]',
|
||||
plugins TEXT NOT NULL DEFAULT '[]'
|
||||
rules TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -50,36 +49,6 @@ func (s *SQLiteStore) init() error {
|
||||
|
||||
// 迁移:添加 nickname 列
|
||||
s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`)
|
||||
// 迁移:添加 plugins 列
|
||||
s.db.Exec(`ALTER TABLE clients ADD COLUMN plugins TEXT NOT NULL DEFAULT '[]'`)
|
||||
|
||||
// 创建 JS 插件表
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS js_plugins (
|
||||
name TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
signature TEXT NOT NULL DEFAULT '',
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
version TEXT DEFAULT '',
|
||||
auto_push TEXT NOT NULL DEFAULT '[]',
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
auto_start INTEGER DEFAULT 1,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 迁移:添加 signature 列
|
||||
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN signature TEXT NOT NULL DEFAULT ''`)
|
||||
// 迁移:添加 version 列
|
||||
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN version TEXT DEFAULT ''`)
|
||||
// 迁移:添加 updated_at 列
|
||||
s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`)
|
||||
|
||||
// 创建流量统计表
|
||||
_, err = s.db.Exec(`
|
||||
@@ -108,6 +77,19 @@ func (s *SQLiteStore) init() error {
|
||||
// 初始化总流量记录
|
||||
s.db.Exec(`INSERT OR IGNORE INTO traffic_total (id, inbound, outbound) VALUES (1, 0, 0)`)
|
||||
|
||||
// 创建安装token表
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS install_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,7 +103,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT id, nickname, rules, plugins FROM clients`)
|
||||
rows, err := s.db.Query(`SELECT id, nickname, rules FROM clients`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,16 +112,13 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
||||
var clients []Client
|
||||
for rows.Next() {
|
||||
var c Client
|
||||
var rulesJSON, pluginsJSON string
|
||||
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON); err != nil {
|
||||
var rulesJSON string
|
||||
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||
c.Rules = []protocol.ProxyRule{}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
|
||||
c.Plugins = []ClientPlugin{}
|
||||
}
|
||||
clients = append(clients, c)
|
||||
}
|
||||
return clients, nil
|
||||
@@ -151,17 +130,14 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) {
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var c Client
|
||||
var rulesJSON, pluginsJSON string
|
||||
err := s.db.QueryRow(`SELECT id, nickname, rules, plugins FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON)
|
||||
var rulesJSON string
|
||||
err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||
c.Rules = []protocol.ProxyRule{}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
|
||||
c.Plugins = []ClientPlugin{}
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
@@ -174,12 +150,8 @@ func (s *SQLiteStore) CreateClient(c *Client) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginsJSON, err := json.Marshal(c.Plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules, plugins) VALUES (?, ?, ?, ?)`,
|
||||
c.ID, c.Nickname, string(rulesJSON), string(pluginsJSON))
|
||||
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules) VALUES (?, ?, ?)`,
|
||||
c.ID, c.Nickname, string(rulesJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -192,12 +164,8 @@ func (s *SQLiteStore) UpdateClient(c *Client) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginsJSON, err := json.Marshal(c.Plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ?, plugins = ? WHERE id = ?`,
|
||||
c.Nickname, string(rulesJSON), string(pluginsJSON), c.ID)
|
||||
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ? WHERE id = ?`,
|
||||
c.Nickname, string(rulesJSON), c.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -229,121 +197,6 @@ func (s *SQLiteStore) GetClientRules(id string) ([]protocol.ProxyRule, error) {
|
||||
return c.Rules, nil
|
||||
}
|
||||
|
||||
// ========== JS 插件存储方法 ==========
|
||||
|
||||
// GetAllJSPlugins 获取所有 JS 插件
|
||||
func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled
|
||||
FROM js_plugins
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plugins []JSPlugin
|
||||
for rows.Next() {
|
||||
var p JSPlugin
|
||||
var autoPushJSON, configJSON string
|
||||
var version sql.NullString
|
||||
var autoStart, enabled int
|
||||
err := rows.Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
|
||||
&version, &autoPushJSON, &configJSON, &autoStart, &enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Version = version.String
|
||||
json.Unmarshal([]byte(autoPushJSON), &p.AutoPush)
|
||||
json.Unmarshal([]byte(configJSON), &p.Config)
|
||||
p.AutoStart = autoStart == 1
|
||||
p.Enabled = enabled == 1
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// GetJSPlugin 获取单个 JS 插件
|
||||
func (s *SQLiteStore) GetJSPlugin(name string) (*JSPlugin, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var p JSPlugin
|
||||
var autoPushJSON, configJSON string
|
||||
var version sql.NullString
|
||||
var autoStart, enabled int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT name, source, signature, description, author, version, auto_push, config, auto_start, enabled
|
||||
FROM js_plugins WHERE name = ?
|
||||
`, name).Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author,
|
||||
&version, &autoPushJSON, &configJSON, &autoStart, &enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Version = version.String
|
||||
json.Unmarshal([]byte(autoPushJSON), &p.AutoPush)
|
||||
json.Unmarshal([]byte(configJSON), &p.Config)
|
||||
p.AutoStart = autoStart == 1
|
||||
p.Enabled = enabled == 1
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// SaveJSPlugin 保存 JS 插件
|
||||
func (s *SQLiteStore) SaveJSPlugin(p *JSPlugin) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
autoPushJSON, _ := json.Marshal(p.AutoPush)
|
||||
configJSON, _ := json.Marshal(p.Config)
|
||||
autoStart, enabled := 0, 0
|
||||
if p.AutoStart {
|
||||
autoStart = 1
|
||||
}
|
||||
if p.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR REPLACE INTO js_plugins
|
||||
(name, source, signature, description, author, version, auto_push, config, auto_start, enabled, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, p.Name, p.Source, p.Signature, p.Description, p.Author, p.Version,
|
||||
string(autoPushJSON), string(configJSON), autoStart, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteJSPlugin 删除 JS 插件
|
||||
func (s *SQLiteStore) DeleteJSPlugin(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`DELETE FROM js_plugins WHERE name = ?`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetJSPluginEnabled 设置 JS 插件启用状态
|
||||
func (s *SQLiteStore) SetJSPluginEnabled(name string, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
val := 0
|
||||
if enabled {
|
||||
val = 1
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE js_plugins SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, val, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateJSPluginConfig 更新 JS 插件配置
|
||||
func (s *SQLiteStore) UpdateJSPluginConfig(name string, config map[string]string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
configJSON, _ := json.Marshal(config)
|
||||
_, err := s.db.Exec(`UPDATE js_plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?`, string(configJSON), name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ========== 流量统计方法 ==========
|
||||
|
||||
// getHourTimestamp 获取当前小时的时间戳
|
||||
@@ -397,12 +250,17 @@ func (s *SQLiteStore) Get24HourTraffic() (inbound, outbound int64, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// GetHourlyTraffic 获取每小时流量记录
|
||||
// GetHourlyTraffic 获取每小时流量记录(始终返回完整的 hours 小时数据)
|
||||
func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cutoff := time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
|
||||
// 计算当前小时的起始时间戳
|
||||
now := time.Now()
|
||||
currentHour := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
|
||||
// 查询数据库中的记录
|
||||
cutoff := currentHour.Add(-time.Duration(hours-1) * time.Hour).Unix()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT hour_ts, inbound, outbound FROM traffic_stats
|
||||
WHERE hour_ts >= ? ORDER BY hour_ts ASC
|
||||
@@ -412,13 +270,26 @@ func (s *SQLiteStore) GetHourlyTraffic(hours int) ([]TrafficRecord, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []TrafficRecord
|
||||
// 将数据库记录放入 map 以便快速查找
|
||||
dbRecords := make(map[int64]TrafficRecord)
|
||||
for rows.Next() {
|
||||
var r TrafficRecord
|
||||
if err := rows.Scan(&r.Timestamp, &r.Inbound, &r.Outbound); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, r)
|
||||
dbRecords[r.Timestamp] = r
|
||||
}
|
||||
|
||||
// 生成完整的 hours 小时数据
|
||||
records := make([]TrafficRecord, hours)
|
||||
for i := 0; i < hours; i++ {
|
||||
ts := currentHour.Add(-time.Duration(hours-1-i) * time.Hour).Unix()
|
||||
if r, ok := dbRecords[ts]; ok {
|
||||
records[i] = r
|
||||
} else {
|
||||
records[i] = TrafficRecord{Timestamp: ts, Inbound: 0, Outbound: 0}
|
||||
}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gotunnel/pkg/plugin"
|
||||
)
|
||||
|
||||
// Manager 服务端 plugin 管理器
|
||||
type Manager struct {
|
||||
registry *plugin.Registry
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager 创建 plugin 管理器
|
||||
func NewManager() (*Manager, error) {
|
||||
registry := plugin.NewRegistry()
|
||||
|
||||
m := &Manager{
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ListPlugins 返回所有插件
|
||||
func (m *Manager) ListPlugins() []plugin.Info {
|
||||
return m.registry.List()
|
||||
}
|
||||
|
||||
// GetRegistry 返回插件注册表
|
||||
func (m *Manager) GetRegistry() *plugin.Registry {
|
||||
return m.registry
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
"github.com/gotunnel/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -17,7 +16,6 @@ type CreateClientRequest struct {
|
||||
type UpdateClientRequest struct {
|
||||
Nickname string `json:"nickname" binding:"max=128" example:"My Client"`
|
||||
Rules []protocol.ProxyRule `json:"rules"`
|
||||
Plugins []db.ClientPlugin `json:"plugins"`
|
||||
}
|
||||
|
||||
// ClientResponse 客户端详情响应
|
||||
@@ -26,7 +24,6 @@ type ClientResponse struct {
|
||||
ID string `json:"id" example:"client-001"`
|
||||
Nickname string `json:"nickname,omitempty" example:"My Client"`
|
||||
Rules []protocol.ProxyRule `json:"rules"`
|
||||
Plugins []db.ClientPlugin `json:"plugins,omitempty"`
|
||||
Online bool `json:"online" example:"true"`
|
||||
LastPing string `json:"last_ping,omitempty" example:"2025-01-02T10:30:00Z"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty" example:"192.168.1.100:54321"`
|
||||
@@ -47,17 +44,3 @@ type ClientListItem struct {
|
||||
OS string `json:"os,omitempty" example:"linux"`
|
||||
Arch string `json:"arch,omitempty" example:"amd64"`
|
||||
}
|
||||
|
||||
// InstallPluginsRequest 安装插件到客户端请求
|
||||
// @Description 安装插件到指定客户端
|
||||
type InstallPluginsRequest struct {
|
||||
Plugins []string `json:"plugins" binding:"required,min=1,dive,required" example:"socks5,http-proxy"`
|
||||
}
|
||||
|
||||
// ClientPluginActionRequest 客户端插件操作请求
|
||||
// @Description 对客户端插件执行操作
|
||||
type ClientPluginActionRequest struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
Config map[string]string `json:"config,omitempty"`
|
||||
Restart bool `json:"restart"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package dto
|
||||
|
||||
// PluginConfigRequest 更新插件配置请求
|
||||
// @Description 更新客户端插件配置
|
||||
type PluginConfigRequest struct {
|
||||
Config map[string]string `json:"config" binding:"required"`
|
||||
}
|
||||
|
||||
// PluginConfigResponse 插件配置响应
|
||||
// @Description 插件配置详情
|
||||
type PluginConfigResponse struct {
|
||||
PluginName string `json:"plugin_name"`
|
||||
Schema []ConfigField `json:"schema"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
// ConfigField 配置字段定义
|
||||
// @Description 配置表单字段
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// RuleSchema 规则表单模式
|
||||
// @Description 代理规则的配置模式
|
||||
type RuleSchema struct {
|
||||
NeedsLocalAddr bool `json:"needs_local_addr"`
|
||||
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
|
||||
}
|
||||
|
||||
// PluginInfo 插件信息
|
||||
// @Description 服务端插件信息
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
|
||||
}
|
||||
|
||||
// JSPluginCreateRequest 创建 JS 插件请求
|
||||
// @Description 创建新的 JS 插件
|
||||
type JSPluginCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Source string `json:"source" binding:"required"`
|
||||
Signature string `json:"signature"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
Author string `json:"author" binding:"max=64"`
|
||||
Config map[string]string `json:"config"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
}
|
||||
|
||||
// JSPluginUpdateRequest 更新 JS 插件请求
|
||||
// @Description 更新 JS 插件
|
||||
type JSPluginUpdateRequest struct {
|
||||
Source string `json:"source"`
|
||||
Signature string `json:"signature"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
Author string `json:"author" binding:"max=64"`
|
||||
Config map[string]string `json:"config"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// JSPluginInstallRequest JS 插件安装请求
|
||||
// @Description 安装 JS 插件到客户端
|
||||
type JSPluginInstallRequest struct {
|
||||
PluginName string `json:"plugin_name" binding:"required"`
|
||||
Source string `json:"source" binding:"required"`
|
||||
Signature string `json:"signature"`
|
||||
RuleName string `json:"rule_name"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
Config map[string]string `json:"config"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
}
|
||||
|
||||
// StorePluginInfo 扩展商店插件信息
|
||||
// @Description 插件商店中的插件信息
|
||||
type StorePluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
SignatureURL string `json:"signature_url,omitempty"`
|
||||
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
|
||||
}
|
||||
|
||||
// StoreInstallRequest 从商店安装插件请求
|
||||
// @Description 从插件商店安装插件到客户端
|
||||
type StoreInstallRequest struct {
|
||||
PluginName string `json:"plugin_name" binding:"required"`
|
||||
Version string `json:"version"`
|
||||
DownloadURL string `json:"download_url" binding:"required,url"`
|
||||
SignatureURL string `json:"signature_url" binding:"required,url"`
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
|
||||
// HTTP Basic Auth 配置
|
||||
AuthEnabled bool `json:"auth_enabled,omitempty"`
|
||||
AuthUsername string `json:"auth_username,omitempty"`
|
||||
AuthPassword string `json:"auth_password,omitempty"`
|
||||
}
|
||||
|
||||
// JSPluginPushRequest 推送 JS 插件到客户端请求
|
||||
// @Description 推送 JS 插件到指定客户端
|
||||
type JSPluginPushRequest struct {
|
||||
RemotePort int `json:"remote_port"`
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
"github.com/gotunnel/internal/server/router/dto"
|
||||
"github.com/gotunnel/pkg/protocol"
|
||||
)
|
||||
|
||||
// ClientHandler 客户端处理器
|
||||
@@ -115,41 +114,18 @@ func (h *ClientHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
online, lastPing, remoteAddr, clientOS, clientArch, clientVersion := h.app.GetServer().GetClientStatus(clientID)
|
||||
online, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion := h.app.GetServer().GetClientStatus(clientID)
|
||||
|
||||
// 复制插件列表
|
||||
plugins := make([]db.ClientPlugin, len(client.Plugins))
|
||||
copy(plugins, client.Plugins)
|
||||
|
||||
// 如果客户端在线,获取实时插件运行状态
|
||||
if online {
|
||||
if statusList, err := h.app.GetServer().GetClientPluginStatus(clientID); err == nil {
|
||||
// 创建运行中插件的映射
|
||||
runningPlugins := make(map[string]bool)
|
||||
for _, s := range statusList {
|
||||
runningPlugins[s.PluginName] = s.Running
|
||||
}
|
||||
// 更新插件状态
|
||||
for i := range plugins {
|
||||
if running, ok := runningPlugins[plugins[i].Name]; ok {
|
||||
plugins[i].Running = running
|
||||
} else {
|
||||
plugins[i].Running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 客户端离线时,所有插件都标记为未运行
|
||||
for i := range plugins {
|
||||
plugins[i].Running = false
|
||||
}
|
||||
// 如果客户端在线且有名称,优先使用在线名称
|
||||
nickname := client.Nickname
|
||||
if online && clientName != "" && nickname == "" {
|
||||
nickname = clientName
|
||||
}
|
||||
|
||||
resp := dto.ClientResponse{
|
||||
ID: client.ID,
|
||||
Nickname: client.Nickname,
|
||||
Nickname: nickname,
|
||||
Rules: client.Rules,
|
||||
Plugins: plugins,
|
||||
Online: online,
|
||||
LastPing: lastPing,
|
||||
RemoteAddr: remoteAddr,
|
||||
@@ -190,9 +166,6 @@ func (h *ClientHandler) Update(c *gin.Context) {
|
||||
|
||||
client.Nickname = req.Nickname
|
||||
client.Rules = req.Rules
|
||||
if req.Plugins != nil {
|
||||
client.Plugins = req.Plugins
|
||||
}
|
||||
|
||||
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
@@ -242,8 +215,7 @@ func (h *ClientHandler) Delete(c *gin.Context) {
|
||||
func (h *ClientHandler) PushConfig(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||
if !online {
|
||||
if !h.app.GetServer().IsClientOnline(clientID) {
|
||||
ClientNotOnline(c)
|
||||
return
|
||||
}
|
||||
@@ -296,160 +268,12 @@ func (h *ClientHandler) Restart(c *gin.Context) {
|
||||
SuccessWithMessage(c, gin.H{"status": "ok"}, "client restart initiated")
|
||||
}
|
||||
|
||||
// InstallPlugins 安装插件到客户端
|
||||
// @Summary 安装插件
|
||||
// @Description 将指定插件安装到客户端
|
||||
// @Tags 客户端
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param id path string true "客户端ID"
|
||||
// @Param request body dto.InstallPluginsRequest true "插件列表"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Router /api/client/{id}/install-plugins [post]
|
||||
func (h *ClientHandler) InstallPlugins(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||
if !online {
|
||||
ClientNotOnline(c)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.InstallPluginsRequest
|
||||
if !BindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.app.GetServer().InstallPluginsToClient(clientID, req.Plugins); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// PluginAction 客户端插件操作
|
||||
// @Summary 插件操作
|
||||
// @Description 对客户端插件执行操作(start/stop/restart/config/delete)
|
||||
// @Tags 客户端
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param id path string true "客户端ID"
|
||||
// @Param pluginID path string true "插件实例ID"
|
||||
// @Param action path string true "操作类型" Enums(start, stop, restart, config, delete)
|
||||
// @Param request body dto.ClientPluginActionRequest false "操作参数"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Router /api/client/{id}/plugin/{pluginID}/{action} [post]
|
||||
func (h *ClientHandler) PluginAction(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
pluginID := c.Param("pluginID")
|
||||
action := c.Param("action")
|
||||
|
||||
var req dto.ClientPluginActionRequest
|
||||
c.ShouldBindJSON(&req) // 忽略错误,使用默认值
|
||||
|
||||
// 通过 pluginID 查找插件信息
|
||||
client, err := h.app.GetClientStore().GetClient(clientID)
|
||||
if err != nil {
|
||||
NotFound(c, "client not found")
|
||||
return
|
||||
}
|
||||
|
||||
var pluginName string
|
||||
for _, p := range client.Plugins {
|
||||
if p.ID == pluginID {
|
||||
pluginName = p.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if pluginName == "" {
|
||||
NotFound(c, "plugin not found")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RuleName == "" {
|
||||
req.RuleName = pluginName
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "start":
|
||||
err = h.app.GetServer().StartClientPlugin(clientID, pluginID, pluginName, req.RuleName)
|
||||
case "stop":
|
||||
err = h.app.GetServer().StopClientPlugin(clientID, pluginID, pluginName, req.RuleName)
|
||||
case "restart":
|
||||
err = h.app.GetServer().RestartClientPlugin(clientID, pluginID, pluginName, req.RuleName)
|
||||
case "config":
|
||||
if req.Config == nil {
|
||||
BadRequest(c, "config required")
|
||||
return
|
||||
}
|
||||
err = h.app.GetServer().UpdateClientPluginConfig(clientID, pluginID, pluginName, req.RuleName, req.Config, req.Restart)
|
||||
case "delete":
|
||||
err = h.deleteClientPlugin(clientID, pluginID)
|
||||
default:
|
||||
BadRequest(c, "unknown action: "+action)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"plugin_id": pluginID,
|
||||
"plugin": pluginName,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ClientHandler) deleteClientPlugin(clientID, pluginID string) error {
|
||||
client, err := h.app.GetClientStore().GetClient(clientID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client not found")
|
||||
}
|
||||
|
||||
var newPlugins []db.ClientPlugin
|
||||
var pluginName string
|
||||
var pluginPort int
|
||||
found := false
|
||||
for _, p := range client.Plugins {
|
||||
if p.ID == pluginID {
|
||||
found = true
|
||||
pluginName = p.Name
|
||||
pluginPort = p.RemotePort
|
||||
continue
|
||||
}
|
||||
newPlugins = append(newPlugins, p)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("plugin %s not found", pluginID)
|
||||
}
|
||||
|
||||
// 删除插件管理的代理规则
|
||||
var newRules []protocol.ProxyRule
|
||||
for _, r := range client.Rules {
|
||||
if r.PluginManaged && r.Name == pluginName {
|
||||
continue // 跳过此插件的规则
|
||||
}
|
||||
newRules = append(newRules, r)
|
||||
}
|
||||
|
||||
// 停止端口监听器
|
||||
if pluginPort > 0 {
|
||||
h.app.GetServer().StopPluginRule(clientID, pluginPort)
|
||||
}
|
||||
|
||||
client.Plugins = newPlugins
|
||||
client.Rules = newRules
|
||||
return h.app.GetClientStore().UpdateClient(client)
|
||||
}
|
||||
|
||||
// GetSystemStats 获取客户端系统状态
|
||||
func (h *ClientHandler) GetSystemStats(c *gin.Context) {
|
||||
@@ -462,6 +286,47 @@ func (h *ClientHandler) GetSystemStats(c *gin.Context) {
|
||||
Success(c, stats)
|
||||
}
|
||||
|
||||
// GetScreenshot 获取客户端截图
|
||||
func (h *ClientHandler) GetScreenshot(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
quality := 0
|
||||
if q, ok := c.GetQuery("quality"); ok {
|
||||
fmt.Sscanf(q, "%d", &quality)
|
||||
}
|
||||
|
||||
screenshot, err := h.app.GetServer().GetClientScreenshot(clientID, quality)
|
||||
if err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, screenshot)
|
||||
}
|
||||
|
||||
// ExecuteShellRequest Shell 执行请求体
|
||||
type ExecuteShellRequest struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
// ExecuteShell 执行 Shell 命令
|
||||
func (h *ClientHandler) ExecuteShell(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
var req ExecuteShellRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.app.GetServer().ExecuteClientShell(clientID, req.Command, req.Timeout)
|
||||
if err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, result)
|
||||
}
|
||||
|
||||
// validateClientID 验证客户端 ID 格式
|
||||
func validateClientID(id string) bool {
|
||||
if len(id) < 1 || len(id) > 64 {
|
||||
|
||||
@@ -47,9 +47,6 @@ func (h *ConfigHandler) Get(c *gin.Context) {
|
||||
Username: cfg.Server.Web.Username,
|
||||
Password: "****",
|
||||
},
|
||||
PluginStore: dto.PluginStoreConfigInfo{
|
||||
URL: cfg.Server.PluginStore.URL,
|
||||
},
|
||||
}
|
||||
|
||||
Success(c, resp)
|
||||
@@ -103,11 +100,6 @@ func (h *ConfigHandler) Update(c *gin.Context) {
|
||||
cfg.Server.Web.Password = req.Web.Password
|
||||
}
|
||||
|
||||
// 更新 PluginStore 配置
|
||||
if req.PluginStore != nil {
|
||||
cfg.Server.PluginStore.URL = req.PluginStore.URL
|
||||
}
|
||||
|
||||
if err := h.app.SaveConfig(); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
|
||||
@@ -165,6 +165,12 @@ func performSelfUpdate(downloadURL string, restart bool) error {
|
||||
// performWindowsUpdate Windows 平台更新
|
||||
func performWindowsUpdate(newFile, currentPath string, restart bool) error {
|
||||
batchScript := fmt.Sprintf(`@echo off
|
||||
:: Check for admin rights, request UAC elevation if needed
|
||||
net session >nul 2>&1
|
||||
if %%errorlevel%% neq 0 (
|
||||
powershell -Command "Start-Process cmd -ArgumentList '/C \\"\"%%~f0\"\"' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
ping 127.0.0.1 -n 2 > nul
|
||||
del "%s"
|
||||
move "%s" "%s"
|
||||
|
||||
183
internal/server/router/handler/install.go
Normal file
183
internal/server/router/handler/install.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
)
|
||||
|
||||
type InstallHandler struct {
|
||||
app AppInterface
|
||||
}
|
||||
|
||||
const (
|
||||
installTokenHeader = "X-GoTunnel-Install-Token"
|
||||
installTokenTTL = 3600
|
||||
)
|
||||
|
||||
func NewInstallHandler(app AppInterface) *InstallHandler {
|
||||
return &InstallHandler{app: app}
|
||||
}
|
||||
|
||||
type InstallCommandResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TunnelPort int `json:"tunnel_port"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// @Produce json
|
||||
// @Success 200 {object} InstallCommandResponse
|
||||
// @Router /api/install/generate [post]
|
||||
func (h *InstallHandler) GenerateInstallCommand(c *gin.Context) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
now := time.Now().Unix()
|
||||
|
||||
installToken := &db.InstallToken{
|
||||
Token: token,
|
||||
CreatedAt: now,
|
||||
Used: false,
|
||||
}
|
||||
|
||||
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
|
||||
if !ok {
|
||||
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": "failed to persist token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, InstallCommandResponse{
|
||||
Token: token,
|
||||
ExpiresAt: now + installTokenTTL,
|
||||
TunnelPort: h.app.GetServer().GetBindPort(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *InstallHandler) ServeShellScript(c *gin.Context) {
|
||||
if !h.validateInstallToken(c) {
|
||||
return
|
||||
}
|
||||
|
||||
applyInstallSecurityHeaders(c)
|
||||
c.Header("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||
c.String(http.StatusOK, shellInstallScript)
|
||||
}
|
||||
|
||||
func (h *InstallHandler) ServePowerShellScript(c *gin.Context) {
|
||||
if !h.validateInstallToken(c) {
|
||||
return
|
||||
}
|
||||
|
||||
applyInstallSecurityHeaders(c)
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, powerShellInstallScript)
|
||||
}
|
||||
|
||||
func (h *InstallHandler) DownloadClient(c *gin.Context) {
|
||||
if !h.validateInstallToken(c) {
|
||||
return
|
||||
}
|
||||
|
||||
osName := c.Query("os")
|
||||
arch := c.Query("arch")
|
||||
|
||||
updateInfo, err := checkClientUpdateForPlatform(osName, arch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve client package"})
|
||||
return
|
||||
}
|
||||
if updateInfo.DownloadURL == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no client package found for this platform"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, updateInfo.DownloadURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create download request"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download client package"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("upstream returned %s", resp.Status)})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
applyInstallSecurityHeaders(c)
|
||||
c.Header("Content-Type", contentType)
|
||||
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
||||
c.Header("Content-Length", contentLength)
|
||||
}
|
||||
if updateInfo.AssetName != "" {
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, updateInfo.AssetName))
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func (h *InstallHandler) validateInstallToken(c *gin.Context) bool {
|
||||
token := strings.TrimSpace(c.GetHeader(installTokenHeader))
|
||||
if token == "" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
|
||||
store, ok := h.app.GetClientStore().(db.InstallTokenStore)
|
||||
if !ok {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
|
||||
installToken, err := store.GetInstallToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
|
||||
if installToken.Used || time.Now().Unix()-installToken.CreatedAt >= installTokenTTL {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applyInstallSecurityHeaders(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||
}
|
||||
185
internal/server/router/handler/install_scripts.go
Normal file
185
internal/server/router/handler/install_scripts.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
const shellInstallScript = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash install.sh -s <server:port> -t <token> -b <web-base-url>
|
||||
|
||||
Options:
|
||||
-s Tunnel server address, for example 10.0.0.2:7000
|
||||
-t One-time install token generated by the server
|
||||
-b Web console base URL, for example https://example.com:7500
|
||||
EOF
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux) echo "linux" ;;
|
||||
Darwin) echo "darwin" ;;
|
||||
*)
|
||||
echo "unsupported operating system: $(uname -s)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64" ;;
|
||||
aarch64|arm64) echo "arm64" ;;
|
||||
i386|i686) echo "386" ;;
|
||||
armv7l|armv6l|arm) echo "arm" ;;
|
||||
*)
|
||||
echo "unsupported architecture: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
SERVER_ADDR=""
|
||||
INSTALL_TOKEN=""
|
||||
BASE_URL=""
|
||||
|
||||
while getopts ":s:t:b:h" opt; do
|
||||
case "$opt" in
|
||||
s) SERVER_ADDR="$OPTARG" ;;
|
||||
t) INSTALL_TOKEN="$OPTARG" ;;
|
||||
b) BASE_URL="$OPTARG" ;;
|
||||
h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
:)
|
||||
echo "option -$OPTARG requires a value" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
\?)
|
||||
echo "unknown option: -$OPTARG" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SERVER_ADDR" || -z "$INSTALL_TOKEN" || -z "$BASE_URL" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_cmd curl
|
||||
require_cmd tar
|
||||
require_cmd mktemp
|
||||
|
||||
OS_NAME="$(detect_os)"
|
||||
ARCH_NAME="$(detect_arch)"
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
INSTALL_ROOT="${HOME:-$(pwd)}/.gotunnel"
|
||||
BIN_DIR="$INSTALL_ROOT/bin"
|
||||
TARGET_BIN="$BIN_DIR/gotunnel-client"
|
||||
LOG_FILE="$INSTALL_ROOT/client.log"
|
||||
PID_FILE="$INSTALL_ROOT/client.pid"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
ARCHIVE_PATH="$TMP_DIR/gotunnel-client.tar.gz"
|
||||
DOWNLOAD_URL="$BASE_URL/install/client?os=$OS_NAME&arch=$ARCH_NAME"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
echo "Downloading GoTunnel client from $DOWNLOAD_URL"
|
||||
curl -fsSL -H "X-GoTunnel-Install-Token: $INSTALL_TOKEN" "$DOWNLOAD_URL" -o "$ARCHIVE_PATH"
|
||||
tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
|
||||
|
||||
EXTRACTED_BIN="$(find "$TMP_DIR" -type f -name 'gotunnel-client*' ! -name '*.tar.gz' ! -name '*.zip' | head -n 1)"
|
||||
if [[ -z "$EXTRACTED_BIN" ]]; then
|
||||
echo "failed to find extracted client binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$EXTRACTED_BIN" "$TARGET_BIN"
|
||||
chmod 0755 "$TARGET_BIN"
|
||||
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||
if [[ -n "$OLD_PID" ]]; then
|
||||
kill "$OLD_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
nohup "$TARGET_BIN" -s "$SERVER_ADDR" -t "$INSTALL_TOKEN" >>"$LOG_FILE" 2>&1 &
|
||||
NEW_PID=$!
|
||||
echo "$NEW_PID" >"$PID_FILE"
|
||||
|
||||
echo "GoTunnel client installed to $TARGET_BIN"
|
||||
echo "Client started in background with PID $NEW_PID"
|
||||
echo "Logs: $LOG_FILE"
|
||||
`
|
||||
|
||||
const powerShellInstallScript = `function Get-GoTunnelArch {
|
||||
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) {
|
||||
'x64' { return 'amd64' }
|
||||
'arm64' { return 'arm64' }
|
||||
'x86' { return '386' }
|
||||
default { throw "Unsupported architecture: $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" }
|
||||
}
|
||||
}
|
||||
|
||||
function Install-GoTunnel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Server,
|
||||
[Parameter(Mandatory = $true)][string]$Token,
|
||||
[Parameter(Mandatory = $true)][string]$BaseUrl
|
||||
)
|
||||
|
||||
$BaseUrl = $BaseUrl.TrimEnd('/')
|
||||
$Arch = Get-GoTunnelArch
|
||||
$InstallRoot = Join-Path $env:LOCALAPPDATA 'GoTunnel'
|
||||
$ExtractDir = Join-Path $InstallRoot 'extract'
|
||||
$ArchivePath = Join-Path $InstallRoot 'gotunnel-client.zip'
|
||||
$TargetPath = Join-Path $InstallRoot 'gotunnel-client.exe'
|
||||
$DownloadUrl = "$BaseUrl/install/client?os=windows&arch=$Arch"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
|
||||
|
||||
Write-Host "Downloading GoTunnel client from $DownloadUrl"
|
||||
$Headers = @{ 'X-GoTunnel-Install-Token' = $Token }
|
||||
Invoke-WebRequest -Uri $DownloadUrl -Headers $Headers -OutFile $ArchivePath -MaximumRedirection 5
|
||||
|
||||
if (Test-Path $ExtractDir) {
|
||||
Remove-Item -Path $ExtractDir -Recurse -Force
|
||||
}
|
||||
Expand-Archive -Path $ArchivePath -DestinationPath $ExtractDir -Force
|
||||
|
||||
$Binary = Get-ChildItem -Path $ExtractDir -Recurse -File |
|
||||
Where-Object { $_.Name -eq 'gotunnel-client.exe' } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $Binary) {
|
||||
throw 'Failed to find extracted client binary.'
|
||||
}
|
||||
|
||||
Copy-Item -Path $Binary.FullName -Destination $TargetPath -Force
|
||||
|
||||
Get-Process |
|
||||
Where-Object { $_.Path -eq $TargetPath } |
|
||||
Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Process -FilePath $TargetPath -ArgumentList @('-s', $Server, '-t', $Token) -WindowStyle Hidden
|
||||
|
||||
Write-Host "GoTunnel client installed to $TargetPath"
|
||||
Write-Host 'Client started in background.'
|
||||
}
|
||||
`
|
||||
@@ -13,17 +13,18 @@ type AppInterface interface {
|
||||
GetConfig() *config.ServerConfig
|
||||
GetConfigPath() string
|
||||
SaveConfig() error
|
||||
GetJSPluginStore() db.JSPluginStore
|
||||
GetTrafficStore() db.TrafficStore
|
||||
}
|
||||
|
||||
// ServerInterface 服务端接口
|
||||
type ServerInterface interface {
|
||||
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientOS, clientArch, clientVersion string)
|
||||
IsClientOnline(clientID string) bool
|
||||
GetClientStatus(clientID string) (online bool, lastPing, remoteAddr, clientName, clientOS, clientArch, clientVersion string)
|
||||
GetAllClientStatus() map[string]struct {
|
||||
Online bool
|
||||
LastPing string
|
||||
RemoteAddr string
|
||||
Name string
|
||||
OS string
|
||||
Arch string
|
||||
Version string
|
||||
@@ -33,72 +34,17 @@ type ServerInterface interface {
|
||||
GetBindPort() int
|
||||
PushConfigToClient(clientID string) error
|
||||
DisconnectClient(clientID string) error
|
||||
GetPluginList() []PluginInfo
|
||||
EnablePlugin(name string) error
|
||||
DisablePlugin(name string) error
|
||||
InstallPluginsToClient(clientID string, plugins []string) error
|
||||
GetPluginConfigSchema(name string) ([]ConfigField, error)
|
||||
SyncPluginConfigToClient(clientID string, pluginName string, config map[string]string) error
|
||||
InstallJSPluginToClient(clientID string, req JSPluginInstallRequest) error
|
||||
RestartClient(clientID string) error
|
||||
StartClientPlugin(clientID, pluginID, pluginName, ruleName string) error
|
||||
StopClientPlugin(clientID, pluginID, pluginName, ruleName string) error
|
||||
RestartClientPlugin(clientID, pluginID, pluginName, ruleName string) error
|
||||
UpdateClientPluginConfig(clientID, pluginID, pluginName, ruleName string, config map[string]string, restart bool) error
|
||||
SendUpdateToClient(clientID, downloadURL string) error
|
||||
// 日志流
|
||||
StartClientLogStream(clientID, sessionID string, lines int, follow bool, level string) (<-chan protocol.LogEntry, error)
|
||||
StopClientLogStream(sessionID string)
|
||||
// 插件状态查询
|
||||
GetClientPluginStatus(clientID string) ([]protocol.PluginStatusEntry, error)
|
||||
// 插件规则管理
|
||||
StartPluginRule(clientID string, rule protocol.ProxyRule) error
|
||||
StopPluginRule(clientID string, remotePort int) error
|
||||
// 端口检查
|
||||
IsPortAvailable(port int, excludeClientID string) bool
|
||||
// 插件 API 代理
|
||||
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
|
||||
// 系统状态
|
||||
GetClientSystemStats(clientID string) (*protocol.SystemStatsResponse, error)
|
||||
}
|
||||
|
||||
// ConfigField 配置字段
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// RuleSchema 规则表单模式
|
||||
type RuleSchema struct {
|
||||
NeedsLocalAddr bool `json:"needs_local_addr"`
|
||||
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
|
||||
}
|
||||
|
||||
// PluginInfo 插件信息
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
|
||||
}
|
||||
|
||||
// JSPluginInstallRequest JS 插件安装请求
|
||||
type JSPluginInstallRequest struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
PluginName string `json:"plugin_name"`
|
||||
Source string `json:"source"`
|
||||
Signature string `json:"signature"`
|
||||
RuleName string `json:"rule_name"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
Config map[string]string `json:"config"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
// 截图
|
||||
GetClientScreenshot(clientID string, quality int) (*protocol.ScreenshotResponse, error)
|
||||
// Shell 执行
|
||||
ExecuteClientShell(clientID, command string, timeout int) (*protocol.ShellExecuteResponse, error)
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
// removed router import
|
||||
"github.com/gotunnel/internal/server/router/dto"
|
||||
)
|
||||
|
||||
// JSPluginHandler JS 插件处理器
|
||||
type JSPluginHandler struct {
|
||||
app AppInterface
|
||||
}
|
||||
|
||||
// NewJSPluginHandler 创建 JS 插件处理器
|
||||
func NewJSPluginHandler(app AppInterface) *JSPluginHandler {
|
||||
return &JSPluginHandler{app: app}
|
||||
}
|
||||
|
||||
// List 获取 JS 插件列表
|
||||
// @Summary 获取所有 JS 插件
|
||||
// @Description 返回所有注册的 JS 插件
|
||||
// @Tags JS插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Success 200 {object} Response{data=[]db.JSPlugin}
|
||||
// @Router /api/js-plugins [get]
|
||||
func (h *JSPluginHandler) List(c *gin.Context) {
|
||||
plugins, err := h.app.GetJSPluginStore().GetAllJSPlugins()
|
||||
if err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
if plugins == nil {
|
||||
plugins = []db.JSPlugin{}
|
||||
}
|
||||
Success(c, plugins)
|
||||
}
|
||||
|
||||
// Create 创建 JS 插件
|
||||
// @Summary 创建 JS 插件
|
||||
// @Description 创建新的 JS 插件
|
||||
// @Tags JS插件
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param request body dto.JSPluginCreateRequest true "插件信息"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Router /api/js-plugins [post]
|
||||
func (h *JSPluginHandler) Create(c *gin.Context) {
|
||||
var req dto.JSPluginCreateRequest
|
||||
if !BindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
plugin := &db.JSPlugin{
|
||||
Name: req.Name,
|
||||
Source: req.Source,
|
||||
Signature: req.Signature,
|
||||
Description: req.Description,
|
||||
Author: req.Author,
|
||||
Config: req.Config,
|
||||
AutoStart: req.AutoStart,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// Get 获取单个 JS 插件
|
||||
// @Summary 获取 JS 插件详情
|
||||
// @Description 获取指定 JS 插件的详细信息
|
||||
// @Tags JS插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param name path string true "插件名称"
|
||||
// @Success 200 {object} Response{data=db.JSPlugin}
|
||||
// @Failure 404 {object} Response
|
||||
// @Router /api/js-plugin/{name} [get]
|
||||
func (h *JSPluginHandler) Get(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
|
||||
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(name)
|
||||
if err != nil {
|
||||
NotFound(c, "plugin not found")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, plugin)
|
||||
}
|
||||
|
||||
// Update 更新 JS 插件
|
||||
// @Summary 更新 JS 插件
|
||||
// @Description 更新指定 JS 插件的信息
|
||||
// @Tags JS插件
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param name path string true "插件名称"
|
||||
// @Param request body dto.JSPluginUpdateRequest true "更新内容"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Router /api/js-plugin/{name} [put]
|
||||
func (h *JSPluginHandler) Update(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
|
||||
var req dto.JSPluginUpdateRequest
|
||||
if !BindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
plugin := &db.JSPlugin{
|
||||
Name: name,
|
||||
Source: req.Source,
|
||||
Signature: req.Signature,
|
||||
Description: req.Description,
|
||||
Author: req.Author,
|
||||
Config: req.Config,
|
||||
AutoStart: req.AutoStart,
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
|
||||
if err := h.app.GetJSPluginStore().SaveJSPlugin(plugin); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// Delete 删除 JS 插件
|
||||
// @Summary 删除 JS 插件
|
||||
// @Description 删除指定的 JS 插件
|
||||
// @Tags JS插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param name path string true "插件名称"
|
||||
// @Success 200 {object} Response
|
||||
// @Router /api/js-plugin/{name} [delete]
|
||||
func (h *JSPluginHandler) Delete(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
|
||||
if err := h.app.GetJSPluginStore().DeleteJSPlugin(name); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// PushToClient 推送 JS 插件到客户端
|
||||
// @Summary 推送插件到客户端
|
||||
// @Description 将 JS 插件推送到指定客户端
|
||||
// @Tags JS插件
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param name path string true "插件名称"
|
||||
// @Param clientID path string true "客户端ID"
|
||||
// @Param request body dto.JSPluginPushRequest false "推送配置"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Router /api/js-plugin/{name}/push/{clientID} [post]
|
||||
func (h *JSPluginHandler) PushToClient(c *gin.Context) {
|
||||
pluginName := c.Param("name")
|
||||
clientID := c.Param("clientID")
|
||||
|
||||
// 解析请求体(可选)
|
||||
var pushReq dto.JSPluginPushRequest
|
||||
c.ShouldBindJSON(&pushReq) // 忽略错误,允许空请求体
|
||||
|
||||
// 检查客户端是否在线
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||
if !online {
|
||||
ClientNotOnline(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取插件
|
||||
plugin, err := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
|
||||
if err != nil {
|
||||
NotFound(c, "plugin not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !plugin.Enabled {
|
||||
Error(c, 400, CodePluginDisabled, "plugin is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// 推送到客户端
|
||||
req := JSPluginInstallRequest{
|
||||
PluginName: plugin.Name,
|
||||
Source: plugin.Source,
|
||||
Signature: plugin.Signature,
|
||||
RuleName: plugin.Name,
|
||||
RemotePort: pushReq.RemotePort,
|
||||
Config: plugin.Config,
|
||||
AutoStart: plugin.AutoStart,
|
||||
}
|
||||
|
||||
if err := h.app.GetServer().InstallJSPluginToClient(clientID, req); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": "ok",
|
||||
"plugin": pluginName,
|
||||
"client": clientID,
|
||||
"remote_port": pushReq.RemotePort,
|
||||
})
|
||||
}
|
||||
@@ -35,8 +35,7 @@ func (h *LogHandler) StreamLogs(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
|
||||
// 检查客户端是否在线
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||
if !online {
|
||||
if !h.app.GetServer().IsClientOnline(clientID) {
|
||||
c.JSON(400, gin.H{"code": 400, "message": "client not online"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
"github.com/gotunnel/internal/server/router/dto"
|
||||
"github.com/gotunnel/pkg/plugin"
|
||||
)
|
||||
|
||||
// PluginHandler 插件处理器
|
||||
type PluginHandler struct {
|
||||
app AppInterface
|
||||
}
|
||||
|
||||
// NewPluginHandler 创建插件处理器
|
||||
func NewPluginHandler(app AppInterface) *PluginHandler {
|
||||
return &PluginHandler{app: app}
|
||||
}
|
||||
|
||||
// List 获取插件列表
|
||||
// @Summary 获取所有插件
|
||||
// @Description 返回服务端所有注册的插件
|
||||
// @Tags 插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Success 200 {object} Response{data=[]dto.PluginInfo}
|
||||
// @Router /api/plugins [get]
|
||||
func (h *PluginHandler) List(c *gin.Context) {
|
||||
plugins := h.app.GetServer().GetPluginList()
|
||||
|
||||
result := make([]dto.PluginInfo, len(plugins))
|
||||
for i, p := range plugins {
|
||||
result[i] = dto.PluginInfo{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Type: p.Type,
|
||||
Description: p.Description,
|
||||
Source: p.Source,
|
||||
Icon: p.Icon,
|
||||
Enabled: p.Enabled,
|
||||
}
|
||||
if p.RuleSchema != nil {
|
||||
result[i].RuleSchema = &dto.RuleSchema{
|
||||
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
|
||||
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Success(c, result)
|
||||
}
|
||||
|
||||
// Enable 启用插件
|
||||
// @Summary 启用插件
|
||||
// @Description 启用指定插件
|
||||
// @Tags 插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param name path string true "插件名称"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Router /api/plugin/{name}/enable [post]
|
||||
func (h *PluginHandler) Enable(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
|
||||
if err := h.app.GetServer().EnablePlugin(name); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// Disable 禁用插件
|
||||
// @Summary 禁用插件
|
||||
// @Description 禁用指定插件
|
||||
// @Tags 插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param name path string true "插件名称"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Router /api/plugin/{name}/disable [post]
|
||||
func (h *PluginHandler) Disable(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
|
||||
if err := h.app.GetServer().DisablePlugin(name); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetRuleSchemas 获取规则配置模式
|
||||
// @Summary 获取规则模式
|
||||
// @Description 返回所有协议类型的配置模式
|
||||
// @Tags 插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Success 200 {object} Response{data=map[string]dto.RuleSchema}
|
||||
// @Router /api/rule-schemas [get]
|
||||
func (h *PluginHandler) GetRuleSchemas(c *gin.Context) {
|
||||
// 获取内置协议模式
|
||||
schemas := make(map[string]dto.RuleSchema)
|
||||
for name, schema := range plugin.BuiltinRuleSchemas() {
|
||||
schemas[name] = dto.RuleSchema{
|
||||
NeedsLocalAddr: schema.NeedsLocalAddr,
|
||||
ExtraFields: convertConfigFields(schema.ExtraFields),
|
||||
}
|
||||
}
|
||||
|
||||
// 添加已注册插件的模式
|
||||
plugins := h.app.GetServer().GetPluginList()
|
||||
for _, p := range plugins {
|
||||
if p.RuleSchema != nil {
|
||||
schemas[p.Name] = dto.RuleSchema{
|
||||
NeedsLocalAddr: p.RuleSchema.NeedsLocalAddr,
|
||||
ExtraFields: convertRouterConfigFields(p.RuleSchema.ExtraFields),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Success(c, schemas)
|
||||
}
|
||||
|
||||
// GetClientConfig 获取客户端插件配置
|
||||
// @Summary 获取客户端插件配置
|
||||
// @Description 获取客户端上指定插件的配置
|
||||
// @Tags 插件
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param clientID path string true "客户端ID"
|
||||
// @Param pluginName path string true "插件名称"
|
||||
// @Success 200 {object} Response{data=dto.PluginConfigResponse}
|
||||
// @Failure 404 {object} Response
|
||||
// @Router /api/client-plugin/{clientID}/{pluginName}/config [get]
|
||||
func (h *PluginHandler) GetClientConfig(c *gin.Context) {
|
||||
clientID := c.Param("clientID")
|
||||
pluginName := c.Param("pluginName")
|
||||
|
||||
client, err := h.app.GetClientStore().GetClient(clientID)
|
||||
if err != nil {
|
||||
NotFound(c, "client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 查找客户端的插件
|
||||
var clientPlugin *db.ClientPlugin
|
||||
for i, p := range client.Plugins {
|
||||
if p.Name == pluginName {
|
||||
clientPlugin = &client.Plugins[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if clientPlugin == nil {
|
||||
NotFound(c, "plugin not installed on client")
|
||||
return
|
||||
}
|
||||
|
||||
var schemaFields []dto.ConfigField
|
||||
|
||||
// 优先使用客户端插件保存的 ConfigSchema
|
||||
if len(clientPlugin.ConfigSchema) > 0 {
|
||||
for _, f := range clientPlugin.ConfigSchema {
|
||||
schemaFields = append(schemaFields, dto.ConfigField{
|
||||
Key: f.Key,
|
||||
Label: f.Label,
|
||||
Type: f.Type,
|
||||
Default: f.Default,
|
||||
Required: f.Required,
|
||||
Options: f.Options,
|
||||
Description: f.Description,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 尝试从内置插件获取配置模式
|
||||
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
|
||||
if err != nil {
|
||||
// 如果内置插件中找不到,尝试从 JS 插件获取
|
||||
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
|
||||
if jsErr == nil {
|
||||
// 使用 JS 插件的 config 作为动态 schema
|
||||
for key := range jsPlugin.Config {
|
||||
schemaFields = append(schemaFields, dto.ConfigField{
|
||||
Key: key,
|
||||
Label: key,
|
||||
Type: "string",
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
schemaFields = convertRouterConfigFields(schema)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 remote_port 作为系统配置字段(始终显示)
|
||||
schemaFields = append([]dto.ConfigField{{
|
||||
Key: "remote_port",
|
||||
Label: "远程端口",
|
||||
Type: "number",
|
||||
Description: "服务端监听端口,修改后需重启插件生效",
|
||||
}}, schemaFields...)
|
||||
|
||||
// 添加 Auth 配置字段
|
||||
schemaFields = append(schemaFields, dto.ConfigField{
|
||||
Key: "auth_enabled",
|
||||
Label: "启用认证",
|
||||
Type: "bool",
|
||||
Description: "启用 HTTP Basic Auth 保护",
|
||||
}, dto.ConfigField{
|
||||
Key: "auth_username",
|
||||
Label: "认证用户名",
|
||||
Type: "string",
|
||||
Description: "HTTP Basic Auth 用户名",
|
||||
}, dto.ConfigField{
|
||||
Key: "auth_password",
|
||||
Label: "认证密码",
|
||||
Type: "password",
|
||||
Description: "HTTP Basic Auth 密码",
|
||||
})
|
||||
|
||||
// 构建配置值
|
||||
config := clientPlugin.Config
|
||||
if config == nil {
|
||||
config = make(map[string]string)
|
||||
}
|
||||
// 将 remote_port 加入配置
|
||||
if clientPlugin.RemotePort > 0 {
|
||||
config["remote_port"] = fmt.Sprintf("%d", clientPlugin.RemotePort)
|
||||
}
|
||||
// 将 Auth 配置加入
|
||||
if clientPlugin.AuthEnabled {
|
||||
config["auth_enabled"] = "true"
|
||||
} else {
|
||||
config["auth_enabled"] = "false"
|
||||
}
|
||||
config["auth_username"] = clientPlugin.AuthUsername
|
||||
config["auth_password"] = clientPlugin.AuthPassword
|
||||
|
||||
Success(c, dto.PluginConfigResponse{
|
||||
PluginName: pluginName,
|
||||
Schema: schemaFields,
|
||||
Config: config,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateClientConfig 更新客户端插件配置
|
||||
// @Summary 更新客户端插件配置
|
||||
// @Description 更新客户端上指定插件的配置
|
||||
// @Tags 插件
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param clientID path string true "客户端ID"
|
||||
// @Param pluginName path string true "插件名称"
|
||||
// @Param request body dto.PluginConfigRequest true "配置内容"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Router /api/client-plugin/{clientID}/{pluginName}/config [put]
|
||||
func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
|
||||
clientID := c.Param("clientID")
|
||||
pluginName := c.Param("pluginName")
|
||||
|
||||
var req dto.PluginConfigRequest
|
||||
if !BindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := h.app.GetClientStore().GetClient(clientID)
|
||||
if err != nil {
|
||||
NotFound(c, "client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新插件配置
|
||||
found := false
|
||||
portChanged := false
|
||||
authChanged := false
|
||||
var oldPort, newPort int
|
||||
for i, p := range client.Plugins {
|
||||
if p.Name == pluginName {
|
||||
oldPort = client.Plugins[i].RemotePort
|
||||
// 提取 remote_port 并单独处理
|
||||
if portStr, ok := req.Config["remote_port"]; ok {
|
||||
fmt.Sscanf(portStr, "%d", &newPort)
|
||||
if newPort > 0 && newPort != oldPort {
|
||||
// 检查新端口是否可用
|
||||
if !h.app.GetServer().IsPortAvailable(newPort, clientID) {
|
||||
BadRequest(c, fmt.Sprintf("port %d is already in use", newPort))
|
||||
return
|
||||
}
|
||||
client.Plugins[i].RemotePort = newPort
|
||||
portChanged = true
|
||||
}
|
||||
delete(req.Config, "remote_port") // 不保存到 Config map
|
||||
}
|
||||
// 提取 Auth 配置并单独处理
|
||||
if authEnabledStr, ok := req.Config["auth_enabled"]; ok {
|
||||
newAuthEnabled := authEnabledStr == "true"
|
||||
if newAuthEnabled != client.Plugins[i].AuthEnabled {
|
||||
client.Plugins[i].AuthEnabled = newAuthEnabled
|
||||
authChanged = true
|
||||
}
|
||||
delete(req.Config, "auth_enabled")
|
||||
}
|
||||
if authUsername, ok := req.Config["auth_username"]; ok {
|
||||
if authUsername != client.Plugins[i].AuthUsername {
|
||||
client.Plugins[i].AuthUsername = authUsername
|
||||
authChanged = true
|
||||
}
|
||||
delete(req.Config, "auth_username")
|
||||
}
|
||||
if authPassword, ok := req.Config["auth_password"]; ok {
|
||||
if authPassword != client.Plugins[i].AuthPassword {
|
||||
client.Plugins[i].AuthPassword = authPassword
|
||||
authChanged = true
|
||||
}
|
||||
delete(req.Config, "auth_password")
|
||||
}
|
||||
client.Plugins[i].Config = req.Config
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
NotFound(c, "plugin not installed on client")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果端口变更,同步更新代理规则
|
||||
if portChanged {
|
||||
for i, r := range client.Rules {
|
||||
if r.Name == pluginName && r.PluginManaged {
|
||||
client.Rules[i].RemotePort = newPort
|
||||
break
|
||||
}
|
||||
}
|
||||
// 停止旧端口监听器
|
||||
if oldPort > 0 {
|
||||
h.app.GetServer().StopPluginRule(clientID, oldPort)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 Auth 配置变更,同步更新代理规则
|
||||
if authChanged {
|
||||
for i, p := range client.Plugins {
|
||||
if p.Name == pluginName {
|
||||
for j, r := range client.Rules {
|
||||
if r.Name == pluginName && r.PluginManaged {
|
||||
client.Rules[j].AuthEnabled = client.Plugins[i].AuthEnabled
|
||||
client.Rules[j].AuthUsername = client.Plugins[i].AuthUsername
|
||||
client.Rules[j].AuthPassword = client.Plugins[i].AuthPassword
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := h.app.GetClientStore().UpdateClient(client); err != nil {
|
||||
InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 如果客户端在线,同步配置
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||
if online {
|
||||
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
|
||||
PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Success(c, gin.H{"status": "ok", "port_changed": portChanged})
|
||||
}
|
||||
|
||||
// convertConfigFields 转换插件配置字段到 DTO
|
||||
func convertConfigFields(fields []plugin.ConfigField) []dto.ConfigField {
|
||||
result := make([]dto.ConfigField, len(fields))
|
||||
for i, f := range fields {
|
||||
result[i] = dto.ConfigField{
|
||||
Key: f.Key,
|
||||
Label: f.Label,
|
||||
Type: string(f.Type),
|
||||
Default: f.Default,
|
||||
Required: f.Required,
|
||||
Options: f.Options,
|
||||
Description: f.Description,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertRouterConfigFields 转换 ConfigField 到 dto.ConfigField
|
||||
func convertRouterConfigFields(fields []ConfigField) []dto.ConfigField {
|
||||
result := make([]dto.ConfigField, len(fields))
|
||||
for i, f := range fields {
|
||||
result[i] = dto.ConfigField{
|
||||
Key: f.Key,
|
||||
Label: f.Label,
|
||||
Type: f.Type,
|
||||
Default: f.Default,
|
||||
Required: f.Required,
|
||||
Options: f.Options,
|
||||
Description: f.Description,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gotunnel/pkg/protocol"
|
||||
)
|
||||
|
||||
// PluginAPIHandler 插件 API 代理处理器
|
||||
type PluginAPIHandler struct {
|
||||
app AppInterface
|
||||
}
|
||||
|
||||
// NewPluginAPIHandler 创建插件 API 代理处理器
|
||||
func NewPluginAPIHandler(app AppInterface) *PluginAPIHandler {
|
||||
return &PluginAPIHandler{app: app}
|
||||
}
|
||||
|
||||
// ProxyRequest 代理请求到客户端插件
|
||||
// @Summary 代理插件 API 请求
|
||||
// @Description 将请求代理到客户端的 JS 插件处理
|
||||
// @Tags 插件 API
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param id path string true "客户端 ID"
|
||||
// @Param pluginID path string true "插件实例 ID"
|
||||
// @Param route path string true "插件路由"
|
||||
// @Success 200 {object} object
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 502 {object} Response
|
||||
// @Router /api/client/{id}/plugin-api/{pluginID}/{route} [get]
|
||||
func (h *PluginAPIHandler) ProxyRequest(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
pluginID := c.Param("pluginID")
|
||||
route := c.Param("route")
|
||||
|
||||
// 确保路由以 / 开头
|
||||
if !strings.HasPrefix(route, "/") {
|
||||
route = "/" + route
|
||||
}
|
||||
|
||||
// 检查客户端是否在线
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(clientID)
|
||||
if !online {
|
||||
ClientNotOnline(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取请求体
|
||||
var body string
|
||||
if c.Request.Body != nil {
|
||||
bodyBytes, _ := io.ReadAll(c.Request.Body)
|
||||
body = string(bodyBytes)
|
||||
}
|
||||
|
||||
// 构建请求头
|
||||
headers := make(map[string]string)
|
||||
for key, values := range c.Request.Header {
|
||||
if len(values) > 0 {
|
||||
headers[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 API 请求
|
||||
apiReq := protocol.PluginAPIRequest{
|
||||
PluginID: pluginID,
|
||||
Method: c.Request.Method,
|
||||
Path: route,
|
||||
Query: c.Request.URL.RawQuery,
|
||||
Headers: headers,
|
||||
Body: body,
|
||||
}
|
||||
|
||||
// 发送请求到客户端
|
||||
resp, err := h.app.GetServer().ProxyPluginAPIRequest(clientID, apiReq)
|
||||
if err != nil {
|
||||
BadGateway(c, "Plugin request failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if resp.Error != "" {
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"code": 502,
|
||||
"message": resp.Error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
for key, value := range resp.Headers {
|
||||
c.Header(key, value)
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.String(resp.Status, resp.Body)
|
||||
}
|
||||
|
||||
// ProxyPluginAPIRequest 接口方法声明 - 添加到 ServerInterface
|
||||
type PluginAPIProxyInterface interface {
|
||||
ProxyPluginAPIRequest(clientID string, req protocol.PluginAPIRequest) (*protocol.PluginAPIResponse, error)
|
||||
}
|
||||
|
||||
// AuthConfig 认证配置
|
||||
type AuthConfig struct {
|
||||
Type string `json:"type"` // none, basic, token
|
||||
Username string `json:"username"` // Basic Auth 用户名
|
||||
Password string `json:"password"` // Basic Auth 密码
|
||||
Token string `json:"token"` // Token 认证
|
||||
}
|
||||
|
||||
// BasicAuthMiddleware 创建 Basic Auth 中间件
|
||||
func BasicAuthMiddleware(username, password string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, pass, ok := c.Request.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
c.Header("WWW-Authenticate", `Basic realm="Plugin"`)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout 带超时的请求处理
|
||||
func WithTimeout(timeout time.Duration, handler gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 设置请求超时
|
||||
c.Request = c.Request.WithContext(c.Request.Context())
|
||||
handler(c)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
"github.com/gotunnel/internal/server/router/dto"
|
||||
"github.com/gotunnel/pkg/protocol"
|
||||
)
|
||||
|
||||
// StoreHandler 插件商店处理器
|
||||
type StoreHandler struct {
|
||||
app AppInterface
|
||||
}
|
||||
|
||||
// NewStoreHandler 创建插件商店处理器
|
||||
func NewStoreHandler(app AppInterface) *StoreHandler {
|
||||
return &StoreHandler{app: app}
|
||||
}
|
||||
|
||||
// ListPlugins 获取商店插件列表
|
||||
// @Summary 获取商店插件
|
||||
// @Description 从远程插件商店获取可用插件列表
|
||||
// @Tags 插件商店
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Success 200 {object} Response{data=object{plugins=[]dto.StorePluginInfo}}
|
||||
// @Failure 502 {object} Response
|
||||
// @Router /api/store/plugins [get]
|
||||
func (h *StoreHandler) ListPlugins(c *gin.Context) {
|
||||
cfg := h.app.GetConfig()
|
||||
storeURL := cfg.Server.PluginStore.GetPluginStoreURL()
|
||||
|
||||
// 从远程 URL 获取插件列表
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(storeURL)
|
||||
if err != nil {
|
||||
BadGateway(c, "Failed to fetch store: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
BadGateway(c, "Store returned error")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
InternalError(c, "Failed to read response")
|
||||
return
|
||||
}
|
||||
|
||||
// 直接返回原始 JSON(已经是数组格式)
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Writer.Write([]byte(`{"code":0,"data":{"plugins":`))
|
||||
c.Writer.Write(body)
|
||||
c.Writer.Write([]byte(`}}`))
|
||||
}
|
||||
|
||||
// Install 从商店安装插件到客户端
|
||||
// @Summary 安装商店插件
|
||||
// @Description 从插件商店下载并安装插件到指定客户端
|
||||
// @Tags 插件商店
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param request body dto.StoreInstallRequest true "安装请求"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 502 {object} Response
|
||||
// @Router /api/store/install [post]
|
||||
func (h *StoreHandler) Install(c *gin.Context) {
|
||||
var req dto.StoreInstallRequest
|
||||
if !BindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查客户端是否在线
|
||||
online, _, _, _, _, _ := h.app.GetServer().GetClientStatus(req.ClientID)
|
||||
if !online {
|
||||
ClientNotOnline(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(req.DownloadURL)
|
||||
if err != nil {
|
||||
BadGateway(c, "Failed to download plugin: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
BadGateway(c, "Plugin download failed with status: "+resp.Status)
|
||||
return
|
||||
}
|
||||
|
||||
source, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
InternalError(c, "Failed to read plugin: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 下载签名文件
|
||||
sigResp, err := client.Get(req.SignatureURL)
|
||||
if err != nil {
|
||||
BadGateway(c, "Failed to download signature: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer sigResp.Body.Close()
|
||||
|
||||
if sigResp.StatusCode != http.StatusOK {
|
||||
BadGateway(c, "Signature download failed with status: "+sigResp.Status)
|
||||
return
|
||||
}
|
||||
|
||||
signature, err := io.ReadAll(sigResp.Body)
|
||||
if err != nil {
|
||||
InternalError(c, "Failed to read signature: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查插件是否已存在,决定使用已有 ID 还是生成新 ID
|
||||
pluginID := ""
|
||||
dbClient, err := h.app.GetClientStore().GetClient(req.ClientID)
|
||||
if err == nil {
|
||||
for _, p := range dbClient.Plugins {
|
||||
if p.Name == req.PluginName && p.ID != "" {
|
||||
pluginID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if pluginID == "" {
|
||||
pluginID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 安装到客户端
|
||||
installReq := JSPluginInstallRequest{
|
||||
PluginID: pluginID,
|
||||
PluginName: req.PluginName,
|
||||
Source: string(source),
|
||||
Signature: string(signature),
|
||||
RuleName: req.PluginName,
|
||||
RemotePort: req.RemotePort,
|
||||
AutoStart: true,
|
||||
}
|
||||
|
||||
if err := h.app.GetServer().InstallJSPluginToClient(req.ClientID, installReq); err != nil {
|
||||
InternalError(c, "Failed to install plugin: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 将插件保存到 JSPluginStore(用于客户端重连时恢复)
|
||||
jsPlugin := &db.JSPlugin{
|
||||
Name: req.PluginName,
|
||||
Source: string(source),
|
||||
Signature: string(signature),
|
||||
AutoStart: true,
|
||||
Enabled: true,
|
||||
}
|
||||
// 尝试保存,忽略错误(可能已存在)
|
||||
h.app.GetJSPluginStore().SaveJSPlugin(jsPlugin)
|
||||
|
||||
// 将插件信息保存到客户端记录
|
||||
// 重新获取 dbClient(可能已被修改)
|
||||
dbClient, err = h.app.GetClientStore().GetClient(req.ClientID)
|
||||
if err == nil {
|
||||
// 检查插件是否已存在(通过名称匹配)
|
||||
pluginExists := false
|
||||
for i, p := range dbClient.Plugins {
|
||||
if p.Name == req.PluginName {
|
||||
dbClient.Plugins[i].Enabled = true
|
||||
dbClient.Plugins[i].RemotePort = req.RemotePort
|
||||
dbClient.Plugins[i].AuthEnabled = req.AuthEnabled
|
||||
dbClient.Plugins[i].AuthUsername = req.AuthUsername
|
||||
dbClient.Plugins[i].AuthPassword = req.AuthPassword
|
||||
// 确保有 ID
|
||||
if dbClient.Plugins[i].ID == "" {
|
||||
dbClient.Plugins[i].ID = pluginID
|
||||
}
|
||||
pluginExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !pluginExists {
|
||||
version := req.Version
|
||||
if version == "" {
|
||||
version = "1.0.0"
|
||||
}
|
||||
// 转换 ConfigSchema
|
||||
var configSchema []db.ConfigField
|
||||
for _, f := range req.ConfigSchema {
|
||||
configSchema = append(configSchema, db.ConfigField{
|
||||
Key: f.Key,
|
||||
Label: f.Label,
|
||||
Type: f.Type,
|
||||
Default: f.Default,
|
||||
Required: f.Required,
|
||||
Options: f.Options,
|
||||
Description: f.Description,
|
||||
})
|
||||
}
|
||||
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
|
||||
ID: pluginID,
|
||||
Name: req.PluginName,
|
||||
Version: version,
|
||||
Enabled: true,
|
||||
RemotePort: req.RemotePort,
|
||||
ConfigSchema: configSchema,
|
||||
AuthEnabled: req.AuthEnabled,
|
||||
AuthUsername: req.AuthUsername,
|
||||
AuthPassword: req.AuthPassword,
|
||||
})
|
||||
}
|
||||
|
||||
// 自动创建代理规则(如果指定了端口)
|
||||
if req.RemotePort > 0 {
|
||||
// 检查端口是否可用
|
||||
if !h.app.GetServer().IsPortAvailable(req.RemotePort, req.ClientID) {
|
||||
InternalError(c, fmt.Sprintf("port %d is already in use", req.RemotePort))
|
||||
return
|
||||
}
|
||||
ruleExists := false
|
||||
for i, r := range dbClient.Rules {
|
||||
if r.Name == req.PluginName {
|
||||
// 更新现有规则
|
||||
dbClient.Rules[i].Type = req.PluginName
|
||||
dbClient.Rules[i].RemotePort = req.RemotePort
|
||||
dbClient.Rules[i].Enabled = boolPtr(true)
|
||||
dbClient.Rules[i].PluginID = pluginID
|
||||
dbClient.Rules[i].AuthEnabled = req.AuthEnabled
|
||||
dbClient.Rules[i].AuthUsername = req.AuthUsername
|
||||
dbClient.Rules[i].AuthPassword = req.AuthPassword
|
||||
dbClient.Rules[i].PluginManaged = true
|
||||
ruleExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ruleExists {
|
||||
// 创建新规则
|
||||
dbClient.Rules = append(dbClient.Rules, protocol.ProxyRule{
|
||||
Name: req.PluginName,
|
||||
Type: req.PluginName,
|
||||
RemotePort: req.RemotePort,
|
||||
Enabled: boolPtr(true),
|
||||
PluginID: pluginID,
|
||||
AuthEnabled: req.AuthEnabled,
|
||||
AuthUsername: req.AuthUsername,
|
||||
AuthPassword: req.AuthPassword,
|
||||
PluginManaged: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
h.app.GetClientStore().UpdateClient(dbClient)
|
||||
}
|
||||
|
||||
// 启动服务端监听器(让外部用户可以通过 RemotePort 访问插件)
|
||||
if req.RemotePort > 0 {
|
||||
pluginRule := protocol.ProxyRule{
|
||||
Name: req.PluginName,
|
||||
Type: req.PluginName, // 使用插件名作为类型,让 isClientPlugin 识别
|
||||
RemotePort: req.RemotePort,
|
||||
Enabled: boolPtr(true),
|
||||
PluginID: pluginID,
|
||||
AuthEnabled: req.AuthEnabled,
|
||||
AuthUsername: req.AuthUsername,
|
||||
AuthPassword: req.AuthPassword,
|
||||
}
|
||||
// 启动监听器(忽略错误,可能端口已被占用)
|
||||
h.app.GetServer().StartPluginRule(req.ClientID, pluginRule)
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": "ok",
|
||||
"plugin": req.PluginName,
|
||||
"plugin_id": pluginID,
|
||||
"client": req.ClientID,
|
||||
})
|
||||
}
|
||||
|
||||
// boolPtr 返回 bool 值的指针
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
@@ -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))
|
||||
@@ -67,9 +72,9 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
|
||||
api.POST("/client/:id/push", clientHandler.PushConfig)
|
||||
api.POST("/client/:id/disconnect", clientHandler.Disconnect)
|
||||
api.POST("/client/:id/restart", clientHandler.Restart)
|
||||
api.POST("/client/:id/install-plugins", clientHandler.InstallPlugins)
|
||||
api.POST("/client/:id/plugin/:pluginID/:action", clientHandler.PluginAction)
|
||||
api.GET("/client/:id/system-stats", clientHandler.GetSystemStats)
|
||||
api.GET("/client/:id/screenshot", clientHandler.GetScreenshot)
|
||||
api.POST("/client/:id/shell", clientHandler.ExecuteShell)
|
||||
|
||||
// 配置管理
|
||||
configHandler := handler.NewConfigHandler(app)
|
||||
@@ -77,29 +82,6 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
|
||||
api.PUT("/config", configHandler.Update)
|
||||
api.POST("/config/reload", configHandler.Reload)
|
||||
|
||||
// 插件管理
|
||||
pluginHandler := handler.NewPluginHandler(app)
|
||||
api.GET("/plugins", pluginHandler.List)
|
||||
api.POST("/plugin/:name/enable", pluginHandler.Enable)
|
||||
api.POST("/plugin/:name/disable", pluginHandler.Disable)
|
||||
api.GET("/rule-schemas", pluginHandler.GetRuleSchemas)
|
||||
api.GET("/client-plugin/:clientID/:pluginName/config", pluginHandler.GetClientConfig)
|
||||
api.PUT("/client-plugin/:clientID/:pluginName/config", pluginHandler.UpdateClientConfig)
|
||||
|
||||
// JS 插件管理
|
||||
jsPluginHandler := handler.NewJSPluginHandler(app)
|
||||
api.GET("/js-plugins", jsPluginHandler.List)
|
||||
api.POST("/js-plugins", jsPluginHandler.Create)
|
||||
api.GET("/js-plugin/:name", jsPluginHandler.Get)
|
||||
api.PUT("/js-plugin/:name", jsPluginHandler.Update)
|
||||
api.DELETE("/js-plugin/:name", jsPluginHandler.Delete)
|
||||
api.POST("/js-plugin/:name/push/:clientID", jsPluginHandler.PushToClient)
|
||||
|
||||
// 插件商店
|
||||
storeHandler := handler.NewStoreHandler(app)
|
||||
api.GET("/store/plugins", storeHandler.ListPlugins)
|
||||
api.POST("/store/install", storeHandler.Install)
|
||||
|
||||
// 更新管理
|
||||
updateHandler := handler.NewUpdateHandler(app)
|
||||
api.GET("/update/check/server", updateHandler.CheckServer)
|
||||
@@ -116,9 +98,8 @@ func (r *GinRouter) SetupRoutes(app handler.AppInterface, jwtAuth *auth.JWTAuth,
|
||||
api.GET("/traffic/stats", trafficHandler.GetStats)
|
||||
api.GET("/traffic/hourly", trafficHandler.GetHourly)
|
||||
|
||||
// 插件 API 代理 (通过 Web API 访问客户端插件)
|
||||
pluginAPIHandler := handler.NewPluginAPIHandler(app)
|
||||
api.Any("/client/:id/plugin-api/:pluginID/*route", pluginAPIHandler.ProxyRequest)
|
||||
// 安装命令生成
|
||||
api.POST("/install/generate", installHandler.GenerateInstallCommand)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +181,4 @@ func isStaticAsset(path string) bool {
|
||||
type (
|
||||
ServerInterface = handler.ServerInterface
|
||||
AppInterface = handler.AppInterface
|
||||
ConfigField = handler.ConfigField
|
||||
RuleSchema = handler.RuleSchema
|
||||
PluginInfo = handler.PluginInfo
|
||||
JSPluginInstallRequest = handler.JSPluginInstallRequest
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,5 +142,5 @@ func (s *Server) handleWebsocketProxyConn(cs *ClientSession, conn net.Conn, rule
|
||||
return
|
||||
}
|
||||
|
||||
relay.Relay(conn, stream)
|
||||
relay.RelayWithStats(conn, stream, s.recordTraffic)
|
||||
}
|
||||
|
||||
144
mobile/gotunnelmobile/gotunnelmobile.go
Normal file
144
mobile/gotunnelmobile/gotunnelmobile.go
Normal 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
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType 审计事件类型
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventPluginInstall EventType = "plugin_install"
|
||||
EventPluginUninstall EventType = "plugin_uninstall"
|
||||
EventPluginStart EventType = "plugin_start"
|
||||
EventPluginStop EventType = "plugin_stop"
|
||||
EventPluginVerify EventType = "plugin_verify"
|
||||
EventPluginReject EventType = "plugin_reject"
|
||||
EventConfigChange EventType = "config_change"
|
||||
)
|
||||
|
||||
// Event 审计事件
|
||||
type Event struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type EventType `json:"type"`
|
||||
PluginName string `json:"plugin_name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Logger 审计日志记录器
|
||||
type Logger struct {
|
||||
path string
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
}
|
||||
|
||||
var (
|
||||
defaultLogger *Logger
|
||||
loggerOnce sync.Once
|
||||
)
|
||||
|
||||
// NewLogger 创建审计日志记录器
|
||||
func NewLogger(dataDir string) (*Logger, error) {
|
||||
path := filepath.Join(dataDir, "audit.log")
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Logger{path: path, file: file, enabled: true}, nil
|
||||
}
|
||||
|
||||
// InitDefault 初始化默认日志记录器
|
||||
func InitDefault(dataDir string) error {
|
||||
var err error
|
||||
loggerOnce.Do(func() {
|
||||
defaultLogger, err = NewLogger(dataDir)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Log 记录审计事件
|
||||
func (l *Logger) Log(event Event) {
|
||||
if l == nil || !l.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
event.Timestamp = time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
log.Printf("[Audit] Marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := l.file.Write(append(data, '\n')); err != nil {
|
||||
log.Printf("[Audit] Write error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭日志文件
|
||||
func (l *Logger) Close() error {
|
||||
if l == nil || l.file == nil {
|
||||
return nil
|
||||
}
|
||||
return l.file.Close()
|
||||
}
|
||||
|
||||
// LogEvent 使用默认记录器记录事件
|
||||
func LogEvent(event Event) {
|
||||
if defaultLogger != nil {
|
||||
defaultLogger.Log(event)
|
||||
}
|
||||
}
|
||||
|
||||
// LogPluginInstall 记录插件安装事件
|
||||
func LogPluginInstall(pluginName, version, clientID string, success bool, msg string) {
|
||||
LogEvent(Event{
|
||||
Type: EventPluginInstall,
|
||||
PluginName: pluginName,
|
||||
Version: version,
|
||||
ClientID: clientID,
|
||||
Success: success,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// LogPluginVerify 记录插件验证事件
|
||||
func LogPluginVerify(pluginName, version string, success bool, msg string) {
|
||||
LogEvent(Event{
|
||||
Type: EventPluginVerify,
|
||||
PluginName: pluginName,
|
||||
Version: version,
|
||||
Success: success,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// LogPluginReject 记录插件拒绝事件
|
||||
func LogPluginReject(pluginName, version, reason string) {
|
||||
LogEvent(Event{
|
||||
Type: EventPluginReject,
|
||||
PluginName: pluginName,
|
||||
Version: version,
|
||||
Success: false,
|
||||
Message: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// LogWithDetails 记录带详情的事件
|
||||
func LogWithDetails(eventType EventType, pluginName string, success bool, msg string, details map[string]string) {
|
||||
LogEvent(Event{
|
||||
Type: eventType,
|
||||
PluginName: pluginName,
|
||||
Success: success,
|
||||
Message: msg,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry 管理可用的 plugins (仅客户端插件)
|
||||
type Registry struct {
|
||||
clientPlugins map[string]ClientPlugin // 客户端插件
|
||||
enabled map[string]bool // 启用状态
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRegistry 创建 plugin 注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
clientPlugins: make(map[string]ClientPlugin),
|
||||
enabled: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClient 注册客户端插件
|
||||
func (r *Registry) RegisterClient(handler ClientPlugin) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
meta := handler.Metadata()
|
||||
if meta.Name == "" {
|
||||
return fmt.Errorf("plugin name cannot be empty")
|
||||
}
|
||||
|
||||
if _, exists := r.clientPlugins[meta.Name]; exists {
|
||||
return fmt.Errorf("client plugin %s already registered", meta.Name)
|
||||
}
|
||||
|
||||
r.clientPlugins[meta.Name] = handler
|
||||
r.enabled[meta.Name] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClient 返回客户端插件
|
||||
func (r *Registry) GetClient(name string) (ClientPlugin, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if handler, ok := r.clientPlugins[name]; ok {
|
||||
if !r.enabled[name] {
|
||||
return nil, fmt.Errorf("client plugin %s is disabled", name)
|
||||
}
|
||||
return handler, nil
|
||||
}
|
||||
return nil, fmt.Errorf("client plugin %s not found", name)
|
||||
}
|
||||
|
||||
// List 返回所有可用的 plugins
|
||||
func (r *Registry) List() []Info {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var plugins []Info
|
||||
|
||||
for name, handler := range r.clientPlugins {
|
||||
plugins = append(plugins, Info{
|
||||
Metadata: handler.Metadata(),
|
||||
Loaded: true,
|
||||
Enabled: r.enabled[name],
|
||||
})
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
// Has 检查 plugin 是否存在
|
||||
func (r *Registry) Has(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
_, ok := r.clientPlugins[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Close 关闭所有 plugins
|
||||
func (r *Registry) Close(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
var lastErr error
|
||||
for name, handler := range r.clientPlugins {
|
||||
if err := handler.Stop(); err != nil {
|
||||
lastErr = fmt.Errorf("failed to stop client plugin %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Enable 启用插件
|
||||
func (r *Registry) Enable(name string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if !r.has(name) {
|
||||
return fmt.Errorf("plugin %s not found", name)
|
||||
}
|
||||
r.enabled[name] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable 禁用插件
|
||||
func (r *Registry) Disable(name string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if !r.has(name) {
|
||||
return fmt.Errorf("plugin %s not found", name)
|
||||
}
|
||||
r.enabled[name] = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// has 内部检查(无锁)
|
||||
func (r *Registry) has(name string) bool {
|
||||
_, ok := r.clientPlugins[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsEnabled 检查插件是否启用
|
||||
func (r *Registry) IsEnabled(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.enabled[name]
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package plugin
|
||||
|
||||
// 内置协议类型配置模式
|
||||
|
||||
// BuiltinRuleSchemas 返回所有内置协议类型的配置模式
|
||||
func BuiltinRuleSchemas() map[string]RuleSchema {
|
||||
return map[string]RuleSchema{
|
||||
"tcp": {
|
||||
NeedsLocalAddr: true,
|
||||
ExtraFields: nil,
|
||||
},
|
||||
"udp": {
|
||||
NeedsLocalAddr: true,
|
||||
ExtraFields: nil,
|
||||
},
|
||||
"http": {
|
||||
NeedsLocalAddr: false,
|
||||
ExtraFields: []ConfigField{
|
||||
{
|
||||
Key: "auth_enabled",
|
||||
Label: "启用认证",
|
||||
Type: ConfigFieldBool,
|
||||
Default: "false",
|
||||
Description: "是否启用 HTTP Basic 认证",
|
||||
},
|
||||
{
|
||||
Key: "username",
|
||||
Label: "用户名",
|
||||
Type: ConfigFieldString,
|
||||
Description: "HTTP 代理认证用户名",
|
||||
},
|
||||
{
|
||||
Key: "password",
|
||||
Label: "密码",
|
||||
Type: ConfigFieldPassword,
|
||||
Description: "HTTP 代理认证密码",
|
||||
},
|
||||
},
|
||||
},
|
||||
"https": {
|
||||
NeedsLocalAddr: false,
|
||||
ExtraFields: []ConfigField{
|
||||
{
|
||||
Key: "auth_enabled",
|
||||
Label: "启用认证",
|
||||
Type: ConfigFieldBool,
|
||||
Default: "false",
|
||||
Description: "是否启用 HTTPS 代理认证",
|
||||
},
|
||||
{
|
||||
Key: "username",
|
||||
Label: "用户名",
|
||||
Type: ConfigFieldString,
|
||||
Description: "HTTPS 代理认证用户名",
|
||||
},
|
||||
{
|
||||
Key: "password",
|
||||
Label: "密码",
|
||||
Type: ConfigFieldPassword,
|
||||
Description: "HTTPS 代理认证密码",
|
||||
},
|
||||
},
|
||||
},
|
||||
"socks5": {
|
||||
NeedsLocalAddr: false,
|
||||
ExtraFields: []ConfigField{
|
||||
{
|
||||
Key: "auth_enabled",
|
||||
Label: "启用认证",
|
||||
Type: ConfigFieldBool,
|
||||
Default: "false",
|
||||
Description: "是否启用 SOCKS5 用户名/密码认证",
|
||||
},
|
||||
{
|
||||
Key: "username",
|
||||
Label: "用户名",
|
||||
Type: ConfigFieldString,
|
||||
Description: "SOCKS5 认证用户名",
|
||||
},
|
||||
{
|
||||
Key: "password",
|
||||
Label: "密码",
|
||||
Type: ConfigFieldPassword,
|
||||
Description: "SOCKS5 认证密码",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRuleSchema 获取指定协议类型的配置模式
|
||||
func GetRuleSchema(proxyType string) *RuleSchema {
|
||||
schemas := BuiltinRuleSchemas()
|
||||
if schema, ok := schemas[proxyType]; ok {
|
||||
return &schema
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBuiltinType 检查是否为内置协议类型
|
||||
func IsBuiltinType(proxyType string) bool {
|
||||
builtinTypes := []string{"tcp", "udp", "http", "https"}
|
||||
for _, t := range builtinTypes {
|
||||
if t == proxyType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,913 +0,0 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/gotunnel/pkg/plugin"
|
||||
)
|
||||
|
||||
// JSPlugin JavaScript 脚本插件
|
||||
type JSPlugin struct {
|
||||
name string
|
||||
source string
|
||||
vm *goja.Runtime
|
||||
metadata plugin.Metadata
|
||||
config map[string]string
|
||||
sandbox *Sandbox
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
eventListeners map[string][]func(goja.Value)
|
||||
storagePath string
|
||||
apiHandlers map[string]map[string]goja.Callable // method -> path -> handler
|
||||
}
|
||||
|
||||
// NewJSPlugin 从 JS 源码创建插件
|
||||
func NewJSPlugin(name, source string) (*JSPlugin, error) {
|
||||
p := &JSPlugin{
|
||||
name: name,
|
||||
source: source,
|
||||
vm: goja.New(),
|
||||
sandbox: DefaultSandbox(),
|
||||
eventListeners: make(map[string][]func(goja.Value)),
|
||||
storagePath: filepath.Join("plugin_data", name+".json"),
|
||||
apiHandlers: make(map[string]map[string]goja.Callable),
|
||||
}
|
||||
|
||||
// 确保存储目录存在
|
||||
os.MkdirAll("plugin_data", 0755)
|
||||
|
||||
if err := p.init(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SetSandbox 设置沙箱配置
|
||||
func (p *JSPlugin) SetSandbox(sandbox *Sandbox) {
|
||||
p.sandbox = sandbox
|
||||
}
|
||||
|
||||
// init 初始化 JS 运行时
|
||||
func (p *JSPlugin) init() error {
|
||||
// 设置栈深度限制(防止递归攻击)
|
||||
if p.sandbox.MaxStackDepth > 0 {
|
||||
p.vm.SetMaxCallStackSize(p.sandbox.MaxStackDepth)
|
||||
}
|
||||
|
||||
// 注入基础 API
|
||||
p.vm.Set("log", p.jsLog)
|
||||
|
||||
// Config API (兼容旧的 config() 调用,同时支持 config.get/getAll)
|
||||
p.vm.Set("config", p.jsGetConfig)
|
||||
if configObj := p.vm.Get("config"); configObj != nil {
|
||||
obj := configObj.ToObject(p.vm)
|
||||
obj.Set("get", p.jsGetConfig)
|
||||
obj.Set("getAll", p.jsGetAllConfig)
|
||||
}
|
||||
|
||||
// 注入增强 API
|
||||
p.vm.Set("logger", p.createLoggerAPI())
|
||||
p.vm.Set("storage", p.createStorageAPI())
|
||||
p.vm.Set("event", p.createEventAPI())
|
||||
p.vm.Set("request", p.createRequestAPI())
|
||||
p.vm.Set("notify", p.createNotifyAPI())
|
||||
|
||||
// 注入文件 API
|
||||
p.vm.Set("fs", p.createFsAPI())
|
||||
|
||||
// 注入 HTTP API
|
||||
p.vm.Set("http", p.createHttpAPI())
|
||||
|
||||
// 注入路由 API
|
||||
p.vm.Set("api", p.createRouteAPI())
|
||||
|
||||
// 执行脚本
|
||||
_, err := p.vm.RunString(p.source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run script: %w", err)
|
||||
}
|
||||
|
||||
// 获取元数据
|
||||
if err := p.loadMetadata(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadMetadata 从 JS 获取元数据
|
||||
func (p *JSPlugin) loadMetadata() error {
|
||||
fn, ok := goja.AssertFunction(p.vm.Get("metadata"))
|
||||
if !ok {
|
||||
// 使用默认元数据
|
||||
p.metadata = plugin.Metadata{
|
||||
Name: p.name,
|
||||
Type: plugin.PluginTypeApp,
|
||||
Source: plugin.PluginSourceScript,
|
||||
RunAt: plugin.SideClient,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fn(goja.Undefined())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obj := result.ToObject(p.vm)
|
||||
p.metadata = plugin.Metadata{
|
||||
Name: getString(obj, "name", p.name),
|
||||
Version: getString(obj, "version", "1.0.0"),
|
||||
Type: plugin.PluginType(getString(obj, "type", "app")),
|
||||
Source: plugin.PluginSourceScript,
|
||||
RunAt: plugin.Side(getString(obj, "run_at", "client")),
|
||||
Description: getString(obj, "description", ""),
|
||||
Author: getString(obj, "author", ""),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Metadata 返回插件元数据
|
||||
func (p *JSPlugin) Metadata() plugin.Metadata {
|
||||
return p.metadata
|
||||
}
|
||||
|
||||
// Init 初始化插件配置
|
||||
func (p *JSPlugin) Init(config map[string]string) error {
|
||||
p.config = config
|
||||
|
||||
// 根据 root_path 配置设置沙箱允许的路径
|
||||
if rootPath := config["root_path"]; rootPath != "" {
|
||||
absPath, err := filepath.Abs(rootPath)
|
||||
if err == nil {
|
||||
p.sandbox.AllowedPaths = append(p.sandbox.AllowedPaths, absPath)
|
||||
p.sandbox.WritablePaths = append(p.sandbox.WritablePaths, absPath)
|
||||
}
|
||||
} else {
|
||||
// 如果没有配置 root_path,默认允许访问当前目录
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
p.sandbox.AllowedPaths = append(p.sandbox.AllowedPaths, cwd)
|
||||
p.sandbox.WritablePaths = append(p.sandbox.WritablePaths, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 启动插件
|
||||
func (p *JSPlugin) Start() (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.running {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
fn, ok := goja.AssertFunction(p.vm.Get("start"))
|
||||
if ok {
|
||||
_, err := fn(goja.Undefined())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
p.running = true
|
||||
return "script-plugin", nil
|
||||
}
|
||||
|
||||
// HandleConn 处理连接
|
||||
func (p *JSPlugin) HandleConn(conn net.Conn) error {
|
||||
defer conn.Close()
|
||||
|
||||
// goja Runtime 不是线程安全的,需要加锁
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 创建连接包装器
|
||||
jsConn := newJSConn(conn)
|
||||
|
||||
fn, ok := goja.AssertFunction(p.vm.Get("handleConn"))
|
||||
if !ok {
|
||||
return fmt.Errorf("handleConn not defined")
|
||||
}
|
||||
|
||||
_, err := fn(goja.Undefined(), p.vm.ToValue(jsConn))
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop 停止插件
|
||||
func (p *JSPlugin) Stop() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if !p.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
fn, ok := goja.AssertFunction(p.vm.Get("stop"))
|
||||
if ok {
|
||||
fn(goja.Undefined())
|
||||
}
|
||||
|
||||
p.running = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsLog JS 日志函数
|
||||
func (p *JSPlugin) jsLog(msg string) {
|
||||
fmt.Printf("[JS:%s] %s\n", p.name, msg)
|
||||
}
|
||||
|
||||
// jsGetConfig 获取配置
|
||||
func (p *JSPlugin) jsGetConfig(key string) string {
|
||||
if p.config == nil {
|
||||
return ""
|
||||
}
|
||||
return p.config[key]
|
||||
}
|
||||
|
||||
// getString 从 JS 对象获取字符串
|
||||
func getString(obj *goja.Object, key, def string) string {
|
||||
v := obj.Get(key)
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return def
|
||||
}
|
||||
return v.String()
|
||||
}
|
||||
|
||||
// jsConn JS 连接包装器
|
||||
type jsConn struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func newJSConn(conn net.Conn) *jsConn {
|
||||
return &jsConn{conn: conn}
|
||||
}
|
||||
|
||||
func (c *jsConn) Read(size int) []byte {
|
||||
buf := make([]byte, size)
|
||||
n, err := c.conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return buf[:n]
|
||||
}
|
||||
|
||||
func (c *jsConn) Write(data []byte) int {
|
||||
n, _ := c.conn.Write(data)
|
||||
return n
|
||||
}
|
||||
|
||||
func (c *jsConn) Close() {
|
||||
c.conn.Close()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 文件系统 API
|
||||
// =============================================================================
|
||||
|
||||
// createFsAPI 创建文件系统 API
|
||||
func (p *JSPlugin) createFsAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"readFile": p.fsReadFile,
|
||||
"writeFile": p.fsWriteFile,
|
||||
"readDir": p.fsReadDir,
|
||||
"stat": p.fsStat,
|
||||
"exists": p.fsExists,
|
||||
"mkdir": p.fsMkdir,
|
||||
"remove": p.fsRemove,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsReadFile(path string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "data": ""}
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "data": ""}
|
||||
}
|
||||
if info.Size() > p.sandbox.MaxReadSize {
|
||||
return map[string]interface{}{"error": "file too large", "data": ""}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "data": ""}
|
||||
}
|
||||
return map[string]interface{}{"error": "", "data": string(data)}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsWriteFile(path, content string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateWritePath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||
}
|
||||
|
||||
if int64(len(content)) > p.sandbox.MaxWriteSize {
|
||||
return map[string]interface{}{"error": "content too large", "ok": false}
|
||||
}
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||
}
|
||||
return map[string]interface{}{"error": "", "ok": true}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsReadDir(path string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "entries": nil}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "entries": nil}
|
||||
}
|
||||
var result []map[string]interface{}
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
result = append(result, map[string]interface{}{
|
||||
"name": e.Name(),
|
||||
"isDir": e.IsDir(),
|
||||
"size": info.Size(),
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{"error": "", "entries": result}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsStat(path string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"error": "",
|
||||
"name": info.Name(),
|
||||
"size": info.Size(),
|
||||
"isDir": info.IsDir(),
|
||||
"modTime": info.ModTime().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsExists(path string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateReadPath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "exists": false}
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
return map[string]interface{}{"error": "", "exists": err == nil}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsMkdir(path string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateWritePath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||
}
|
||||
err := os.MkdirAll(path, 0755)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||
}
|
||||
return map[string]interface{}{"error": "", "ok": true}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) fsRemove(path string) map[string]interface{} {
|
||||
if err := p.sandbox.ValidateWritePath(path); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||
}
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "ok": false}
|
||||
}
|
||||
return map[string]interface{}{"error": "", "ok": true}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP 服务 API
|
||||
// =============================================================================
|
||||
|
||||
// createHttpAPI 创建 HTTP API
|
||||
func (p *JSPlugin) createHttpAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"serve": p.httpServe,
|
||||
"json": p.httpJSON,
|
||||
"sendFile": p.httpSendFile,
|
||||
}
|
||||
}
|
||||
|
||||
// httpServe 启动 HTTP 服务处理连接
|
||||
func (p *JSPlugin) httpServe(connObj interface{}, handler goja.Callable) {
|
||||
// 从 jsConn 包装器中提取原始 net.Conn
|
||||
var conn net.Conn
|
||||
if jc, ok := connObj.(*jsConn); ok {
|
||||
conn = jc.conn
|
||||
} else if nc, ok := connObj.(net.Conn); ok {
|
||||
conn = nc
|
||||
} else {
|
||||
fmt.Printf("[JS:%s] httpServe: invalid conn type: %T\n", p.name, connObj)
|
||||
return
|
||||
}
|
||||
|
||||
// 注意:不要在这里关闭连接,HandleConn 会负责关闭
|
||||
|
||||
// Use bufio to read the request properly
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
for {
|
||||
// 1. Read Request Line
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
fmt.Printf("[JS:%s] httpServe read error: %v\n", p.name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, " ")
|
||||
if len(parts) < 2 {
|
||||
fmt.Printf("[JS:%s] Invalid request line: %s\n", p.name, line)
|
||||
return
|
||||
}
|
||||
method := parts[0]
|
||||
path := parts[1]
|
||||
|
||||
fmt.Printf("[JS:%s] Request: %s %s\n", p.name, method, path)
|
||||
|
||||
// 2. Read Headers
|
||||
headers := make(map[string]string)
|
||||
contentLength := 0
|
||||
for {
|
||||
hLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
hLine = strings.TrimSpace(hLine)
|
||||
if hLine == "" {
|
||||
break
|
||||
}
|
||||
if idx := strings.Index(hLine, ":"); idx > 0 {
|
||||
key := strings.TrimSpace(hLine[:idx])
|
||||
val := strings.TrimSpace(hLine[idx+1:])
|
||||
headers[strings.ToLower(key)] = val
|
||||
if strings.ToLower(key) == "content-length" {
|
||||
contentLength, _ = strconv.Atoi(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Read Body
|
||||
body := ""
|
||||
if contentLength > 0 {
|
||||
bodyBuf := make([]byte, contentLength)
|
||||
if _, err := io.ReadFull(reader, bodyBuf); err == nil {
|
||||
body = string(bodyBuf)
|
||||
}
|
||||
}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
// 调用 JS handler 函数
|
||||
result, err := handler(goja.Undefined(), p.vm.ToValue(req))
|
||||
if err != nil {
|
||||
fmt.Printf("[JS:%s] HTTP handler error: %v\n", p.name, err)
|
||||
conn.Write([]byte("HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"))
|
||||
return
|
||||
}
|
||||
|
||||
// 将结果转换为 map
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"))
|
||||
continue
|
||||
}
|
||||
|
||||
resp := make(map[string]interface{})
|
||||
respObj := result.ToObject(p.vm)
|
||||
for _, key := range respObj.Keys() {
|
||||
val := respObj.Get(key)
|
||||
resp[key] = val.Export()
|
||||
}
|
||||
|
||||
writeHTTPResponse(conn, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) httpJSON(data interface{}) string {
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (p *JSPlugin) httpSendFile(connObj interface{}, filePath string) {
|
||||
// 从 jsConn 包装器中提取原始 net.Conn
|
||||
var conn net.Conn
|
||||
if jc, ok := connObj.(*jsConn); ok {
|
||||
conn = jc.conn
|
||||
} else if nc, ok := connObj.(net.Conn); ok {
|
||||
conn = nc
|
||||
} else {
|
||||
fmt.Printf("[JS:%s] httpSendFile: invalid conn type: %T\n", p.name, connObj)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
conn.Write([]byte("HTTP/1.1 404 Not Found\r\n\r\n"))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, _ := f.Stat()
|
||||
contentType := getContentType(filePath)
|
||||
|
||||
header := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
|
||||
contentType, info.Size())
|
||||
conn.Write([]byte(header))
|
||||
io.Copy(conn, f)
|
||||
}
|
||||
|
||||
// parseHTTPRequest is deprecated, logic moved to httpServe
|
||||
func parseHTTPRequest(data []byte) map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeHTTPResponse 写入 HTTP 响应
|
||||
func writeHTTPResponse(conn net.Conn, resp map[string]interface{}) {
|
||||
status := 200
|
||||
if s, ok := resp["status"].(int); ok {
|
||||
status = s
|
||||
}
|
||||
|
||||
body := ""
|
||||
if b, ok := resp["body"].(string); ok {
|
||||
body = b
|
||||
}
|
||||
|
||||
contentType := "application/json"
|
||||
if ct, ok := resp["contentType"].(string); ok {
|
||||
contentType = ct
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("HTTP/1.1 %d OK\r\nContent-Type: %s\r\nContent-Length: %d\r\n\r\n",
|
||||
status, contentType, len(body))
|
||||
conn.Write([]byte(header + body))
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func getContentType(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
types := map[string]string{
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
if ct, ok := types[ext]; ok {
|
||||
return ct
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Logger API
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) createLoggerAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"info": func(msg string) { fmt.Printf("[JS:%s][INFO] %s\n", p.name, msg) },
|
||||
"warn": func(msg string) { fmt.Printf("[JS:%s][WARN] %s\n", p.name, msg) },
|
||||
"error": func(msg string) { fmt.Printf("[JS:%s][ERROR] %s\n", p.name, msg) },
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config API Enhancements
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) jsGetAllConfig() map[string]string {
|
||||
if p.config == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return p.config
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Storage API
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) createStorageAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"get": p.storageGet,
|
||||
"set": p.storageSet,
|
||||
"delete": p.storageDelete,
|
||||
"keys": p.storageKeys,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) loadStorage() map[string]interface{} {
|
||||
data := make(map[string]interface{})
|
||||
if _, err := os.Stat(p.storagePath); err == nil {
|
||||
content, _ := os.ReadFile(p.storagePath)
|
||||
json.Unmarshal(content, &data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (p *JSPlugin) saveStorage(data map[string]interface{}) {
|
||||
content, _ := json.MarshalIndent(data, "", " ")
|
||||
os.WriteFile(p.storagePath, content, 0644)
|
||||
}
|
||||
|
||||
func (p *JSPlugin) storageGet(key string, def interface{}) interface{} {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data := p.loadStorage()
|
||||
if v, ok := data[key]; ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (p *JSPlugin) storageSet(key string, value interface{}) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data := p.loadStorage()
|
||||
data[key] = value
|
||||
p.saveStorage(data)
|
||||
}
|
||||
|
||||
func (p *JSPlugin) storageDelete(key string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data := p.loadStorage()
|
||||
delete(data, key)
|
||||
p.saveStorage(data)
|
||||
}
|
||||
|
||||
func (p *JSPlugin) storageKeys() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data := p.loadStorage()
|
||||
keys := make([]string, 0, len(data))
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Event API
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) createEventAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"on": p.eventOn,
|
||||
"emit": p.eventEmit,
|
||||
"off": p.eventOff,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) eventOn(event string, callback func(goja.Value)) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.eventListeners[event] = append(p.eventListeners[event], callback)
|
||||
}
|
||||
|
||||
func (p *JSPlugin) eventEmit(event string, data interface{}) {
|
||||
p.mu.Lock()
|
||||
listeners := p.eventListeners[event]
|
||||
p.mu.Unlock() // 释放锁以允许回调中操作
|
||||
|
||||
val := p.vm.ToValue(data)
|
||||
for _, cb := range listeners {
|
||||
cb(val)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) eventOff(event string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.eventListeners, event)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Request API (HTTP Client)
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) createRequestAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"get": p.requestGet,
|
||||
"post": p.requestPost,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) requestGet(url string) map[string]interface{} {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "status": 0}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return map[string]interface{}{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(body),
|
||||
"error": "",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSPlugin) requestPost(url string, contentType, data string) map[string]interface{} {
|
||||
resp, err := http.Post(url, contentType, strings.NewReader(data))
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "status": 0}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return map[string]interface{}{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(body),
|
||||
"error": "",
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notify API
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) createNotifyAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"send": func(title, msg string) {
|
||||
// 目前仅打印到日志,后续对接系统通知
|
||||
fmt.Printf("[NOTIFY][%s] %s: %s\n", p.name, title, msg)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Route API (用于 Web API 代理)
|
||||
// =============================================================================
|
||||
|
||||
func (p *JSPlugin) createRouteAPI() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"handle": p.apiHandle,
|
||||
"get": func(path string, handler goja.Callable) { p.apiRegister("GET", path, handler) },
|
||||
"post": func(path string, handler goja.Callable) { p.apiRegister("POST", path, handler) },
|
||||
"put": func(path string, handler goja.Callable) { p.apiRegister("PUT", path, handler) },
|
||||
"delete": func(path string, handler goja.Callable) { p.apiRegister("DELETE", path, handler) },
|
||||
}
|
||||
}
|
||||
|
||||
// apiHandle 注册 API 路由处理函数
|
||||
func (p *JSPlugin) apiHandle(method, path string, handler goja.Callable) {
|
||||
p.apiRegister(method, path, handler)
|
||||
}
|
||||
|
||||
// apiRegister 注册 API 路由
|
||||
func (p *JSPlugin) apiRegister(method, path string, handler goja.Callable) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.apiHandlers[method] == nil {
|
||||
p.apiHandlers[method] = make(map[string]goja.Callable)
|
||||
}
|
||||
p.apiHandlers[method][path] = handler
|
||||
fmt.Printf("[JS:%s] Registered API: %s %s\n", p.name, method, path)
|
||||
}
|
||||
|
||||
// HandleAPIRequest 处理 API 请求
|
||||
func (p *JSPlugin) HandleAPIRequest(method, path, query string, headers map[string]string, body string) (int, map[string]string, string, error) {
|
||||
p.mu.Lock()
|
||||
handlers := p.apiHandlers[method]
|
||||
p.mu.Unlock()
|
||||
|
||||
if handlers == nil {
|
||||
return 404, nil, `{"error":"method not allowed"}`, nil
|
||||
}
|
||||
|
||||
// 查找匹配的路由
|
||||
var handler goja.Callable
|
||||
var matchedPath string
|
||||
|
||||
for registeredPath, h := range handlers {
|
||||
if matchRoute(registeredPath, path) {
|
||||
handler = h
|
||||
matchedPath = registeredPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
return 404, nil, `{"error":"route not found"}`, nil
|
||||
}
|
||||
|
||||
// 构建请求对象
|
||||
reqObj := map[string]interface{}{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"pattern": matchedPath,
|
||||
"query": query,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
"params": extractParams(matchedPath, path),
|
||||
}
|
||||
|
||||
// 调用处理函数
|
||||
result, err := handler(goja.Undefined(), p.vm.ToValue(reqObj))
|
||||
if err != nil {
|
||||
return 500, nil, fmt.Sprintf(`{"error":"%s"}`, err.Error()), nil
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return 200, nil, "", nil
|
||||
}
|
||||
|
||||
respObj := result.ToObject(p.vm)
|
||||
status := 200
|
||||
if s := respObj.Get("status"); s != nil && !goja.IsUndefined(s) {
|
||||
status = int(s.ToInteger())
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]string)
|
||||
if h := respObj.Get("headers"); h != nil && !goja.IsUndefined(h) {
|
||||
hObj := h.ToObject(p.vm)
|
||||
for _, key := range hObj.Keys() {
|
||||
respHeaders[key] = hObj.Get(key).String()
|
||||
}
|
||||
}
|
||||
|
||||
respBody := ""
|
||||
if b := respObj.Get("body"); b != nil && !goja.IsUndefined(b) {
|
||||
respBody = b.String()
|
||||
}
|
||||
|
||||
return status, respHeaders, respBody, nil
|
||||
}
|
||||
|
||||
// matchRoute 匹配路由 (支持简单的路径参数)
|
||||
func matchRoute(pattern, path string) bool {
|
||||
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
if len(patternParts) != len(pathParts) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i, part := range patternParts {
|
||||
if strings.HasPrefix(part, ":") {
|
||||
continue // 路径参数,匹配任意值
|
||||
}
|
||||
if part != pathParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extractParams 提取路径参数
|
||||
func extractParams(pattern, path string) map[string]string {
|
||||
params := make(map[string]string)
|
||||
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
for i, part := range patternParts {
|
||||
if strings.HasPrefix(part, ":") && i < len(pathParts) {
|
||||
paramName := strings.TrimPrefix(part, ":")
|
||||
params[paramName] = pathParts[i]
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sandbox 插件沙箱配置
|
||||
type Sandbox struct {
|
||||
// 允许访问的路径列表(绝对路径)
|
||||
AllowedPaths []string
|
||||
// 允许写入的路径列表(必须是 AllowedPaths 的子集)
|
||||
WritablePaths []string
|
||||
// 禁止访问的路径(黑名单,优先级高于白名单)
|
||||
DeniedPaths []string
|
||||
// 是否允许网络访问
|
||||
AllowNetwork bool
|
||||
// 最大文件读取大小 (bytes)
|
||||
MaxReadSize int64
|
||||
// 最大文件写入大小 (bytes)
|
||||
MaxWriteSize int64
|
||||
// 最大内存使用量 (bytes),0 表示不限制
|
||||
MaxMemory int64
|
||||
// 最大调用栈深度
|
||||
MaxStackDepth int
|
||||
}
|
||||
|
||||
// DefaultSandbox 返回默认沙箱配置(最小权限)
|
||||
func DefaultSandbox() *Sandbox {
|
||||
return &Sandbox{
|
||||
AllowedPaths: []string{},
|
||||
WritablePaths: []string{},
|
||||
DeniedPaths: defaultDeniedPaths(),
|
||||
AllowNetwork: false,
|
||||
MaxReadSize: 10 * 1024 * 1024, // 10MB
|
||||
MaxWriteSize: 1 * 1024 * 1024, // 1MB
|
||||
MaxMemory: 64 * 1024 * 1024, // 64MB
|
||||
MaxStackDepth: 1000, // 最大调用栈深度
|
||||
}
|
||||
}
|
||||
|
||||
// defaultDeniedPaths 返回默认禁止访问的路径
|
||||
func defaultDeniedPaths() []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
denied := []string{
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"/etc/sudoers",
|
||||
"/root",
|
||||
"/.ssh",
|
||||
"/.gnupg",
|
||||
"/.aws",
|
||||
"/.kube",
|
||||
"/proc",
|
||||
"/sys",
|
||||
}
|
||||
if home != "" {
|
||||
denied = append(denied,
|
||||
filepath.Join(home, ".ssh"),
|
||||
filepath.Join(home, ".gnupg"),
|
||||
filepath.Join(home, ".aws"),
|
||||
filepath.Join(home, ".kube"),
|
||||
filepath.Join(home, ".config"),
|
||||
filepath.Join(home, ".local"),
|
||||
)
|
||||
}
|
||||
return denied
|
||||
}
|
||||
|
||||
// ValidateReadPath 验证读取路径是否允许
|
||||
func (s *Sandbox) ValidateReadPath(path string) error {
|
||||
return s.validatePath(path, false)
|
||||
}
|
||||
|
||||
// ValidateWritePath 验证写入路径是否允许
|
||||
func (s *Sandbox) ValidateWritePath(path string) error {
|
||||
return s.validatePath(path, true)
|
||||
}
|
||||
|
||||
func (s *Sandbox) validatePath(path string, write bool) error {
|
||||
// 清理路径,防止路径遍历攻击
|
||||
cleanPath, err := s.cleanPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查黑名单(优先级最高)
|
||||
if s.isDenied(cleanPath) {
|
||||
return fmt.Errorf("access denied: path is in denied list")
|
||||
}
|
||||
|
||||
// 检查白名单
|
||||
allowedList := s.AllowedPaths
|
||||
if write {
|
||||
allowedList = s.WritablePaths
|
||||
}
|
||||
|
||||
if len(allowedList) == 0 {
|
||||
return fmt.Errorf("access denied: no paths allowed")
|
||||
}
|
||||
|
||||
if !s.isAllowed(cleanPath, allowedList) {
|
||||
if write {
|
||||
return fmt.Errorf("access denied: path not in writable list")
|
||||
}
|
||||
return fmt.Errorf("access denied: path not in allowed list")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanPath 清理并验证路径
|
||||
func (s *Sandbox) cleanPath(path string) (string, error) {
|
||||
// 转换为绝对路径
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// 清理路径(解析 .. 和 .)
|
||||
cleanPath := filepath.Clean(absPath)
|
||||
|
||||
// 检查符号链接(防止通过符号链接绕过限制)
|
||||
realPath, err := filepath.EvalSymlinks(cleanPath)
|
||||
if err != nil {
|
||||
// 文件可能不存在,使用清理后的路径
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
realPath = cleanPath
|
||||
}
|
||||
|
||||
// 再次检查路径遍历
|
||||
if strings.Contains(realPath, "..") {
|
||||
return "", fmt.Errorf("path traversal detected")
|
||||
}
|
||||
|
||||
return realPath, nil
|
||||
}
|
||||
|
||||
// isDenied 检查路径是否在黑名单中
|
||||
func (s *Sandbox) isDenied(path string) bool {
|
||||
for _, denied := range s.DeniedPaths {
|
||||
if strings.HasPrefix(path, denied) || path == denied {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllowed 检查路径是否在白名单中
|
||||
func (s *Sandbox) isAllowed(path string, allowedList []string) bool {
|
||||
for _, allowed := range allowedList {
|
||||
if strings.HasPrefix(path, allowed) || path == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 官方固定公钥(客户端内置)
|
||||
const OfficialPublicKeyBase64 = "0A0xRthj0wgPg8X8GJZ6/EnNpAUw5v7O//XLty+P5Yw="
|
||||
|
||||
var (
|
||||
officialPubKey ed25519.PublicKey
|
||||
officialPubKeyOnce sync.Once
|
||||
officialPubKeyErr error
|
||||
)
|
||||
|
||||
// initOfficialKey 初始化官方公钥
|
||||
func initOfficialKey() {
|
||||
officialPubKey, officialPubKeyErr = DecodePublicKey(OfficialPublicKeyBase64)
|
||||
}
|
||||
|
||||
// GetOfficialPublicKey 获取官方公钥
|
||||
func GetOfficialPublicKey() (ed25519.PublicKey, error) {
|
||||
officialPubKeyOnce.Do(initOfficialKey)
|
||||
return officialPubKey, officialPubKeyErr
|
||||
}
|
||||
|
||||
// GetPublicKeyByID 根据 ID 获取公钥(兼容旧接口,忽略 keyID)
|
||||
func GetPublicKeyByID(keyID string) (ed25519.PublicKey, error) {
|
||||
return GetOfficialPublicKey()
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PluginPayload 插件签名载荷
|
||||
type PluginPayload struct {
|
||||
Name string `json:"name"` // 插件名称
|
||||
Version string `json:"version"` // 版本号
|
||||
SourceHash string `json:"source_hash"` // 源码 SHA256
|
||||
KeyID string `json:"key_id"` // 签名密钥 ID
|
||||
Timestamp int64 `json:"timestamp"` // 签名时间戳
|
||||
}
|
||||
|
||||
// SignedPlugin 已签名的插件
|
||||
type SignedPlugin struct {
|
||||
Payload PluginPayload `json:"payload"`
|
||||
Signature string `json:"signature"` // Base64 签名
|
||||
}
|
||||
|
||||
// NormalizeSource 规范化源码(统一换行符)
|
||||
func NormalizeSource(source string) string {
|
||||
// 统一换行符为 LF
|
||||
normalized := strings.ReplaceAll(source, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
// 去除尾部空白
|
||||
normalized = strings.TrimRight(normalized, " \t\n")
|
||||
return normalized
|
||||
}
|
||||
|
||||
// HashSource 计算源码哈希
|
||||
func HashSource(source string) string {
|
||||
normalized := NormalizeSource(source)
|
||||
hash := sha256.Sum256([]byte(normalized))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// CreatePayload 创建签名载荷
|
||||
func CreatePayload(name, version, source, keyID string) *PluginPayload {
|
||||
return &PluginPayload{
|
||||
Name: name,
|
||||
Version: version,
|
||||
SourceHash: HashSource(source),
|
||||
KeyID: keyID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// SignPlugin 签名插件
|
||||
func SignPlugin(priv ed25519.PrivateKey, payload *PluginPayload) (*SignedPlugin, error) {
|
||||
// 序列化载荷
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// 签名
|
||||
sig := SignBase64(priv, data)
|
||||
|
||||
return &SignedPlugin{
|
||||
Payload: *payload,
|
||||
Signature: sig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyPlugin 验证插件签名
|
||||
func VerifyPlugin(pub ed25519.PublicKey, signed *SignedPlugin, source string) error {
|
||||
// 验证源码哈希
|
||||
expectedHash := HashSource(source)
|
||||
if signed.Payload.SourceHash != expectedHash {
|
||||
return fmt.Errorf("source hash mismatch")
|
||||
}
|
||||
|
||||
// 序列化载荷
|
||||
data, err := json.Marshal(signed.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
return VerifyBase64(pub, data, signed.Signature)
|
||||
}
|
||||
|
||||
// EncodeSignedPlugin 编码已签名插件为 JSON
|
||||
func EncodeSignedPlugin(sp *SignedPlugin) (string, error) {
|
||||
data, err := json.Marshal(sp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DecodeSignedPlugin 从 JSON 解码已签名插件
|
||||
func DecodeSignedPlugin(data string) (*SignedPlugin, error) {
|
||||
var sp SignedPlugin
|
||||
if err := json.Unmarshal([]byte(data), &sp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sp, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSignature = errors.New("invalid signature")
|
||||
ErrInvalidPublicKey = errors.New("invalid public key")
|
||||
ErrInvalidPrivateKey = errors.New("invalid private key")
|
||||
)
|
||||
|
||||
// KeyPair Ed25519 密钥对
|
||||
type KeyPair struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
PrivateKey ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateKeyPair 生成新的密钥对
|
||||
func GenerateKeyPair() (*KeyPair, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
return &KeyPair{PublicKey: pub, PrivateKey: priv}, nil
|
||||
}
|
||||
|
||||
// Sign 使用私钥签名数据
|
||||
func Sign(privateKey ed25519.PrivateKey, data []byte) []byte {
|
||||
return ed25519.Sign(privateKey, data)
|
||||
}
|
||||
|
||||
// Verify 使用公钥验证签名
|
||||
func Verify(publicKey ed25519.PublicKey, data, signature []byte) bool {
|
||||
return ed25519.Verify(publicKey, data, signature)
|
||||
}
|
||||
|
||||
// SignBase64 签名并返回 Base64 编码
|
||||
func SignBase64(privateKey ed25519.PrivateKey, data []byte) string {
|
||||
sig := Sign(privateKey, data)
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// VerifyBase64 验证 Base64 编码的签名
|
||||
func VerifyBase64(publicKey ed25519.PublicKey, data []byte, sigB64 string) error {
|
||||
sig, err := base64.StdEncoding.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
if !Verify(publicKey, data, sig) {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePublicKey 编码公钥为 Base64
|
||||
func EncodePublicKey(pub ed25519.PublicKey) string {
|
||||
return base64.StdEncoding.EncodeToString(pub)
|
||||
}
|
||||
|
||||
// DecodePublicKey 从 Base64 解码公钥
|
||||
func DecodePublicKey(s string) (ed25519.PublicKey, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) != ed25519.PublicKeySize {
|
||||
return nil, ErrInvalidPublicKey
|
||||
}
|
||||
return ed25519.PublicKey(data), nil
|
||||
}
|
||||
|
||||
// EncodePrivateKey 编码私钥为 Base64
|
||||
func EncodePrivateKey(priv ed25519.PrivateKey) string {
|
||||
return base64.StdEncoding.EncodeToString(priv)
|
||||
}
|
||||
|
||||
// DecodePrivateKey 从 Base64 解码私钥
|
||||
func DecodePrivateKey(s string) (ed25519.PrivateKey, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) != ed25519.PrivateKeySize {
|
||||
return nil, ErrInvalidPrivateKey
|
||||
}
|
||||
return ed25519.PrivateKey(data), nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CompareVersions 比较两个版本号
|
||||
// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
|
||||
func CompareVersions(v1, v2 string) int {
|
||||
parts1 := parseVersion(v1)
|
||||
parts2 := parseVersion(v2)
|
||||
|
||||
maxLen := len(parts1)
|
||||
if len(parts2) > maxLen {
|
||||
maxLen = len(parts2)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var p1, p2 int
|
||||
if i < len(parts1) {
|
||||
p1 = parts1[i]
|
||||
}
|
||||
if i < len(parts2) {
|
||||
p2 = parts2[i]
|
||||
}
|
||||
|
||||
if p1 < p2 {
|
||||
return -1
|
||||
}
|
||||
if p1 > p2 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseVersion(v string) []int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.Split(v, ".")
|
||||
result := make([]int, len(parts))
|
||||
for i, p := range parts {
|
||||
n, _ := strconv.Atoi(p)
|
||||
result[i] = n
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 基础类型
|
||||
// =============================================================================
|
||||
|
||||
// Side 运行侧
|
||||
type Side string
|
||||
|
||||
const (
|
||||
SideClient Side = "client"
|
||||
)
|
||||
|
||||
// PluginType 插件类别
|
||||
type PluginType string
|
||||
|
||||
const (
|
||||
PluginTypeProxy PluginType = "proxy" // 代理协议 (SOCKS5 等)
|
||||
PluginTypeApp PluginType = "app" // 应用服务 (VNC, Echo 等)
|
||||
)
|
||||
|
||||
// PluginSource 插件来源
|
||||
type PluginSource string
|
||||
|
||||
const (
|
||||
PluginSourceBuiltin PluginSource = "builtin" // 内置编译
|
||||
PluginSourceScript PluginSource = "script" // 脚本插件
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 配置相关
|
||||
// =============================================================================
|
||||
|
||||
// ConfigFieldType 配置字段类型
|
||||
type ConfigFieldType string
|
||||
|
||||
const (
|
||||
ConfigFieldString ConfigFieldType = "string"
|
||||
ConfigFieldNumber ConfigFieldType = "number"
|
||||
ConfigFieldBool ConfigFieldType = "bool"
|
||||
ConfigFieldSelect ConfigFieldType = "select"
|
||||
ConfigFieldPassword ConfigFieldType = "password"
|
||||
)
|
||||
|
||||
// ConfigField 配置字段定义
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type ConfigFieldType `json:"type"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// RuleSchema 规则表单模式
|
||||
type RuleSchema struct {
|
||||
NeedsLocalAddr bool `json:"needs_local_addr"`
|
||||
ExtraFields []ConfigField `json:"extra_fields,omitempty"`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 元数据
|
||||
// =============================================================================
|
||||
|
||||
// Metadata 插件元数据
|
||||
type Metadata struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type PluginType `json:"type"`
|
||||
Source PluginSource `json:"source"`
|
||||
RunAt Side `json:"run_at"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author,omitempty"`
|
||||
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
|
||||
RuleSchema *RuleSchema `json:"rule_schema,omitempty"`
|
||||
}
|
||||
|
||||
// Info 插件运行时信息
|
||||
type Info struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LoadedAt time.Time `json:"loaded_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 核心接口
|
||||
// =============================================================================
|
||||
|
||||
// Dialer 网络拨号接口
|
||||
type Dialer interface {
|
||||
Dial(network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// ClientPlugin 客户端插件接口
|
||||
// 运行在客户端,提供本地服务
|
||||
type ClientPlugin interface {
|
||||
Metadata() Metadata
|
||||
Init(config map[string]string) error
|
||||
Start() (localAddr string, err error)
|
||||
HandleConn(conn net.Conn) error
|
||||
Stop() error
|
||||
}
|
||||
@@ -26,34 +26,11 @@ const (
|
||||
MsgTypeProxyConnect uint8 = 9 // 代理连接请求 (SOCKS5/HTTP)
|
||||
MsgTypeProxyResult uint8 = 10 // 代理连接结果
|
||||
|
||||
// Plugin 相关消息
|
||||
MsgTypePluginList uint8 = 20 // 请求/响应可用 plugins
|
||||
MsgTypePluginDownload uint8 = 21 // 请求下载 plugin
|
||||
MsgTypePluginData uint8 = 22 // Plugin 二进制数据(分块)
|
||||
MsgTypePluginReady uint8 = 23 // Plugin 加载确认
|
||||
|
||||
// UDP 相关消息
|
||||
MsgTypeUDPData uint8 = 30 // UDP 数据包
|
||||
|
||||
// 插件安装消息
|
||||
MsgTypeInstallPlugins uint8 = 24 // 服务端推送安装插件列表
|
||||
MsgTypePluginConfig uint8 = 25 // 插件配置同步
|
||||
|
||||
// 客户端插件消息
|
||||
MsgTypeClientPluginStart uint8 = 40 // 启动客户端插件
|
||||
MsgTypeClientPluginStop uint8 = 41 // 停止客户端插件
|
||||
MsgTypeClientPluginStatus uint8 = 42 // 客户端插件状态
|
||||
MsgTypeClientPluginConn uint8 = 43 // 客户端插件连接请求
|
||||
MsgTypePluginStatusQuery uint8 = 44 // 查询所有插件状态
|
||||
MsgTypePluginStatusQueryResp uint8 = 45 // 插件状态查询响应
|
||||
|
||||
// JS 插件动态安装
|
||||
MsgTypeJSPluginInstall uint8 = 50 // 安装 JS 插件
|
||||
MsgTypeJSPluginResult uint8 = 51 // 安装结果
|
||||
|
||||
// 客户端控制消息
|
||||
MsgTypeClientRestart uint8 = 60 // 重启客户端
|
||||
MsgTypePluginConfigUpdate uint8 = 61 // 更新插件配置
|
||||
|
||||
// 更新相关消息
|
||||
MsgTypeUpdateCheck uint8 = 70 // 检查更新请求
|
||||
@@ -68,13 +45,17 @@ const (
|
||||
MsgTypeLogData uint8 = 81 // 日志数据
|
||||
MsgTypeLogStop uint8 = 82 // 停止日志流
|
||||
|
||||
// 插件 API 路由消息
|
||||
MsgTypePluginAPIRequest uint8 = 90 // 插件 API 请求
|
||||
MsgTypePluginAPIResponse uint8 = 91 // 插件 API 响应
|
||||
|
||||
// 系统状态消息
|
||||
MsgTypeSystemStatsRequest uint8 = 100 // 请求系统状态
|
||||
MsgTypeSystemStatsResponse uint8 = 101 // 系统状态响应
|
||||
|
||||
// 截图消息
|
||||
MsgTypeScreenshotRequest uint8 = 102 // 请求截图
|
||||
MsgTypeScreenshotResponse uint8 = 103 // 截图响应
|
||||
|
||||
// Shell 执行消息
|
||||
MsgTypeShellExecuteRequest uint8 = 104 // 执行 Shell 命令
|
||||
MsgTypeShellExecuteResponse uint8 = 105 // Shell 执行结果
|
||||
)
|
||||
|
||||
// Message 基础消息结构
|
||||
@@ -87,6 +68,7 @@ type Message struct {
|
||||
type AuthRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name,omitempty"` // 客户端名称(主机名)
|
||||
OS string `json:"os,omitempty"` // 客户端操作系统
|
||||
Arch string `json:"arch,omitempty"` // 客户端架构
|
||||
Version string `json:"version,omitempty"` // 客户端版本
|
||||
@@ -102,22 +84,17 @@ type AuthResponse struct {
|
||||
// ProxyRule 代理规则
|
||||
type ProxyRule struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Type string `json:"type" yaml:"type"` // 内置: tcp, udp, http, https, websocket; 插件: socks5 等
|
||||
Type string `json:"type" yaml:"type"` // tcp, udp, http, https, socks5
|
||||
LocalIP string `json:"local_ip" yaml:"local_ip"` // tcp/udp 模式使用
|
||||
LocalPort int `json:"local_port" yaml:"local_port"` // tcp/udp 模式使用
|
||||
RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口
|
||||
Enabled *bool `json:"enabled,omitempty" yaml:"enabled"` // 是否启用,默认为 true
|
||||
// Plugin 支持字段
|
||||
PluginID string `json:"plugin_id,omitempty" yaml:"plugin_id"` // 插件实例ID
|
||||
PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"`
|
||||
PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"`
|
||||
PluginConfig map[string]string `json:"plugin_config,omitempty" yaml:"plugin_config"`
|
||||
// HTTP Basic Auth 字段 (用于独立端口模式)
|
||||
// HTTP Basic Auth 字段
|
||||
AuthEnabled bool `json:"auth_enabled,omitempty" yaml:"auth_enabled"`
|
||||
AuthUsername string `json:"auth_username,omitempty" yaml:"auth_username"`
|
||||
AuthPassword string `json:"auth_password,omitempty" yaml:"auth_password"`
|
||||
// 插件管理标记 - 由插件自动创建的规则,不允许手动编辑/删除
|
||||
PluginManaged bool `json:"plugin_managed,omitempty" yaml:"plugin_managed"`
|
||||
// 端口状态: "listening", "failed: <error message>", ""
|
||||
PortStatus string `json:"port_status,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
// IsEnabled 检查规则是否启用,默认为 true
|
||||
@@ -155,60 +132,6 @@ type ProxyConnectResult struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// PluginMetadata Plugin 元数据(协议层)
|
||||
type PluginMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
Size int64 `json:"size"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// PluginListRequest 请求可用 plugins
|
||||
type PluginListRequest struct {
|
||||
ClientVersion string `json:"client_version"`
|
||||
}
|
||||
|
||||
// PluginListResponse 返回可用 plugins
|
||||
type PluginListResponse struct {
|
||||
Plugins []PluginMetadata `json:"plugins"`
|
||||
}
|
||||
|
||||
// PluginDownloadRequest 请求下载 plugin
|
||||
type PluginDownloadRequest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// PluginDataChunk Plugin 二进制数据块
|
||||
type PluginDataChunk struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
Data []byte `json:"data"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// PluginReadyNotification Plugin 加载确认
|
||||
type PluginReadyNotification struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// InstallPluginsRequest 安装插件请求
|
||||
type InstallPluginsRequest struct {
|
||||
Plugins []string `json:"plugins"` // 要安装的插件名称列表
|
||||
}
|
||||
|
||||
// PluginConfigSync 插件配置同步
|
||||
type PluginConfigSync struct {
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
Config map[string]string `json:"config"` // 配置内容
|
||||
}
|
||||
|
||||
// UDPPacket UDP 数据包
|
||||
type UDPPacket struct {
|
||||
RemotePort int `json:"remote_port"` // 服务端监听端口
|
||||
@@ -216,67 +139,6 @@ type UDPPacket struct {
|
||||
Data []byte `json:"data"` // UDP 数据
|
||||
}
|
||||
|
||||
// ClientPluginStartRequest 启动客户端插件请求
|
||||
type ClientPluginStartRequest struct {
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
RuleName string `json:"rule_name"` // 规则名称
|
||||
RemotePort int `json:"remote_port"` // 服务端监听端口
|
||||
Config map[string]string `json:"config"` // 插件配置
|
||||
}
|
||||
|
||||
// ClientPluginStopRequest 停止客户端插件请求
|
||||
type ClientPluginStopRequest struct {
|
||||
PluginID string `json:"plugin_id,omitempty"` // 插件ID(优先使用)
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
RuleName string `json:"rule_name"` // 规则名称
|
||||
}
|
||||
|
||||
// ClientPluginStatusResponse 客户端插件状态响应
|
||||
type ClientPluginStatusResponse struct {
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
RuleName string `json:"rule_name"` // 规则名称
|
||||
Running bool `json:"running"` // 是否运行中
|
||||
LocalAddr string `json:"local_addr"` // 本地监听地址
|
||||
Error string `json:"error"` // 错误信息
|
||||
}
|
||||
|
||||
// ClientPluginConnRequest 客户端插件连接请求
|
||||
type ClientPluginConnRequest struct {
|
||||
PluginID string `json:"plugin_id,omitempty"` // 插件ID(优先使用)
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
RuleName string `json:"rule_name"` // 规则名称
|
||||
}
|
||||
|
||||
// PluginStatusEntry 单个插件状态
|
||||
type PluginStatusEntry struct {
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
Running bool `json:"running"` // 是否运行中
|
||||
}
|
||||
|
||||
// PluginStatusQueryResponse 插件状态查询响应
|
||||
type PluginStatusQueryResponse struct {
|
||||
Plugins []PluginStatusEntry `json:"plugins"` // 所有插件状态
|
||||
}
|
||||
|
||||
// JSPluginInstallRequest JS 插件安装请求
|
||||
type JSPluginInstallRequest struct {
|
||||
PluginID string `json:"plugin_id"` // 插件实例唯一 ID
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
Source string `json:"source"` // JS 源码
|
||||
Signature string `json:"signature"` // 官方签名 (Base64)
|
||||
RuleName string `json:"rule_name"` // 规则名称
|
||||
RemotePort int `json:"remote_port"` // 服务端监听端口
|
||||
Config map[string]string `json:"config"` // 插件配置
|
||||
AutoStart bool `json:"auto_start"` // 是否自动启动
|
||||
}
|
||||
|
||||
// JSPluginInstallResult JS 插件安装结果
|
||||
type JSPluginInstallResult struct {
|
||||
PluginName string `json:"plugin_name"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ClientRestartRequest 客户端重启请求
|
||||
type ClientRestartRequest struct {
|
||||
Reason string `json:"reason,omitempty"` // 重启原因
|
||||
@@ -288,23 +150,6 @@ type ClientRestartResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// PluginConfigUpdateRequest 插件配置更新请求
|
||||
type PluginConfigUpdateRequest struct {
|
||||
PluginID string `json:"plugin_id,omitempty"` // 插件ID(优先使用)
|
||||
PluginName string `json:"plugin_name"` // 插件名称
|
||||
RuleName string `json:"rule_name"` // 规则名称
|
||||
Config map[string]string `json:"config"` // 新配置
|
||||
Restart bool `json:"restart"` // 是否重启插件
|
||||
}
|
||||
|
||||
// PluginConfigUpdateResponse 插件配置更新响应
|
||||
type PluginConfigUpdateResponse struct {
|
||||
PluginName string `json:"plugin_name"`
|
||||
RuleName string `json:"rule_name"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateCheckRequest 更新检查请求
|
||||
type UpdateCheckRequest struct {
|
||||
Component string `json:"component"` // "server" 或 "client"
|
||||
@@ -358,7 +203,7 @@ type LogEntry struct {
|
||||
Timestamp int64 `json:"ts"` // Unix 时间戳 (毫秒)
|
||||
Level string `json:"level"` // 日志级别: debug, info, warn, error
|
||||
Message string `json:"msg"` // 日志消息
|
||||
Source string `json:"src"` // 来源: client, plugin:<name>
|
||||
Source string `json:"src"` // 来源: client
|
||||
}
|
||||
|
||||
// LogData 日志数据
|
||||
@@ -373,25 +218,6 @@ type LogStopRequest struct {
|
||||
SessionID string `json:"session_id"` // 会话 ID
|
||||
}
|
||||
|
||||
// PluginAPIRequest 插件 API 请求
|
||||
type PluginAPIRequest struct {
|
||||
PluginID string `json:"plugin_id"` // 插件实例唯一 ID
|
||||
PluginName string `json:"plugin_name"` // 插件名称 (向后兼容)
|
||||
Method string `json:"method"` // HTTP 方法: GET, POST, PUT, DELETE
|
||||
Path string `json:"path"` // 路由路径
|
||||
Query string `json:"query"` // 查询参数
|
||||
Headers map[string]string `json:"headers"` // 请求头
|
||||
Body string `json:"body"` // 请求体
|
||||
}
|
||||
|
||||
// PluginAPIResponse 插件 API 响应
|
||||
type PluginAPIResponse struct {
|
||||
Status int `json:"status"` // HTTP 状态码
|
||||
Headers map[string]string `json:"headers"` // 响应头
|
||||
Body string `json:"body"` // 响应体
|
||||
Error string `json:"error"` // 错误信息
|
||||
}
|
||||
|
||||
// WriteMessage 写入消息到 writer
|
||||
func WriteMessage(w io.Writer, msg *Message) error {
|
||||
header := make([]byte, HeaderSize)
|
||||
@@ -460,3 +286,30 @@ type SystemStatsResponse struct {
|
||||
DiskUsed uint64 `json:"disk_used"` // 已用磁盘 (字节)
|
||||
DiskUsage float64 `json:"disk_usage"` // 磁盘使用率 (0-100)
|
||||
}
|
||||
|
||||
// ScreenshotRequest 截图请求
|
||||
type ScreenshotRequest struct {
|
||||
Quality int `json:"quality"` // JPEG 质量 1-100, 0 使用默认值
|
||||
}
|
||||
|
||||
// ScreenshotResponse 截图响应
|
||||
type ScreenshotResponse struct {
|
||||
Data string `json:"data"` // Base64 编码的 JPEG 图片
|
||||
Width int `json:"width"` // 图片宽度
|
||||
Height int `json:"height"` // 图片高度
|
||||
Timestamp int64 `json:"timestamp"` // 截图时间戳
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
// ShellExecuteRequest Shell 执行请求
|
||||
type ShellExecuteRequest struct {
|
||||
Command string `json:"command"` // 要执行的命令
|
||||
Timeout int `json:"timeout"` // 超时秒数, 0 使用默认值 (30秒)
|
||||
}
|
||||
|
||||
// ShellExecuteResponse Shell 执行响应
|
||||
type ShellExecuteResponse struct {
|
||||
Output string `json:"output"` // stdout + stderr 组合输出
|
||||
ExitCode int `json:"exit_code"` // 进程退出码
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
@@ -2,20 +2,27 @@ package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gotunnel/pkg/relay"
|
||||
)
|
||||
|
||||
// HTTPServer HTTP 代理服务
|
||||
type HTTPServer struct {
|
||||
dialer Dialer
|
||||
onStats func(in, out int64) // 流量统计回调
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewHTTPServer 创建 HTTP 代理服务
|
||||
func NewHTTPServer(dialer Dialer) *HTTPServer {
|
||||
return &HTTPServer{dialer: dialer}
|
||||
func NewHTTPServer(dialer Dialer, onStats func(in, out int64), username, password string) *HTTPServer {
|
||||
return &HTTPServer{dialer: dialer, onStats: onStats, username: username, password: password}
|
||||
}
|
||||
|
||||
// HandleConn 处理 HTTP 代理连接
|
||||
@@ -28,12 +35,45 @@ func (h *HTTPServer) HandleConn(conn net.Conn) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查认证
|
||||
if h.username != "" && h.password != "" {
|
||||
if !h.checkAuth(req) {
|
||||
conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"proxy\"\r\n\r\n"))
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Method == http.MethodConnect {
|
||||
return h.handleConnect(conn, req)
|
||||
}
|
||||
return h.handleHTTP(conn, req, reader)
|
||||
}
|
||||
|
||||
// checkAuth 检查 Proxy-Authorization 头
|
||||
func (h *HTTPServer) checkAuth(req *http.Request) bool {
|
||||
auth := req.Header.Get("Proxy-Authorization")
|
||||
if auth == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
credentials := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(credentials) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return credentials[0] == h.username && credentials[1] == h.password
|
||||
}
|
||||
|
||||
// handleConnect 处理 CONNECT 方法 (HTTPS)
|
||||
func (h *HTTPServer) handleConnect(conn net.Conn, req *http.Request) error {
|
||||
target := req.Host
|
||||
@@ -50,8 +90,8 @@ func (h *HTTPServer) handleConnect(conn net.Conn, req *http.Request) error {
|
||||
|
||||
conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
||||
|
||||
go io.Copy(remote, conn)
|
||||
io.Copy(conn, remote)
|
||||
// 双向转发 (带流量统计)
|
||||
relay.RelayWithStats(conn, remote, h.onStats)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,7 +122,10 @@ func (h *HTTPServer) handleHTTP(conn net.Conn, req *http.Request, reader *bufio.
|
||||
return err
|
||||
}
|
||||
|
||||
// 转发响应
|
||||
_, err = io.Copy(conn, remote)
|
||||
// 转发响应 (带流量统计)
|
||||
n, err := io.Copy(conn, remote)
|
||||
if h.onStats != nil && n > 0 {
|
||||
h.onStats(0, n) // 响应数据为出站流量
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ type Server struct {
|
||||
}
|
||||
|
||||
// NewServer 创建代理服务器
|
||||
func NewServer(typ string, dialer Dialer) *Server {
|
||||
func NewServer(typ string, dialer Dialer, onStats func(in, out int64), username, password string) *Server {
|
||||
return &Server{
|
||||
socks5: NewSOCKS5Server(dialer),
|
||||
http: NewHTTPServer(dialer),
|
||||
socks5: NewSOCKS5Server(dialer, onStats, username, password),
|
||||
http: NewHTTPServer(dialer, onStats, username, password),
|
||||
typ: typ,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"github.com/gotunnel/pkg/relay"
|
||||
)
|
||||
|
||||
const (
|
||||
socks5Version = 0x05
|
||||
noAuth = 0x00
|
||||
userPassAuth = 0x02
|
||||
cmdConnect = 0x01
|
||||
atypIPv4 = 0x01
|
||||
atypDomain = 0x03
|
||||
@@ -20,6 +23,9 @@ const (
|
||||
// SOCKS5Server SOCKS5 代理服务
|
||||
type SOCKS5Server struct {
|
||||
dialer Dialer
|
||||
onStats func(in, out int64) // 流量统计回调
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// Dialer 连接拨号器接口
|
||||
@@ -28,8 +34,8 @@ type Dialer interface {
|
||||
}
|
||||
|
||||
// NewSOCKS5Server 创建 SOCKS5 服务
|
||||
func NewSOCKS5Server(dialer Dialer) *SOCKS5Server {
|
||||
return &SOCKS5Server{dialer: dialer}
|
||||
func NewSOCKS5Server(dialer Dialer, onStats func(in, out int64), username, password string) *SOCKS5Server {
|
||||
return &SOCKS5Server{dialer: dialer, onStats: onStats, username: username, password: password}
|
||||
}
|
||||
|
||||
// HandleConn 处理 SOCKS5 连接
|
||||
@@ -60,9 +66,8 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 双向转发
|
||||
go io.Copy(remote, conn)
|
||||
io.Copy(conn, remote)
|
||||
// 双向转发 (带流量统计)
|
||||
relay.RelayWithStats(conn, remote, s.onStats)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -83,11 +88,54 @@ func (s *SOCKS5Server) handshake(conn net.Conn) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 响应:使用无认证
|
||||
// 如果配置了用户名密码,要求认证
|
||||
if s.username != "" && s.password != "" {
|
||||
_, err := conn.Write([]byte{socks5Version, userPassAuth})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.authenticate(conn)
|
||||
}
|
||||
|
||||
// 无认证
|
||||
_, err := conn.Write([]byte{socks5Version, noAuth})
|
||||
return err
|
||||
}
|
||||
|
||||
// authenticate 处理用户名密码认证
|
||||
func (s *SOCKS5Server) authenticate(conn net.Conn) error {
|
||||
buf := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return err
|
||||
}
|
||||
if buf[0] != 0x01 {
|
||||
return errors.New("unsupported auth version")
|
||||
}
|
||||
|
||||
ulen := int(buf[1])
|
||||
username := make([]byte, ulen)
|
||||
if _, err := io.ReadFull(conn, username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plen := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, plen); err != nil {
|
||||
return err
|
||||
}
|
||||
password := make([]byte, plen[0])
|
||||
if _, err := io.ReadFull(conn, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if string(username) == s.username && string(password) == s.password {
|
||||
conn.Write([]byte{0x01, 0x00}) // 认证成功
|
||||
return nil
|
||||
}
|
||||
|
||||
conn.Write([]byte{0x01, 0x01}) // 认证失败
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
|
||||
// readRequest 读取请求
|
||||
func (s *SOCKS5Server) readRequest(conn net.Conn) (string, error) {
|
||||
buf := make([]byte, 4)
|
||||
|
||||
82
pkg/utils/screenshot_desktop.go
Normal file
82
pkg/utils/screenshot_desktop.go
Normal file
@@ -0,0 +1,82 @@
|
||||
//go:build windows || linux || darwin
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/kbinani/screenshot"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
opts := &jpeg.Options{Quality: quality}
|
||||
if err := jpeg.Encode(&buf, img, opts); err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("encode jpeg: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), bounds.Dx(), bounds.Dy(), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
opts := &jpeg.Options{Quality: quality}
|
||||
if err := jpeg.Encode(&buf, totalImg, opts); err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("encode jpeg: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), totalBounds.Dx(), totalBounds.Dy(), nil
|
||||
}
|
||||
15
pkg/utils/screenshot_stub.go
Normal file
15
pkg/utils/screenshot_stub.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = @(
|
||||
$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
|
||||
}
|
||||
|
||||
179
scripts/build.sh
179
scripts/build.sh
@@ -1,27 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# 项目根目录
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="$ROOT_DIR/build"
|
||||
export GOCACHE="${GOCACHE:-$BUILD_DIR/.gocache}"
|
||||
|
||||
# 版本信息
|
||||
VERSION="${VERSION:-dev}"
|
||||
BUILD_TIME=$(date -u '+%Y-%m-%d %H:%M:%S')
|
||||
GIT_COMMIT=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
# 默认目标平台
|
||||
DEFAULT_PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64"
|
||||
|
||||
# 是否启用 UPX 压缩
|
||||
BUILD_TIME="$(date -u '+%Y-%m-%d %H:%M:%S')"
|
||||
GIT_COMMIT="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
USE_UPX="${USE_UPX:-true}"
|
||||
|
||||
# 颜色输出
|
||||
DESKTOP_PLATFORMS="linux/amd64 linux/arm64 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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
server: "127.0.0.1:7000"
|
||||
token: "testtoken"
|
||||
id: "testclient"
|
||||
7
web/package-lock.json
generated
7
web/package-lock.json
generated
@@ -1217,7 +1217,6 @@
|
||||
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1518,7 +1517,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2296,7 +2294,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2323,7 +2320,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2444,7 +2440,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2497,7 +2492,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -2579,7 +2573,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-sfc": "3.5.27",
|
||||
|
||||
1002
web/src/App.vue
1002
web/src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { get, post, put, del, getToken } from '../config/axios'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions, ConfigField } from '../types'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, LogEntry, LogStreamOptions, InstallCommandResponse } from '../types'
|
||||
|
||||
// 重新导出 token 管理方法
|
||||
export { getToken, setToken, removeToken } from '../config/axios'
|
||||
@@ -24,91 +24,6 @@ export const reloadConfig = () => post('/config/reload')
|
||||
export const pushConfigToClient = (id: string) => post(`/client/${id}/push`)
|
||||
export const disconnectClient = (id: string) => post(`/client/${id}/disconnect`)
|
||||
export const restartClient = (id: string) => post(`/client/${id}/restart`)
|
||||
export const installPluginsToClient = (id: string, plugins: string[]) =>
|
||||
post(`/client/${id}/install-plugins`, { plugins })
|
||||
|
||||
// 规则配置模式
|
||||
export const getRuleSchemas = () => get<RuleSchemasMap>('/rule-schemas')
|
||||
|
||||
// 客户端插件控制(使用 pluginID)
|
||||
export const startClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
|
||||
post(`/client/${clientId}/plugin/${pluginId}/start`, { rule_name: ruleName })
|
||||
export const stopClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
|
||||
post(`/client/${clientId}/plugin/${pluginId}/stop`, { rule_name: ruleName })
|
||||
export const restartClientPlugin = (clientId: string, pluginId: string, ruleName: string) =>
|
||||
post(`/client/${clientId}/plugin/${pluginId}/restart`, { rule_name: ruleName })
|
||||
export const deleteClientPlugin = (clientId: string, pluginId: string) =>
|
||||
post(`/client/${clientId}/plugin/${pluginId}/delete`)
|
||||
export const updateClientPluginConfigWithRestart = (clientId: string, pluginId: string, ruleName: string, config: Record<string, string>, restart: boolean) =>
|
||||
post(`/client/${clientId}/plugin/${pluginId}/config`, { rule_name: ruleName, config, restart })
|
||||
|
||||
// 插件管理
|
||||
export const getPlugins = () => get<PluginInfo[]>('/plugins')
|
||||
export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`)
|
||||
export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
|
||||
|
||||
// 扩展商店
|
||||
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins')
|
||||
export const installStorePlugin = (
|
||||
pluginName: string,
|
||||
downloadUrl: string,
|
||||
signatureUrl: string,
|
||||
clientId: string,
|
||||
remotePort?: number,
|
||||
version?: string,
|
||||
configSchema?: ConfigField[],
|
||||
authEnabled?: boolean,
|
||||
authUsername?: string,
|
||||
authPassword?: string
|
||||
) =>
|
||||
post('/store/install', {
|
||||
plugin_name: pluginName,
|
||||
version: version || '',
|
||||
download_url: downloadUrl,
|
||||
signature_url: signatureUrl,
|
||||
client_id: clientId,
|
||||
remote_port: remotePort || 0,
|
||||
config_schema: configSchema || [],
|
||||
auth_enabled: authEnabled || false,
|
||||
auth_username: authUsername || '',
|
||||
auth_password: authPassword || ''
|
||||
})
|
||||
|
||||
// 客户端插件配置
|
||||
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
|
||||
get<PluginConfigResponse>(`/client-plugin/${clientId}/${pluginName}/config`)
|
||||
export const updateClientPluginConfig = (clientId: string, pluginName: string, config: Record<string, string>) =>
|
||||
put(`/client-plugin/${clientId}/${pluginName}/config`, { config })
|
||||
|
||||
// JS 插件管理
|
||||
export const getJSPlugins = () => get<JSPlugin[]>('/js-plugins')
|
||||
export const createJSPlugin = (plugin: JSPlugin) => post('/js-plugins', plugin)
|
||||
export const getJSPlugin = (name: string) => get<JSPlugin>(`/js-plugin/${name}`)
|
||||
export const updateJSPlugin = (name: string, plugin: JSPlugin) => put(`/js-plugin/${name}`, plugin)
|
||||
export const deleteJSPlugin = (name: string) => del(`/js-plugin/${name}`)
|
||||
export const pushJSPluginToClient = (pluginName: string, clientId: string, remotePort?: number) =>
|
||||
post(`/js-plugin/${pluginName}/push/${clientId}`, { remote_port: remotePort || 0 })
|
||||
export const updateJSPluginConfig = (name: string, config: Record<string, string>) =>
|
||||
put(`/js-plugin/${name}/config`, { config })
|
||||
export const setJSPluginEnabled = (name: string, enabled: boolean) =>
|
||||
post(`/js-plugin/${name}/${enabled ? 'enable' : 'disable'}`)
|
||||
|
||||
// 插件 API 代理(通过 pluginID 调用插件自定义 API)
|
||||
export const callPluginAPI = <T = any>(clientId: string, pluginId: string, method: string, route: string, body?: any) => {
|
||||
const path = `/client/${clientId}/plugin-api/${pluginId}${route.startsWith('/') ? route : '/' + route}`
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
return get<T>(path)
|
||||
case 'POST':
|
||||
return post<T>(path, body)
|
||||
case 'PUT':
|
||||
return put<T>(path, body)
|
||||
case 'DELETE':
|
||||
return del<T>(path)
|
||||
default:
|
||||
return get<T>(path)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新管理
|
||||
export interface UpdateInfo {
|
||||
@@ -209,6 +124,28 @@ export interface SystemStats {
|
||||
|
||||
export const getClientSystemStats = (clientId: string) => get<SystemStats>(`/client/${clientId}/system-stats`)
|
||||
|
||||
// 客户端截图
|
||||
export interface ScreenshotData {
|
||||
data: string // Base64 JPEG
|
||||
width: number
|
||||
height: number
|
||||
timestamp: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const getClientScreenshot = (clientId: string, quality?: number) =>
|
||||
get<ScreenshotData>(`/client/${clientId}/screenshot${quality ? '?quality=' + quality : ''}`)
|
||||
|
||||
// Shell 执行
|
||||
export interface ShellResult {
|
||||
output: string
|
||||
exit_code: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const executeClientShell = (clientId: string, command: string, timeout?: number) =>
|
||||
post<ShellResult>(`/client/${clientId}/shell`, { command, timeout: timeout || 30 })
|
||||
|
||||
// 服务器配置
|
||||
export interface ServerConfigInfo {
|
||||
bind_addr: string
|
||||
@@ -225,21 +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 = () =>
|
||||
post<InstallCommandResponse>('/install/generate')
|
||||
|
||||
@@ -134,9 +134,9 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.inline-log-panel {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--glass-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -145,8 +145,8 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--glass-bg-light);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
@@ -158,7 +158,7 @@ onUnmounted(() => {
|
||||
.log-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.streaming-badge {
|
||||
@@ -166,8 +166,8 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--color-success);
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
@@ -176,7 +176,7 @@ onUnmounted(() => {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
background: var(--color-success);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -192,11 +192,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--color-border);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
@@ -205,8 +205,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
@@ -219,14 +219,14 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-scroll-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #a78bfa;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.log-content {
|
||||
@@ -235,6 +235,7 @@ onUnmounted(() => {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.log-loading,
|
||||
@@ -243,7 +244,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -259,7 +260,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -269,12 +270,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.log-src {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-msg {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -289,11 +290,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.log-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.log-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
62
web/src/components/MetricCard.vue
Normal file
62
web/src/components/MetricCard.vue
Normal 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>
|
||||
154
web/src/components/PageShell.vue
Normal file
154
web/src/components/PageShell.vue
Normal 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>
|
||||
79
web/src/components/SectionCard.vue
Normal file
79
web/src/components/SectionCard.vue
Normal 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>
|
||||
@@ -6,43 +6,6 @@ export interface ProxyRule {
|
||||
remote_port: number
|
||||
type?: string
|
||||
enabled?: boolean
|
||||
plugin_config?: Record<string, string>
|
||||
plugin_managed?: boolean // 插件管理标记 - 由插件自动创建的规则
|
||||
}
|
||||
|
||||
// 客户端已安装的插件
|
||||
export interface ClientPlugin {
|
||||
id: string // 插件实例唯一 ID
|
||||
name: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
config?: Record<string, string>
|
||||
remote_port?: number // 远程监听端口
|
||||
}
|
||||
|
||||
// 插件配置字段
|
||||
export interface ConfigField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'string' | 'number' | 'bool' | 'select' | 'password'
|
||||
default?: string
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 规则表单模式
|
||||
export interface RuleSchema {
|
||||
needs_local_addr: boolean
|
||||
extra_fields?: ConfigField[]
|
||||
}
|
||||
|
||||
// 插件配置响应
|
||||
export interface PluginConfigResponse {
|
||||
plugin_name: string
|
||||
schema: ConfigField[]
|
||||
config: Record<string, string>
|
||||
}
|
||||
|
||||
// 客户端配置
|
||||
@@ -50,7 +13,6 @@ export interface ClientConfig {
|
||||
id: string
|
||||
nickname?: string
|
||||
rules: ProxyRule[]
|
||||
plugins?: ClientPlugin[]
|
||||
}
|
||||
|
||||
// 客户端状态
|
||||
@@ -70,7 +32,6 @@ export interface ClientDetail {
|
||||
id: string
|
||||
nickname?: string
|
||||
rules: ProxyRule[]
|
||||
plugins?: ClientPlugin[]
|
||||
online: boolean
|
||||
last_ping?: string
|
||||
remote_addr?: string
|
||||
@@ -88,64 +49,12 @@ export interface ServerStatus {
|
||||
client_count: number
|
||||
}
|
||||
|
||||
// 插件类型
|
||||
export const PluginType = {
|
||||
Proxy: 'proxy',
|
||||
App: 'app',
|
||||
Service: 'service',
|
||||
Tool: 'tool'
|
||||
} as const
|
||||
|
||||
export type PluginTypeValue = typeof PluginType[keyof typeof PluginType]
|
||||
|
||||
// 插件信息
|
||||
export interface PluginInfo {
|
||||
name: string
|
||||
version: string
|
||||
type: string
|
||||
description: string
|
||||
source: string
|
||||
icon?: string
|
||||
enabled: boolean
|
||||
rule_schema?: RuleSchema
|
||||
}
|
||||
|
||||
// 扩展商店插件信息
|
||||
export interface StorePluginInfo {
|
||||
name: string
|
||||
version: string
|
||||
type: string
|
||||
description: string
|
||||
author: string
|
||||
icon?: string
|
||||
download_url?: string
|
||||
signature_url?: string
|
||||
config_schema?: ConfigField[]
|
||||
}
|
||||
|
||||
// JS 插件信息
|
||||
export interface JSPlugin {
|
||||
name: string
|
||||
source: string
|
||||
signature?: string
|
||||
description: string
|
||||
author: string
|
||||
version?: string
|
||||
auto_push: string[]
|
||||
config: Record<string, string>
|
||||
auto_start: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 规则配置模式集合
|
||||
export type RuleSchemasMap = Record<string, RuleSchema>
|
||||
|
||||
// 日志条目
|
||||
export interface LogEntry {
|
||||
ts: number // Unix 时间戳 (毫秒)
|
||||
level: string // 日志级别: debug, info, warn, error
|
||||
msg: string // 日志消息
|
||||
src: string // 来源: client, plugin:<name>
|
||||
src: string // 来源: client
|
||||
}
|
||||
|
||||
// 日志流选项
|
||||
@@ -154,3 +63,10 @@ export interface LogStreamOptions {
|
||||
follow?: boolean // 是否持续推送
|
||||
level?: string // 日志级别过滤
|
||||
}
|
||||
|
||||
// 安装命令响应
|
||||
export interface InstallCommandResponse {
|
||||
token: string
|
||||
expires_at: number
|
||||
tunnel_port: number
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user